diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 21f9786..742c511 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,6 +27,7 @@ jobs:
worker-docker: ${{ steps.filter.outputs.worker-docker }}
worker-sys: ${{ steps.filter.outputs.worker-sys }}
worker-boxlite: ${{ steps.filter.outputs.worker-boxlite }}
+ worker-bridge-e2b: ${{ steps.filter.outputs.worker-bridge-e2b }}
web: ${{ steps.filter.outputs.web }}
website: ${{ steps.filter.outputs.website }}
steps:
@@ -56,6 +57,9 @@ jobs:
worker-boxlite:
- *shared
- 'worker/worker-boxlite/**'
+ worker-bridge-e2b:
+ - *shared
+ - 'worker/worker-bridge-e2b/**'
web:
- '.github/workflows/ci.yml'
- 'web/**'
@@ -174,6 +178,30 @@ jobs:
- name: Unit tests
run: cargo test
+ worker-bridge-e2b:
+ name: Check worker-bridge-e2b
+ needs: changes
+ if: needs.changes.outputs.worker-bridge-e2b == 'true'
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./worker/worker-bridge-e2b
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: worker/worker-bridge-e2b/go.mod
+ cache-dependency-path: worker/worker-bridge-e2b/go.sum
+
+ - name: Vet
+ run: go vet ./...
+
+ - name: Unit tests
+ run: go test -race ./...
+
web:
name: Check web
needs: changes
@@ -274,6 +302,7 @@ jobs:
- worker-docker
- worker-sys
- worker-boxlite
+ - worker-bridge-e2b
- web
- website
runs-on: ubuntu-latest
diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml
index ba8a8fe..4fd4008 100644
--- a/.github/workflows/package-release.yml
+++ b/.github/workflows/package-release.yml
@@ -370,6 +370,105 @@ jobs:
)
gh release upload "${VERSION_RAW}" "build/${WORKER_BIN}.zip" --clobber
+ build-worker-bridge-e2b:
+ name: worker-bridge-e2b (${{ matrix.os_name }}/${{ matrix.goarch }})
+ runs-on: ubuntu-24.04
+ needs:
+ - prepare-release
+ - create-draft-release
+ defaults:
+ run:
+ shell: bash
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ os_name: linux
+ goos: linux
+ goarch: amd64
+ - arch: arm64
+ os_name: linux
+ goos: linux
+ goarch: arm64
+ - arch: x64
+ os_name: macos
+ goos: darwin
+ goarch: amd64
+ - arch: arm64
+ os_name: macos
+ goos: darwin
+ goarch: arm64
+ env:
+ VERSION_RAW: ${{ needs.prepare-release.outputs.version_raw }}
+ VERSION_SAFE: ${{ needs.prepare-release.outputs.version_safe }}
+ GOOS: ${{ matrix.goos }}
+ GOARCH: ${{ matrix.goarch }}
+ WORKER_BIN: onlyboxes-worker-bridge-e2b_${{ needs.prepare-release.outputs.version_safe }}_${{ matrix.os_name }}_${{ matrix.goarch }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: worker/worker-bridge-e2b/go.mod
+ cache-dependency-path: worker/worker-bridge-e2b/go.sum
+
+ - name: Build worker binary
+ run: |
+ set -euo pipefail
+ mkdir -p build
+ (
+ cd worker/worker-bridge-e2b
+ CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -trimpath \
+ -ldflags "-X github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/buildinfo.Version=${VERSION_RAW}" \
+ -o "../../build/${WORKER_BIN}" \
+ ./cmd/worker-bridge-e2b
+ )
+
+ - name: Validate worker binary architecture
+ run: |
+ set -euo pipefail
+ worker_info="$(file "build/${WORKER_BIN}")"
+ echo "${worker_info}"
+ case "${GOOS}/${GOARCH}" in
+ linux/amd64)
+ grep -Eq "ELF 64-bit.*x86-64" <<< "${worker_info}"
+ ;;
+ linux/arm64)
+ grep -Eq "ELF 64-bit.*ARM aarch64" <<< "${worker_info}"
+ ;;
+ darwin/amd64)
+ grep -Eq "Mach-O 64-bit.*x86_64" <<< "${worker_info}"
+ ;;
+ darwin/arm64)
+ grep -Eq "Mach-O 64-bit.*arm64" <<< "${worker_info}"
+ ;;
+ *)
+ echo "unsupported target: ${GOOS}/${GOARCH}" >&2
+ exit 1
+ ;;
+ esac
+
+ - name: Upload worker workflow artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: onlyboxes-worker-bridge-e2b-${{ matrix.os_name }}-${{ matrix.arch }}-${{ env.VERSION_SAFE }}.zip
+ path: build/${{ env.WORKER_BIN }}
+ if-no-files-found: error
+
+ - name: Upload worker release asset
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ (
+ cd build
+ zip -q -j "${WORKER_BIN}.zip" "${WORKER_BIN}"
+ )
+ gh release upload "${VERSION_RAW}" "build/${WORKER_BIN}.zip" --clobber
+
build-worker-sys:
name: worker-sys (${{ matrix.os_name }}/${{ matrix.goarch }})
runs-on: ubuntu-24.04
@@ -802,6 +901,7 @@ jobs:
- prepare-release
- build-console
- build-worker-docker
+ - build-worker-bridge-e2b
- build-worker-sys
- build-worker-boxlite
- push-console-image
diff --git a/static/architecture-dark.svg b/static/architecture-dark.svg
index 2d35806..6b8dfde 100644
--- a/static/architecture-dark.svg
+++ b/static/architecture-dark.svg
@@ -1,4 +1,4 @@
-
diff --git a/static/architecture.svg b/static/architecture.svg
index 2141a7d..7f41b64 100644
--- a/static/architecture.svg
+++ b/static/architecture.svg
@@ -1,4 +1,4 @@
-
+
@@ -19,11 +19,13 @@
-
-
-
-
-
+
+
+
+
+
+
+
@@ -53,6 +55,8 @@
+
+
@@ -79,17 +83,23 @@
-
-
+
+
+
+
@@ -108,15 +118,18 @@
-
+
+
-
+
+
-
+
+
@@ -199,7 +212,7 @@
-
+
@@ -209,7 +222,19 @@
WORKER NODE
-
+
+
+
+
+
+
+
+
+
+ BRIDGE WORKER
+
+
+
@@ -238,7 +263,7 @@
-
+
@@ -250,6 +275,18 @@
+
+
+
+
+
+
+
+ TARGET
+ E2B Sandboxes
+
+
+
@@ -261,4 +298,4 @@
OS Processes (WIP)
-
\ No newline at end of file
+
diff --git a/static/architecture.zh-CN-dark.svg b/static/architecture.zh-CN-dark.svg
index e9e27f4..4fe1906 100644
--- a/static/architecture.zh-CN-dark.svg
+++ b/static/architecture.zh-CN-dark.svg
@@ -1,4 +1,4 @@
-
+
@@ -19,11 +19,13 @@
-
-
-
-
-
+
+
+
+
+
+
+
@@ -53,6 +55,8 @@
+
+
@@ -78,17 +82,23 @@
-
-
+
+
+
+
@@ -107,15 +117,18 @@
-
+
+
-
+
+
-
+
+
@@ -197,7 +210,7 @@
-
+
@@ -207,7 +220,19 @@
执行节点
-
+
+
+
+
+
+
+
+
+
+ 桥接执行节点
+
+
+
@@ -236,7 +261,7 @@
-
+
@@ -248,6 +273,18 @@
+
+
+
+
+
+
+
+ 执行环境
+ E2B 沙箱
+
+
+
@@ -259,4 +296,4 @@
操作系统进程(WIP)
-
\ No newline at end of file
+
diff --git a/static/architecture.zh-CN.svg b/static/architecture.zh-CN.svg
index 3f7839e..e18652f 100644
--- a/static/architecture.zh-CN.svg
+++ b/static/architecture.zh-CN.svg
@@ -1,4 +1,4 @@
-
+
@@ -19,11 +19,13 @@
-
-
-
-
-
+
+
+
+
+
+
+
@@ -53,6 +55,8 @@
+
+
@@ -79,17 +83,23 @@
-
-
+
+
+
+
@@ -108,15 +118,18 @@
-
+
+
-
+
+
-
+
+
@@ -199,7 +212,7 @@
-
+
@@ -209,7 +222,19 @@
执行节点
-
+
+
+
+
+
+
+
+
+
+ 桥接执行节点
+
+
+
@@ -238,7 +263,7 @@
-
+
@@ -250,6 +275,18 @@
+
+
+
+
+
+
+
+ 执行环境
+ E2B 沙箱
+
+
+
@@ -261,4 +298,4 @@
操作系统进程(WIP)
-
\ No newline at end of file
+
diff --git a/website/package.json b/website/package.json
index df25ceb..2ebcc59 100644
--- a/website/package.json
+++ b/website/package.json
@@ -21,6 +21,7 @@
"tailwindcss": "^4.2.2"
},
"devDependencies": {
+ "@shikijs/rehype": "^4.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
diff --git a/website/public/docs-assets/architecture.svg b/website/public/docs-assets/architecture.svg
deleted file mode 100644
index 2141a7d..0000000
--- a/website/public/docs-assets/architecture.svg
+++ /dev/null
@@ -1,264 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- CLIENT
- Developer
-
-
-
-
-
-
-
-
-
-
- CLIENT
- API Client
-
-
-
-
-
-
-
-
-
-
-
-
-
- CLIENT
- MCP Client
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Console
-
-
-
-
-
-
-
-
-
-
-
-
-
- WORKER NODE
-
-
-
-
-
-
-
-
-
-
- WORKER NODE
-
-
-
-
-
-
-
-
-
-
-
-
- WORKER NODE
-
-
-
-
-
-
-
-
-
-
-
-
-
- TARGET
- Docker Containers
-
-
-
-
-
-
-
-
-
-
- TARGET
- Boxlite Envs
-
-
-
-
-
-
-
-
-
-
- TARGET
- OS Processes (WIP)
-
-
-
\ No newline at end of file
diff --git a/website/public/docs-assets/architecture.zh-CN.svg b/website/public/docs-assets/architecture.zh-CN.svg
deleted file mode 100644
index 3f7839e..0000000
--- a/website/public/docs-assets/architecture.zh-CN.svg
+++ /dev/null
@@ -1,264 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 客户端
- 开发者
-
-
-
-
-
-
-
-
-
-
- 客户端
- API 客户端
-
-
-
-
-
-
-
-
-
-
-
-
-
- 客户端
- MCP 客户端
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 控制台
-
-
-
-
-
-
-
-
-
-
-
-
-
- 执行节点
-
-
-
-
-
-
-
-
-
-
- 执行节点
-
-
-
-
-
-
-
-
-
-
-
-
- 执行节点
-
-
-
-
-
-
-
-
-
-
-
-
-
- 执行环境
- Docker 容器
-
-
-
-
-
-
-
-
-
-
- 执行环境
- Boxlite 环境
-
-
-
-
-
-
-
-
-
-
- 执行环境
- 操作系统进程(WIP)
-
-
-
\ No newline at end of file
diff --git a/website/src/docs/en/architecture.mdx b/website/src/docs/en/architecture.mdx
index e2375bf..bf94777 100644
--- a/website/src/docs/en/architecture.mdx
+++ b/website/src/docs/en/architecture.mdx
@@ -1,3 +1,5 @@
+import { ArchitectureDiagram } from '../../features/site/ArchitectureDiagram'
+
export const meta = {
title: 'Architecture',
description: 'How the control node and workers are connected.',
@@ -10,7 +12,7 @@ export const meta = {
OnlyBoxes uses a control-plane and execution-plane split.
-
+
## Control node
@@ -38,6 +40,7 @@ Workers are responsible for the actual execution environment. A worker connects
| --- | --- | --- | --- |
| `worker-docker` | runc | Docker containers | General sandboxed code execution |
| `worker-boxlite` | KVM | [boxlite](https://boxlite.ai/) | Specialized runtime backend |
+| `worker-bridge-e2b` | E2B | Remote E2B sandboxes | Managed remote sandbox execution |
| `worker-sys` | - | Host OS processes | Real-device or direct host control |
## Deployment pattern
diff --git a/website/src/docs/en/overview.mdx b/website/src/docs/en/overview.mdx
index 92224b7..1d15185 100644
--- a/website/src/docs/en/overview.mdx
+++ b/website/src/docs/en/overview.mdx
@@ -37,7 +37,7 @@ For admin users, OnlyBoxes additionally provides:
| --- | --- |
| Deployment | No external dependencies, control plane and execution plane can be deployed independently |
| Scalability | Multiple heterogeneous workers can be added horizontally |
-| Runtime model | Docker, boxlite, and OS-process based execution backends |
+| Runtime model | Docker, boxlite, E2B, and OS-process based execution backends |
| Interfaces | REST API, MCP endpoint |
| Management | Dashboard, Open API |
@@ -47,6 +47,7 @@ For admin users, OnlyBoxes additionally provides:
- `worker-*`: execution nodes, responsible for task execution.
- `worker-docker`: a worker implementation backed by Docker containers.
- `worker-boxlite`: a worker implementation backed by boxlite.
+ - `worker-bridge-e2b`: a worker implementation backed by remote E2B sandboxes.
- `worker-sys`: a worker implementation backed by host OS processes for direct device control scenarios.
## Typical usage
diff --git a/website/src/docs/en/worker-bridge-e2b.mdx b/website/src/docs/en/worker-bridge-e2b.mdx
new file mode 100644
index 0000000..937f21d
--- /dev/null
+++ b/website/src/docs/en/worker-bridge-e2b.mdx
@@ -0,0 +1,134 @@
+export const meta = {
+ title: 'E2B Bridge',
+ description: 'Run OnlyBoxes executions in remote E2B sandboxes.',
+ section: 'Runtimes',
+ order: 45,
+ slug: 'worker-bridge-e2b',
+}
+
+# E2B Bridge Runtime
+
+`worker-bridge-e2b` connects an OnlyBoxes control node to remote E2B sandboxes. The worker manages sessions and task dispatch locally, while E2B provides the execution environment.
+
+## When to use
+
+- You want managed remote sandboxes without running Docker or KVM on the worker host
+- You need E2B templates tailored to different Python and terminal workloads
+- You accept the network latency and external service dependency of a remote execution backend
+
+## How it works
+
+1. The worker connects to the OnlyBoxes control node over gRPC.
+2. It creates, extends, and destroys sandboxes through the E2B Control API.
+3. It executes commands and reads files through envd inside each sandbox.
+4. `pythonExec` uses a new sandbox per request; terminal sessions reuse a sandbox until their lease expires.
+
+## Prerequisites
+
+- An E2B API key
+- An E2B Python template with Python and `uv`
+- An E2B terminal template with `/bin/bash` and `python3`
+- Network access to the OnlyBoxes control node and E2B endpoints
+
+## Starting the worker
+
+Download the matching `worker-bridge-e2b` binary from the [GitHub releases page](https://github.com/Coooolfan/onlyboxes/releases/latest), then run:
+
+```bash
+WORKER_CONSOLE_GRPC_TARGET= \
+WORKER_ID= \
+WORKER_SECRET= \
+WORKER_E2B_API_KEY= \
+WORKER_E2B_PYTHON_TEMPLATE= \
+WORKER_E2B_TERMINAL_TEMPLATE= \
+./onlyboxes-worker-bridge-e2b
+```
+
+Workers require TLS by default. Set `WORKER_CONSOLE_INSECURE=true` only for a trusted plaintext development connection.
+
+## Configuration file
+
+Set `WORKER_CONFIG_FILE` to use a specific `config.toml`. Otherwise, the worker looks next to its binary and then in the current working directory.
+
+Configuration precedence is: `WORKER_*` environment variable, E2B-compatible environment variable alias, `config.toml`, then built-in default.
+
+```toml
+id = "wk_..."
+secret = "..."
+console_grpc_target = "console.internal:50051"
+e2b_api_key = "..."
+e2b_python_template = "python-template"
+e2b_terminal_template = "terminal-template"
+
+[labels]
+region = "us"
+```
+
+See the [annotated configuration template](https://github.com/Coooolfan/onlyboxes/blob/main/worker/worker-bridge-e2b/config.example.toml) for all available keys.
+
+## Environment variables
+
+### Connection and identity
+
+| Variable | Required | Default | Description |
+| --- | --- | --- | --- |
+| `WORKER_ID` | Yes | | Worker ID from the dashboard |
+| `WORKER_SECRET` | Yes | | One-time secret from worker creation |
+| `WORKER_CONSOLE_GRPC_TARGET` | No | `127.0.0.1:50051` | Control node gRPC address |
+| `WORKER_CONSOLE_INSECURE` | No | `false` | Set `true` only to allow a trusted non-TLS connection |
+| `WORKER_NODE_NAME` | No | generated | Human-readable node name |
+| `WORKER_LABELS` | No | | JSON or comma-separated `key=value` labels |
+
+### E2B
+
+| Variable | Required | Default | Description |
+| --- | --- | --- | --- |
+| `WORKER_E2B_API_KEY` | Yes | | E2B Control API key; alias: `E2B_API_KEY` |
+| `WORKER_E2B_PYTHON_TEMPLATE` | Yes | | Template used by `pythonExec`; alias: `E2B_PYTHON_EXEC_TEMPLATE` |
+| `WORKER_E2B_TERMINAL_TEMPLATE` | Yes | | Template used by terminal sessions; alias: `E2B_TERMINAL_EXEC_TEMPLATE` |
+| `WORKER_E2B_API_URL` | No | `https://api.e2b.app` | Control API base URL; alias: `E2B_API_URL` |
+| `WORKER_E2B_DOMAIN` | No | `e2b.app` | Sandbox domain suffix; alias: `E2B_DOMAIN` |
+| `WORKER_E2B_SANDBOX_URL` | No | | Override the envd base URL; alias: `E2B_SANDBOX_URL` |
+| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | No | `60` | Control API and envd connection timeout |
+| `WORKER_E2B_PYTHON_TIMEOUT_SEC` | No | `300` | Lifetime of a one-shot Python sandbox; alias: `E2B_SANDBOX_TIMEOUT_SEC` |
+
+### Terminal
+
+| Variable | Required | Default | Description |
+| --- | --- | --- | --- |
+| `WORKER_TERMINAL_LEASE_MIN_SEC` | No | `60` | Minimum requested session lease |
+| `WORKER_TERMINAL_LEASE_MAX_SEC` | No | `1800` | Maximum requested session lease |
+| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | No | `300` | Default session lease |
+| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | No | `1048576` | Limit for each output stream and file reads |
+| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | No | `0` (unlimited) | Maximum exported file size |
+| `WORKER_TERMINAL_EXPORT_MODE` | No | `sandbox` | `sandbox` uploads directly from E2B; `worker` proxies file bytes |
+| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | No | `128` | Concurrent operations allowed in one session |
+
+Terminal commands share the sandbox filesystem but run in separate `/bin/bash -l -c` processes. They do not preserve the current directory, shell variables, or process environment between calls.
+
+### Capability concurrency
+
+| Variable | Required | Default | Description |
+| --- | --- | --- | --- |
+| `WORKER_ECHO_MAX_INFLIGHT` | No | `128` | Concurrent `echo` calls |
+| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | No | `32` | Concurrent `pythonExec` calls |
+| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | No | `64` | Concurrent `terminalExec` calls |
+| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | No | `128` | Concurrent `terminalResource` calls |
+
+### Heartbeat and logging
+
+| Variable | Required | Default | Description |
+| --- | --- | --- | --- |
+| `WORKER_HEARTBEAT_INTERVAL_SEC` | No | `5` | Heartbeat interval in seconds |
+| `WORKER_HEARTBEAT_JITTER_PCT` | No | `20` | Heartbeat jitter percentage |
+| `WORKER_CALL_TIMEOUT_SEC` | No | `ceil(2.5 * interval)` | Timeout for console hello and heartbeat acknowledgements |
+| `WORKER_LOG_LEVEL` | No | `info` | `debug` / `info` / `warn` / `error` |
+| `WORKER_LOG_FORMAT` | No | `json` | `json` / `text` |
+| `WORKER_LOG_ADD_SOURCE` | No | `false` | Include source file and line |
+
+## Related docs
+
+- [Architecture](/en/docs/architecture)
+- [Docker Runtime](/en/docs/worker-docker)
+- [Boxlite Runtime](/en/docs/worker-boxlite)
+- [System Process Runtime](/en/docs/worker-sys)
diff --git a/website/src/docs/zh-CN/architecture.mdx b/website/src/docs/zh-CN/architecture.mdx
index 9d13500..01e342f 100644
--- a/website/src/docs/zh-CN/architecture.mdx
+++ b/website/src/docs/zh-CN/architecture.mdx
@@ -1,3 +1,5 @@
+import { ArchitectureDiagram } from '../../features/site/ArchitectureDiagram'
+
export const meta = {
title: '架构',
description: '控制节点与执行节点的连接方式。',
@@ -10,7 +12,7 @@ export const meta = {
OnlyBoxes 采用控制面与执行面分离的架构。
-
+
## 控制节点
@@ -38,6 +40,7 @@ worker 负责真正的执行环境。worker 会通过 gRPC 连接控制节点,
| --- | --- | --- | --- |
| `worker-docker` | runc | Docker 容器 | 通用代码沙箱执行 |
| `worker-boxlite` | KVM | [boxlite](https://boxlite.ai/) | 特定运行时后端 |
+| `worker-bridge-e2b` | E2B | 远程 E2B 沙箱 | 托管式远程沙箱执行 |
| `worker-sys` | - | 宿主机进程 | 真实设备或直接主机控制 |
## 推荐部署方式
diff --git a/website/src/docs/zh-CN/overview.mdx b/website/src/docs/zh-CN/overview.mdx
index aad347d..3bf9365 100644
--- a/website/src/docs/zh-CN/overview.mdx
+++ b/website/src/docs/zh-CN/overview.mdx
@@ -37,7 +37,7 @@ OnlyBoxes 是一个面向个人与小型团队的自托管代码执行沙箱平
| --- | --- |
| 部署形态 | 无外部依赖,控制面与执行面可独立部署 |
| 扩展方式 | 支持横向增加多个异构 worker |
-| 运行时模型 | Docker、boxlite、操作系统进程 |
+| 运行时模型 | Docker、boxlite、E2B、操作系统进程 |
| 接口形式 | REST API、MCP endpoint |
| 管理能力 | Dashboard、Open API |
@@ -47,6 +47,7 @@ OnlyBoxes 是一个面向个人与小型团队的自托管代码执行沙箱平
- `worker-*`:执行节点,负责具体任务执行。
- `worker-docker`:基于 Docker 容器的 worker 实现。
- `worker-boxlite`:基于 boxlite 的 worker 实现。
+ - `worker-bridge-e2b`:基于远程 E2B 沙箱的 worker 实现。
- `worker-sys`:基于宿主机进程的 worker 实现,适合真实设备或主机控制场景。
## 典型使用流程
diff --git a/website/src/docs/zh-CN/worker-bridge-e2b.mdx b/website/src/docs/zh-CN/worker-bridge-e2b.mdx
new file mode 100644
index 0000000..5b53d7e
--- /dev/null
+++ b/website/src/docs/zh-CN/worker-bridge-e2b.mdx
@@ -0,0 +1,134 @@
+export const meta = {
+ title: 'E2B Bridge',
+ description: '通过远程 E2B 沙箱运行 OnlyBoxes 执行任务。',
+ section: '运行时',
+ order: 45,
+ slug: 'worker-bridge-e2b',
+}
+
+# E2B Bridge 运行时
+
+`worker-bridge-e2b` 将 OnlyBoxes 控制节点连接到远程 E2B 沙箱。worker 在本地管理 session 与任务分发,由 E2B 提供实际执行环境。
+
+## 适用场景
+
+- 希望使用托管的远程沙箱,不在 worker 主机上运行 Docker 或 KVM
+- 需要为 Python 与终端任务准备不同的 E2B 模板
+- 可以接受远程执行带来的网络延迟和外部服务依赖
+
+## 工作原理
+
+1. Worker 通过 gRPC 连接 OnlyBoxes 控制节点。
+2. Worker 通过 E2B Control API 创建、续期和销毁沙箱。
+3. 命令执行和文件读取通过沙箱内的 envd 完成。
+4. `pythonExec` 每次请求使用一个新沙箱;`terminalExec` 会话在租约到期前复用同一沙箱。
+
+## 前置条件
+
+- E2B API Key
+- 提供 Python 和 `uv` 的 E2B Python 模板
+- 提供 `/bin/bash` 和 `python3` 的 E2B 终端模板
+- 能够访问 OnlyBoxes 控制节点与 E2B 端点
+
+## 启动 worker
+
+从 [GitHub Releases 页面](https://github.com/Coooolfan/onlyboxes/releases/latest) 下载与系统架构匹配的 `worker-bridge-e2b` 二进制,然后运行:
+
+```bash
+WORKER_CONSOLE_GRPC_TARGET= \
+WORKER_ID= \
+WORKER_SECRET= \
+WORKER_E2B_API_KEY= \
+WORKER_E2B_PYTHON_TEMPLATE= \
+WORKER_E2B_TERMINAL_TEMPLATE= \
+./onlyboxes-worker-bridge-e2b
+```
+
+worker 默认要求 TLS。只有在可信的明文开发链路中才应设置 `WORKER_CONSOLE_INSECURE=true`。
+
+## 配置文件
+
+设置 `WORKER_CONFIG_FILE` 可以显式指定 `config.toml`;未设置时,worker 会依次查找程序文件同目录和当前工作目录下的 `config.toml`。
+
+配置优先级为:`WORKER_*` 环境变量、E2B 兼容环境变量别名、`config.toml`、内置默认值。
+
+```toml
+id = "wk_..."
+secret = "..."
+console_grpc_target = "console.internal:50051"
+e2b_api_key = "..."
+e2b_python_template = "python-template"
+e2b_terminal_template = "terminal-template"
+
+[labels]
+region = "cn"
+```
+
+全部配置项见[带注释的配置模板](https://github.com/Coooolfan/onlyboxes/blob/main/worker/worker-bridge-e2b/config.example.toml)。
+
+## 环境变量
+
+### 连接与身份
+
+| 变量 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `WORKER_ID` | 是 | | Dashboard 中的 Worker ID |
+| `WORKER_SECRET` | 是 | | 创建 worker 时的一次性密钥 |
+| `WORKER_CONSOLE_GRPC_TARGET` | 否 | `127.0.0.1:50051` | 控制节点 gRPC 地址 |
+| `WORKER_CONSOLE_INSECURE` | 否 | `false` | 仅在可信环境中设为 `true` 以允许非 TLS 连接 |
+| `WORKER_NODE_NAME` | 否 | 自动生成 | 可读的节点名称 |
+| `WORKER_LABELS` | 否 | | JSON 或逗号分隔的 `key=value` 标签 |
+
+### E2B
+
+| 变量 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `WORKER_E2B_API_KEY` | 是 | | E2B Control API Key;别名:`E2B_API_KEY` |
+| `WORKER_E2B_PYTHON_TEMPLATE` | 是 | | `pythonExec` 使用的模板;别名:`E2B_PYTHON_EXEC_TEMPLATE` |
+| `WORKER_E2B_TERMINAL_TEMPLATE` | 是 | | 终端 session 使用的模板;别名:`E2B_TERMINAL_EXEC_TEMPLATE` |
+| `WORKER_E2B_API_URL` | 否 | `https://api.e2b.app` | Control API 基础 URL;别名:`E2B_API_URL` |
+| `WORKER_E2B_DOMAIN` | 否 | `e2b.app` | 沙箱域名后缀;别名:`E2B_DOMAIN` |
+| `WORKER_E2B_SANDBOX_URL` | 否 | | 覆盖 envd 基础 URL;别名:`E2B_SANDBOX_URL` |
+| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | 否 | `60` | Control API 与 envd 建连超时 |
+| `WORKER_E2B_PYTHON_TIMEOUT_SEC` | 否 | `300` | 一次性 Python 沙箱的生命周期;别名:`E2B_SANDBOX_TIMEOUT_SEC` |
+
+### 终端
+
+| 变量 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `WORKER_TERMINAL_LEASE_MIN_SEC` | 否 | `60` | 请求可指定的最短 session 租约 |
+| `WORKER_TERMINAL_LEASE_MAX_SEC` | 否 | `1800` | 请求可指定的最长 session 租约 |
+| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | 否 | `300` | 默认 session 租约 |
+| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | 否 | `1048576` | 每路输出流与文件读取的大小限制 |
+| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | 否 | `0`(无限制) | 导出文件大小上限 |
+| `WORKER_TERMINAL_EXPORT_MODE` | 否 | `sandbox` | `sandbox` 由 E2B 直接上传;`worker` 由 worker 转发文件 |
+| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | 否 | `128` | 单个 session 允许的并发操作数 |
+
+终端命令共享沙箱文件系统,但每次调用都在独立的 `/bin/bash -l -c` 进程中执行,不会在调用之间保留当前目录、shell 变量或进程环境。
+
+### 能力并发
+
+| 变量 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `WORKER_ECHO_MAX_INFLIGHT` | 否 | `128` | `echo` 并发调用数 |
+| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | 否 | `32` | `pythonExec` 并发调用数 |
+| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | 否 | `64` | `terminalExec` 并发调用数 |
+| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | 否 | `128` | `terminalResource` 并发调用数 |
+
+### 心跳与日志
+
+| 变量 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `WORKER_HEARTBEAT_INTERVAL_SEC` | 否 | `5` | 心跳间隔秒数 |
+| `WORKER_HEARTBEAT_JITTER_PCT` | 否 | `20` | 心跳抖动百分比 |
+| `WORKER_CALL_TIMEOUT_SEC` | 否 | `ceil(2.5 * interval)` | console hello 与心跳确认超时 |
+| `WORKER_LOG_LEVEL` | 否 | `info` | `debug` / `info` / `warn` / `error` |
+| `WORKER_LOG_FORMAT` | 否 | `json` | `json` / `text` |
+| `WORKER_LOG_ADD_SOURCE` | 否 | `false` | 是否包含源文件和行号 |
+
+## 相关文档
+
+- [架构](/zh-CN/docs/architecture)
+- [Docker 运行时](/zh-CN/docs/worker-docker)
+- [Boxlite 运行时](/zh-CN/docs/worker-boxlite)
+- [系统进程运行时](/zh-CN/docs/worker-sys)
diff --git a/website/src/features/docs/__tests__/docs-router.spec.tsx b/website/src/features/docs/__tests__/docs-router.spec.tsx
index 2b5f8ed..4bdd74c 100644
--- a/website/src/features/docs/__tests__/docs-router.spec.tsx
+++ b/website/src/features/docs/__tests__/docs-router.spec.tsx
@@ -50,6 +50,7 @@ describe('docs routing', () => {
'quick-start',
'worker-docker',
'worker-boxlite',
+ 'worker-bridge-e2b',
'worker-sys',
'console-api',
'mcp-tools',
@@ -77,6 +78,44 @@ describe('docs routing', () => {
expect(view.getByRole('link', { name: '简体中文' })).toHaveAttribute('href', '/zh-CN/docs/quick-start/')
})
+ it('renders build-time syntax highlighting without changing copied code', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ })
+
+ const view = renderRoutes('/en/docs/worker-bridge-e2b')
+
+ expect(await view.findByRole('heading', { name: 'E2B Bridge Runtime' })).toBeInTheDocument()
+
+ const codeBlock = view.container.querySelector('pre.shiki')
+ const tokens = codeBlock?.querySelectorAll('.line > span') ?? []
+
+ expect(codeBlock).not.toBeNull()
+ expect(codeBlock).toHaveClass('github-light', 'github-dark')
+ expect(tokens.length).toBeGreaterThan(3)
+ expect(tokens[0]?.style.getPropertyValue('--shiki-light')).not.toBe('')
+ expect(tokens[0]?.style.getPropertyValue('--shiki-dark')).not.toBe('')
+
+ const code = codeBlock?.textContent ?? ''
+ const copyButton = codeBlock?.parentElement?.querySelector('button[aria-label="Copy code"]')
+
+ expect(copyButton).not.toBeNull()
+ fireEvent.click(copyButton!)
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(code))
+
+ fireEvent.click(view.getByRole('button', { name: 'Toggle theme' }))
+ expect(view.container.querySelector('[data-docs-theme="dark"]')).not.toBeNull()
+
+ if (originalClipboard) {
+ Object.defineProperty(navigator, 'clipboard', originalClipboard)
+ } else {
+ Reflect.deleteProperty(navigator, 'clipboard')
+ }
+ })
+
it('highlights every toc section whose content is currently visible', async () => {
const rects = new WeakMap()
const defaultRect = new DOMRect(0, 0, 0, 0)
diff --git a/website/src/features/site/ArchitectureDiagram.tsx b/website/src/features/site/ArchitectureDiagram.tsx
new file mode 100644
index 0000000..103b7f5
--- /dev/null
+++ b/website/src/features/site/ArchitectureDiagram.tsx
@@ -0,0 +1,39 @@
+import architectureDarkUrl from '../../../../static/architecture-dark.svg?url'
+import architectureUrl from '../../../../static/architecture.svg?url'
+import architectureZhCnDarkUrl from '../../../../static/architecture.zh-CN-dark.svg?url'
+import architectureZhCnUrl from '../../../../static/architecture.zh-CN.svg?url'
+import { useSiteContext } from './SiteContext'
+
+interface ArchitectureDiagramProps {
+ className?: string
+ eager?: boolean
+}
+
+const architectureUrls = {
+ en: {
+ light: architectureUrl,
+ dark: architectureDarkUrl,
+ },
+ 'zh-CN': {
+ light: architectureZhCnUrl,
+ dark: architectureZhCnDarkUrl,
+ },
+} as const
+
+export function ArchitectureDiagram({ className = '', eager = false }: ArchitectureDiagramProps) {
+ const { isDark, locale } = useSiteContext()
+ const src = architectureUrls[locale][isDark ? 'dark' : 'light']
+ const alt = locale === 'zh-CN' ? 'OnlyBoxes 架构图' : 'OnlyBoxes architecture'
+
+ return (
+
+ )
+}
diff --git a/website/src/index.css b/website/src/index.css
index 24b4a8b..5a8d816 100644
--- a/website/src/index.css
+++ b/website/src/index.css
@@ -24,7 +24,7 @@ button {
/* Code block scrollbar */
pre {
scrollbar-width: thin;
- scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
+ scrollbar-color: var(--ob-pre-scrollbar) transparent;
}
pre::-webkit-scrollbar {
@@ -36,18 +36,18 @@ pre::-webkit-scrollbar-track {
}
pre::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.15);
+ background: var(--ob-pre-scrollbar);
border-radius: 3px;
}
pre::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.25);
+ background: var(--ob-pre-scrollbar-hover);
}
/* Code block selection */
pre ::selection {
- background: rgba(255, 255, 255, 0.2);
- color: #ffffff;
+ background: var(--ob-pre-selection);
+ color: inherit;
}
/* Docs theme tokens — light */
@@ -59,8 +59,11 @@ pre ::selection {
--ob-line: #e5e5e5;
--ob-code-bg: #f5f5f5;
--ob-code-text: #171717;
- --ob-pre-bg: #0a0a0a;
- --ob-pre-text: #f5f5f5;
+ --ob-pre-bg: #f6f8fa;
+ --ob-pre-text: #24292f;
+ --ob-pre-scrollbar: rgba(31, 35, 40, 0.18);
+ --ob-pre-scrollbar-hover: rgba(31, 35, 40, 0.3);
+ --ob-pre-selection: rgba(9, 105, 218, 0.22);
--ob-blockquote-bg: #fafafa;
--ob-blockquote-border: #e5e5e5;
--ob-blockquote-text: #525252;
@@ -85,6 +88,9 @@ pre ::selection {
--ob-code-text: #e5e5e5;
--ob-pre-bg: #141414;
--ob-pre-text: #e5e5e5;
+ --ob-pre-scrollbar: rgba(255, 255, 255, 0.15);
+ --ob-pre-scrollbar-hover: rgba(255, 255, 255, 0.25);
+ --ob-pre-selection: rgba(255, 255, 255, 0.2);
--ob-blockquote-bg: #141414;
--ob-blockquote-border: #262626;
--ob-blockquote-text: #a3a3a3;
@@ -92,3 +98,20 @@ pre ::selection {
--ob-table-border: #262626;
--ob-table-text: #a3a3a3;
}
+
+/* Shiki emits both token palettes at build time; the docs theme selects one. */
+[data-docs-theme="light"] .shiki,
+[data-docs-theme="light"] .shiki span {
+ color: var(--shiki-light);
+ font-style: var(--shiki-light-font-style);
+ font-weight: var(--shiki-light-font-weight);
+ text-decoration: var(--shiki-light-text-decoration);
+}
+
+[data-docs-theme="dark"] .shiki,
+[data-docs-theme="dark"] .shiki span {
+ color: var(--shiki-dark);
+ font-style: var(--shiki-dark-font-style);
+ font-weight: var(--shiki-dark-font-weight);
+ text-decoration: var(--shiki-dark-text-decoration);
+}
diff --git a/website/src/pages/HomePage.tsx b/website/src/pages/HomePage.tsx
index d18bba9..cff81e1 100644
--- a/website/src/pages/HomePage.tsx
+++ b/website/src/pages/HomePage.tsx
@@ -1,23 +1,17 @@
-import { type ElementType, useEffect, useState } from 'react'
+import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
BookOpen,
- Box,
Check,
Copy,
- Cpu,
Github,
Languages,
Moon,
- Package,
- Blocks,
- Server,
Sun,
- Terminal,
- User,
} from 'lucide-react'
import { useSiteContext } from '../features/site/SiteContext'
-import { type DocsLocale, docsLocaleLabels, getDocsRootHref, getOtherDocsLocale } from '../features/docs/registry'
+import { ArchitectureDiagram } from '../features/site/ArchitectureDiagram'
+import { docsLocaleLabels, getDocsRootHref, getOtherDocsLocale } from '../features/docs/registry'
const INSTALL_CMD = 'curl -fsSL https://onlybox.es/install.sh | bash'
@@ -31,16 +25,6 @@ const homeCopy = {
subtitle: 'A self-hosted code execution sandbox platform for individuals and small teams.',
documentation: 'Documentation',
github: 'GitHub',
- architecture: 'Architecture',
- developer: 'Developer',
- apiClient: 'API Client',
- mcpClient: 'MCP Client',
- client: 'Client',
- execution: 'Execution',
- workerNode: 'Worker Node',
- environment: 'Environment',
- osProcess: 'OS Process',
- console: 'Console',
},
'zh-CN': {
rotatingTexts: [
@@ -51,21 +35,9 @@ const homeCopy = {
subtitle: '面向个人与小型团队的自托管代码执行沙箱平台。',
documentation: '文档',
github: 'GitHub',
- architecture: '架构',
- developer: '开发者',
- apiClient: 'API 客户端',
- mcpClient: 'MCP 客户端',
- client: '客户端',
- execution: '执行层',
- workerNode: '执行节点',
- environment: '运行环境',
- osProcess: '系统进程',
- console: '控制节点',
},
} as const
-type HomeCopy = (typeof homeCopy)[DocsLocale]
-
const RotatingText = ({ texts }: { texts: readonly string[] }) => {
const [index, setIndex] = useState(0)
const [fade, setFade] = useState(true)
@@ -95,313 +67,6 @@ const RotatingText = ({ texts }: { texts: readonly string[] }) => {
)
}
-const FlowLine = ({ d, isDark }: { d: string; isDark: boolean }) => (
-
-
-
-
-
-
-)
-
-interface SvgNodeProps {
- x: number
- y: number
- width?: number
- height?: number
- title: string
- subtitle?: string
- icon?: ElementType
- dashed?: boolean
- active?: boolean
- isDark: boolean
-}
-
-const SvgNode = ({
- x,
- y,
- width = 220,
- height = 56,
- title,
- subtitle,
- icon: Icon,
- dashed = false,
- active = false,
- isDark,
-}: SvgNodeProps) => (
-
-
-
- {Icon ? (
-
-
-
- ) : null}
- {subtitle ? (
-
- {subtitle}
-
- ) : null}
-
- {title}
-
-
-)
-
-const ArchitectureDiagram = ({ isDark, copy }: { isDark: boolean; copy: HomeCopy }) => {
- const [isCompact, setIsCompact] = useState(false)
-
- useEffect(() => {
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
- return
- }
-
- const mediaQuery = window.matchMedia('(max-width: 767px)')
- setIsCompact(mediaQuery.matches)
- const handler = (event: MediaQueryListEvent) => setIsCompact(event.matches)
- mediaQuery.addEventListener('change', handler)
- return () => mediaQuery.removeEventListener('change', handler)
- }, [])
-
- const clientWidth = isCompact ? 160 : 220
- const clientRight = 20 + clientWidth
- const consoleX = isCompact ? 300 : 320
- const consoleRight = consoleX + 120
- const clientMid = Math.round((clientRight + consoleX) / 2)
- const environmentX = isCompact ? 540 : 820
- const environmentWidth = isCompact ? 160 : 220
- const nextX = isCompact ? environmentX : 520
- const consoleMid = Math.round((consoleRight + nextX) / 2)
-
- return (
-
-
- {copy.architecture}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!isCompact ? (
- <>
-
-
-
- >
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
- {copy.console}
-
-
-
- {!isCompact ? (
- <>
-
-
-
- >
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!isCompact ? (
- <>
-
-
-
-
-
-
- >
- ) : null}
-
-
-
- )
-}
-
const InstallCommand = ({ isDark }: { isDark: boolean }) => {
const [copied, setCopied] = useState(false)
@@ -527,7 +192,7 @@ function HomePage() {
}`}
/>
diff --git a/website/vite.config.ts b/website/vite.config.ts
index c652ab8..73c0669 100644
--- a/website/vite.config.ts
+++ b/website/vite.config.ts
@@ -5,12 +5,30 @@ import mdx from '@mdx-js/rollup'
import tailwindcss from '@tailwindcss/vite'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
+import rehypeShiki from '@shikijs/rehype'
import fs from 'node:fs'
import path from 'node:path'
const mdxPlugin = mdx({
remarkPlugins: [remarkGfm],
- rehypePlugins: [rehypeSlug],
+ rehypePlugins: [
+ rehypeSlug,
+ [
+ rehypeShiki,
+ {
+ themes: {
+ light: 'github-light',
+ dark: 'github-dark',
+ },
+ defaultColor: false,
+ defaultLanguage: 'text',
+ fallbackLanguage: 'text',
+ langAlias: {
+ env: 'dotenv',
+ },
+ },
+ ],
+ ],
}) as Plugin
mdxPlugin.enforce = 'pre'
diff --git a/website/yarn.lock b/website/yarn.lock
index 9f094c1..cfe985f 100644
--- a/website/yarn.lock
+++ b/website/yarn.lock
@@ -759,6 +759,100 @@ __metadata:
languageName: node
linkType: hard
+"@shikijs/core@npm:4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/core@npm:4.3.1"
+ dependencies:
+ "@shikijs/primitive": "npm:4.3.1"
+ "@shikijs/types": "npm:4.3.1"
+ "@shikijs/vscode-textmate": "npm:^10.0.2"
+ "@types/hast": "npm:^3.0.4"
+ hast-util-to-html: "npm:^9.0.5"
+ checksum: 10c0/75faa9aba4251e412764844d2329862350ef31ac21d0f07658b8a975029bd25f192ef069e757e6689a8bd9f1b19999e6f47b308ce1dee7c352543f435dd5d189
+ languageName: node
+ linkType: hard
+
+"@shikijs/engine-javascript@npm:4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/engine-javascript@npm:4.3.1"
+ dependencies:
+ "@shikijs/types": "npm:4.3.1"
+ "@shikijs/vscode-textmate": "npm:^10.0.2"
+ oniguruma-to-es: "npm:^4.3.6"
+ checksum: 10c0/683312ded6648c6a1bfc003d1b628dda293128ad6eebbd2f4627c900ee3951cc606343fc4e87d79e6c4f32f1a51bc66b2d0998f7bcd75da803a7364c7272ffa1
+ languageName: node
+ linkType: hard
+
+"@shikijs/engine-oniguruma@npm:4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/engine-oniguruma@npm:4.3.1"
+ dependencies:
+ "@shikijs/types": "npm:4.3.1"
+ "@shikijs/vscode-textmate": "npm:^10.0.2"
+ checksum: 10c0/a6522fc5a55966a811b20396bdf1678f89e4fcd8e41f07dc6532acf7b111a161fad0656eb0dc1855275fdf7107890099c373bd23eb200e2145fe725cc91e8544
+ languageName: node
+ linkType: hard
+
+"@shikijs/langs@npm:4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/langs@npm:4.3.1"
+ dependencies:
+ "@shikijs/types": "npm:4.3.1"
+ checksum: 10c0/89785ca417021780891598b1eacfbe89272fa068bd02215dbc6945553e5e7164a3825c2f09285d2809003e3421d577366a2fc19b7996e1a0621a2adfb73cad12
+ languageName: node
+ linkType: hard
+
+"@shikijs/primitive@npm:4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/primitive@npm:4.3.1"
+ dependencies:
+ "@shikijs/types": "npm:4.3.1"
+ "@shikijs/vscode-textmate": "npm:^10.0.2"
+ "@types/hast": "npm:^3.0.4"
+ checksum: 10c0/33397d0d60842d74ce07db588dec3818241713c4812de6fe76c40a477e464d319f20f50f3dfa40ebc2c10964754a256d9bb4ac6f8b49a09fca1bc206a7c7b46f
+ languageName: node
+ linkType: hard
+
+"@shikijs/rehype@npm:^4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/rehype@npm:4.3.1"
+ dependencies:
+ "@shikijs/types": "npm:4.3.1"
+ "@types/hast": "npm:^3.0.4"
+ hast-util-to-string: "npm:^3.0.1"
+ shiki: "npm:4.3.1"
+ unified: "npm:^11.0.5"
+ unist-util-visit: "npm:^5.1.0"
+ checksum: 10c0/3221a74cf1dd0ea4283f39b082aa13d7b515e0c4b98c58b713c956fd50cb72267870355f92a6f5a22e9cf93c8a1b99ee98c0fe14a614c2cbd28808c259daa788
+ languageName: node
+ linkType: hard
+
+"@shikijs/themes@npm:4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/themes@npm:4.3.1"
+ dependencies:
+ "@shikijs/types": "npm:4.3.1"
+ checksum: 10c0/3d578a981630e880fa18bbab14ab81e61308163c10e1967fb40fcce9d6e321d340595c7ade66275a0e80a6f3e5417ac5785052aacd7ae19c9e67bbb06eb0f278
+ languageName: node
+ linkType: hard
+
+"@shikijs/types@npm:4.3.1":
+ version: 4.3.1
+ resolution: "@shikijs/types@npm:4.3.1"
+ dependencies:
+ "@shikijs/vscode-textmate": "npm:^10.0.2"
+ "@types/hast": "npm:^3.0.4"
+ checksum: 10c0/e1586b7e8fc0610f9896c622307274c5876fe7f2a40919e08cde8947ef5022d545641c47763f29505eaad6e4b4f7c180890fd1c220146e5e75f69f384a4dc228
+ languageName: node
+ linkType: hard
+
+"@shikijs/vscode-textmate@npm:^10.0.2":
+ version: 10.0.2
+ resolution: "@shikijs/vscode-textmate@npm:10.0.2"
+ checksum: 10c0/36b682d691088ec244de292dc8f91b808f95c89466af421cf84cbab92230f03c8348649c14b3251991b10ce632b0c715e416e992dd5f28ff3221dc2693fd9462
+ languageName: node
+ linkType: hard
+
"@standard-schema/spec@npm:^1.1.0":
version: 1.1.0
resolution: "@standard-schema/spec@npm:1.1.0"
@@ -1047,6 +1141,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/hast@npm:^3.0.4":
+ version: 3.0.5
+ resolution: "@types/hast@npm:3.0.5"
+ dependencies:
+ "@types/unist": "npm:*"
+ checksum: 10c0/f3af8594a6903a507ed191eda944af18099198d6708c29102ae17118c3e20779f6929e91ed37e033541fd28d58055d8a5910a4366dbdf976cf81b13a464741fb
+ languageName: node
+ linkType: hard
+
"@types/mdast@npm:^4.0.0":
version: 4.0.4
resolution: "@types/mdast@npm:4.0.4"
@@ -1778,6 +1881,25 @@ __metadata:
languageName: node
linkType: hard
+"hast-util-to-html@npm:^9.0.5":
+ version: 9.0.5
+ resolution: "hast-util-to-html@npm:9.0.5"
+ dependencies:
+ "@types/hast": "npm:^3.0.0"
+ "@types/unist": "npm:^3.0.0"
+ ccount: "npm:^2.0.0"
+ comma-separated-tokens: "npm:^2.0.0"
+ hast-util-whitespace: "npm:^3.0.0"
+ html-void-elements: "npm:^3.0.0"
+ mdast-util-to-hast: "npm:^13.0.0"
+ property-information: "npm:^7.0.0"
+ space-separated-tokens: "npm:^2.0.0"
+ stringify-entities: "npm:^4.0.0"
+ zwitch: "npm:^2.0.4"
+ checksum: 10c0/b7a08c30bab4371fc9b4a620965c40b270e5ae7a8e94cf885f43b21705179e28c8e43b39c72885d1647965fb3738654e6962eb8b58b0c2a84271655b4d748836
+ languageName: node
+ linkType: hard
+
"hast-util-to-jsx-runtime@npm:^2.0.0":
version: 2.3.6
resolution: "hast-util-to-jsx-runtime@npm:2.3.6"
@@ -1801,7 +1923,7 @@ __metadata:
languageName: node
linkType: hard
-"hast-util-to-string@npm:^3.0.0":
+"hast-util-to-string@npm:^3.0.0, hast-util-to-string@npm:^3.0.1":
version: 3.0.1
resolution: "hast-util-to-string@npm:3.0.1"
dependencies:
@@ -1828,6 +1950,13 @@ __metadata:
languageName: node
linkType: hard
+"html-void-elements@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "html-void-elements@npm:3.0.0"
+ checksum: 10c0/a8b9ec5db23b7c8053876dad73a0336183e6162bf6d2677376d8b38d654fdc59ba74fdd12f8812688f7db6fad451210c91b300e472afc0909224e0a44c8610d2
+ languageName: node
+ linkType: hard
+
"http-cache-semantics@npm:^4.1.1":
version: 4.2.0
resolution: "http-cache-semantics@npm:4.2.0"
@@ -3002,11 +3131,30 @@ __metadata:
languageName: node
linkType: hard
+"oniguruma-parser@npm:^0.12.2":
+ version: 0.12.2
+ resolution: "oniguruma-parser@npm:0.12.2"
+ checksum: 10c0/fe5255d2cd5a6b845d5a0abe1725898ef40cea5522290dba6ccc08cc388891e9e8007af4baa5059942b786ad44b2b677ef25948039c9ba3f057bd35d2c52076a
+ languageName: node
+ linkType: hard
+
+"oniguruma-to-es@npm:^4.3.6":
+ version: 4.3.6
+ resolution: "oniguruma-to-es@npm:4.3.6"
+ dependencies:
+ oniguruma-parser: "npm:^0.12.2"
+ regex: "npm:^6.1.0"
+ regex-recursion: "npm:^6.0.2"
+ checksum: 10c0/044e08b98e706987c2882ccf228de2a671de4aff33e3bd370da137ba599c0d520549625ad26ecc8898502445c991e19f106f584e94382262ba2af02c908ca3cf
+ languageName: node
+ linkType: hard
+
"onlyboxes-website@workspace:.":
version: 0.0.0-use.local
resolution: "onlyboxes-website@workspace:."
dependencies:
"@mdx-js/rollup": "npm:^3.1.1"
+ "@shikijs/rehype": "npm:^4.3.1"
"@tailwindcss/vite": "npm:^4.2.2"
"@testing-library/dom": "npm:^10.4.1"
"@testing-library/jest-dom": "npm:^6.9.1"
@@ -3255,6 +3403,31 @@ __metadata:
languageName: node
linkType: hard
+"regex-recursion@npm:^6.0.2":
+ version: 6.0.2
+ resolution: "regex-recursion@npm:6.0.2"
+ dependencies:
+ regex-utilities: "npm:^2.3.0"
+ checksum: 10c0/68e8b6889680e904b75d7f26edaf70a1a4dc1087406bff53face4c2929d918fd77c72223843fe816ac8ed9964f96b4160650e8d5909e26a998c6e9de324dadb1
+ languageName: node
+ linkType: hard
+
+"regex-utilities@npm:^2.3.0":
+ version: 2.3.0
+ resolution: "regex-utilities@npm:2.3.0"
+ checksum: 10c0/78c550a80a0af75223244fff006743922591bd8f61d91fef7c86b9b56cf9bbf8ee5d7adb6d8991b5e304c57c90103fc4818cf1e357b11c6c669b782839bd7893
+ languageName: node
+ linkType: hard
+
+"regex@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "regex@npm:6.1.0"
+ dependencies:
+ regex-utilities: "npm:^2.3.0"
+ checksum: 10c0/6e0ee2a1c17d5a66dc1120dfc51899dedf6677857e83a0df4d5a822ebb8645a54a079772efc1ade382b67aad35e4e22b5bd2d33c05ed28b0e000f8f57eb0aec1
+ languageName: node
+ linkType: hard
+
"rehype-recma@npm:^1.0.0":
version: 1.0.0
resolution: "rehype-recma@npm:1.0.0"
@@ -3591,6 +3764,22 @@ __metadata:
languageName: node
linkType: hard
+"shiki@npm:4.3.1":
+ version: 4.3.1
+ resolution: "shiki@npm:4.3.1"
+ dependencies:
+ "@shikijs/core": "npm:4.3.1"
+ "@shikijs/engine-javascript": "npm:4.3.1"
+ "@shikijs/engine-oniguruma": "npm:4.3.1"
+ "@shikijs/langs": "npm:4.3.1"
+ "@shikijs/themes": "npm:4.3.1"
+ "@shikijs/types": "npm:4.3.1"
+ "@shikijs/vscode-textmate": "npm:^10.0.2"
+ "@types/hast": "npm:^3.0.4"
+ checksum: 10c0/b25fbd2fd65a69a680c9be4b38712e3864402dfa08d0343ce1f10de73cb930295d8dd768f7d6857b877f8240f42f1c6771d9c20301007b1eb4dcdb5fb082f6b7
+ languageName: node
+ linkType: hard
+
"siginfo@npm:^2.0.0":
version: 2.0.0
resolution: "siginfo@npm:2.0.0"
@@ -3863,7 +4052,7 @@ __metadata:
languageName: node
linkType: hard
-"unified@npm:^11.0.0":
+"unified@npm:^11.0.0, unified@npm:^11.0.5":
version: 11.0.5
resolution: "unified@npm:11.0.5"
dependencies:
@@ -3924,7 +4113,7 @@ __metadata:
languageName: node
linkType: hard
-"unist-util-visit@npm:^5.0.0":
+"unist-util-visit@npm:^5.0.0, unist-util-visit@npm:^5.1.0":
version: 5.1.0
resolution: "unist-util-visit@npm:5.1.0"
dependencies:
@@ -4216,7 +4405,7 @@ __metadata:
languageName: node
linkType: hard
-"zwitch@npm:^2.0.0":
+"zwitch@npm:^2.0.0, zwitch@npm:^2.0.4":
version: 2.0.4
resolution: "zwitch@npm:2.0.4"
checksum: 10c0/3c7830cdd3378667e058ffdb4cf2bb78ac5711214e2725900873accb23f3dfe5f9e7e5a06dcdc5f29605da976fc45c26d9a13ca334d6eea2245a15e77b8fc06e
diff --git a/worker/worker-bridge-e2b/README/config-file.md b/worker/worker-bridge-e2b/README/config-file.md
new file mode 100644
index 0000000..9b7120c
--- /dev/null
+++ b/worker/worker-bridge-e2b/README/config-file.md
@@ -0,0 +1,105 @@
+# E2B Bridge 配置参数
+
+worker 按以下顺序查找 `config.toml`:
+
+1. `WORKER_CONFIG_FILE` 指定的路径;文件缺失或格式错误时启动失败。
+2. worker 可执行文件旁的 `config.toml`。
+3. 当前工作目录的 `config.toml`。
+
+单项配置的取值优先级为:
+
+1. `WORKER_*` 环境变量。
+2. 对应的 E2B 标准环境变量别名。
+3. `config.toml`。
+4. 内置默认值。
+
+环境变量只要存在就会覆盖下一层,包括显式设置为空字符串。配置键通常等于环境变量去掉 `WORKER_` 前缀并转为小写,例如 `WORKER_E2B_API_KEY` 对应 `e2b_api_key`。
+
+## 身份与 console
+
+标记为“必填”的参数没有默认值,缺失时 worker 启动失败。
+
+| 环境变量 | `config.toml` 键 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `WORKER_CONFIG_FILE` | — | 自动查找 | 显式指定配置文件路径;文件不存在或格式错误时启动失败 |
+| `WORKER_ID` | `id` | 必填 | console 创建 worker 后分配的 ID |
+| `WORKER_SECRET` | `secret` | 必填 | hello 认证使用的一次性 worker secret |
+| `WORKER_CONSOLE_GRPC_TARGET` | `console_grpc_target` | `127.0.0.1:50051` | console gRPC 地址 |
+| `WORKER_CONSOLE_INSECURE` | `console_insecure` | `false` | `true` 时关闭 TLS,仅用于可信开发环境 |
+| `WORKER_NODE_NAME` | `node_name` | `worker-bridge-e2b-` | hello 中报告的节点名;空值时自动生成 |
+| `WORKER_LABELS` | `[labels]` 或 `labels` | 空 | worker labels;环境变量接受 JSON 对象或逗号分隔的 `key=value` |
+
+`WORKER_LABELS` 示例:
+
+```bash
+WORKER_LABELS='{"region":"cn","owner":"team-a"}'
+WORKER_LABELS='region=cn,owner=team-a'
+```
+
+对应的 TOML 推荐写法:
+
+```toml
+[labels]
+region = "cn"
+owner = "team-a"
+```
+
+## 心跳与调用
+
+| 环境变量 | `config.toml` 键 | 默认值 | 约束与说明 |
+| --- | --- | --- | --- |
+| `WORKER_HEARTBEAT_INTERVAL_SEC` | `heartbeat_interval_sec` | `5` | 正整数;发送 heartbeat 的基础间隔 |
+| `WORKER_HEARTBEAT_JITTER_PCT` | `heartbeat_jitter_pct` | `20` | `0`–`100`;heartbeat 间隔的抖动比例 |
+| `WORKER_CALL_TIMEOUT_SEC` | `call_timeout_sec` | `ceil(2.5 × heartbeat_interval_sec)` | 正整数;等待 console hello 和 heartbeat ack 的超时,默认配置下为 `13` 秒 |
+
+## E2B
+
+| 环境变量 | E2B 环境变量别名 | `config.toml` 键 | 默认值 | 说明 |
+| --- | --- | --- | --- | --- |
+| `WORKER_E2B_API_KEY` | `E2B_API_KEY` | `e2b_api_key` | 必填 | E2B Control API Key |
+| `WORKER_E2B_API_URL` | `E2B_API_URL` | `e2b_api_url` | `https://api.e2b.app` | Control API 基础 URL |
+| `WORKER_E2B_DOMAIN` | `E2B_DOMAIN` | `e2b_domain` | `e2b.app` | envd 沙箱域名后缀 |
+| `WORKER_E2B_SANDBOX_URL` | `E2B_SANDBOX_URL` | `e2b_sandbox_url` | 空 | 覆盖 envd 沙箱基础 URL,用于自托管、调试或集成测试 |
+| `WORKER_E2B_PYTHON_TEMPLATE` | `E2B_PYTHON_EXEC_TEMPLATE` | `e2b_python_template` | 必填 | `pythonExec` 使用的模板 ID 或别名 |
+| `WORKER_E2B_TERMINAL_TEMPLATE` | `E2B_TERMINAL_EXEC_TEMPLATE` | `e2b_terminal_template` | 必填 | `terminalExec` 使用的模板 ID 或别名 |
+| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | — | `e2b_request_timeout_sec` | `60` | 正整数;E2B Control API 请求以及 envd 建连、TLS 握手和响应头等待的超时秒数 |
+| `WORKER_E2B_PYTHON_TIMEOUT_SEC` | `E2B_SANDBOX_TIMEOUT_SEC` | `e2b_python_timeout_sec` | `300` | 正整数;一次性 Python 沙箱的 E2B timeout 秒数 |
+
+`WORKER_*` 变量始终优先于同一行的 E2B 别名。API URL 和 sandbox URL 末尾的 `/` 会被移除。
+
+envd 成功返回响应头后,命令执行与文件流遵循 console 下发的 command deadline,避免长时间运行的命令或文件导出被建连超时提前中断。
+
+## 终端 session 与文件
+
+| 环境变量 | `config.toml` 键 | 默认值 | 约束与说明 |
+| --- | --- | --- | --- |
+| `WORKER_TERMINAL_LEASE_MIN_SEC` | `terminal_lease_min_sec` | `60` | 正整数;请求可指定的最短 lease |
+| `WORKER_TERMINAL_LEASE_MAX_SEC` | `terminal_lease_max_sec` | `1800` | 正整数;小于最小值时自动提高到最小值 |
+| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | `terminal_lease_default_sec` | `300` | 正整数;自动限制在最小值与最大值之间 |
+| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | `terminal_output_limit_bytes` | `1048576` | 正整数;分别限制 stdout、stderr 和 `terminalResource.read` |
+| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | `terminal_export_max_bytes` | `0` | 非负整数;`0` 表示不限制导出大小 |
+| `WORKER_TERMINAL_EXPORT_MODE` | `terminal_export_mode` | `sandbox` | `worker`:文件流经 worker;`sandbox`:E2B 沙箱使用 `python3` 直接上传 |
+| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | `terminal_session_max_inflight` | `128` | 正整数;同一 session 内 `terminalExec` 与 `terminalResource` 共享的并发上限 |
+
+## 能力并发
+
+以下参数限制整个 worker 同时处理某项能力的数量,并通过 hello 的 `max_inflight` 报告给 console。它们与单 session 并发限制同时生效。
+
+| 环境变量 | `config.toml` 键 | 默认值 | 约束 |
+| --- | --- | --- | --- |
+| `WORKER_ECHO_MAX_INFLIGHT` | `echo_max_inflight` | `128` | 正整数 |
+| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | `python_exec_max_inflight` | `32` | 正整数 |
+| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | `terminal_exec_max_inflight` | `64` | 正整数 |
+| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | `terminal_resource_max_inflight` | `128` | 正整数 |
+
+## 日志
+
+| 环境变量 | `config.toml` 键 | 默认值 | 可选值与说明 |
+| --- | --- | --- | --- |
+| `WORKER_LOG_LEVEL` | `log_level` | `info` | `debug`、`info`、`warn`、`error` |
+| `WORKER_LOG_FORMAT` | `log_format` | `json` | `json`、`text` |
+| `WORKER_LOG_ADD_SOURCE` | `log_add_source` | `false` | 是否记录源码位置;布尔值接受 `1/0`、`true/false`、`yes/no`、`on/off` |
+
+正整数、百分比、枚举或布尔值无效时使用该项默认值。`WORKER_TERMINAL_EXPORT_MAX_BYTES` 的负数也会回退为 `0`;无效的 `WORKER_TERMINAL_EXPORT_MODE` 回退为 `sandbox`。
+
+配置文件中包含 worker secret 或 E2B API Key 时,应设置为仅运行用户可读,并且不要提交到版本库。可复制根目录的 [`config.example.toml`](../config.example.toml) 作为起点。
diff --git a/worker/worker-bridge-e2b/README/overview.md b/worker/worker-bridge-e2b/README/overview.md
new file mode 100644
index 0000000..975c0b7
--- /dev/null
+++ b/worker/worker-bridge-e2b/README/overview.md
@@ -0,0 +1,173 @@
+# E2B Bridge Worker
+
+`worker-bridge-e2b` 是 Onlyboxes 的远程 E2B 执行节点。worker 连接 console 的 `WorkerRegistryService.Connect` 双向流,发送身份、能力和心跳,并在收到任务后调用 E2B 控制 API 与沙箱内的 envd。
+
+## 配置入口
+
+启动至少需要 `WORKER_ID`、`WORKER_SECRET`、`WORKER_E2B_API_KEY`、`WORKER_E2B_PYTHON_TEMPLATE` 和 `WORKER_E2B_TERMINAL_TEMPLATE`。E2B 标准环境变量别名、全部可选参数、默认值和 `config.toml` 键见[完整配置参数参考](config-file.md),可直接复制根目录的 [`config.example.toml`](../config.example.toml)。
+
+console gRPC 默认启用 TLS。只有在可信内网开发环境中才应设置 `WORKER_CONSOLE_INSECURE=true`;明文连接会暴露 hello 中的一次性 worker secret。
+
+## E2B 通信
+
+- 沙箱创建、TTL 更新和销毁使用 E2B Control API。
+- 命令执行使用 envd 的 Connect RPC `process.Process/Start`,端口为 E2B 官方协议固定的 `49983`。
+- 文件读取使用 envd HTTP `GET /files`。
+- E2B API Key 只发送给 Control API;envd 使用创建响应里的独立 access token。
+- 日志不记录 API Key、envd access token、原始代码、命令或文件内容。
+
+Go 客户端根据 E2B 的公开 OpenAPI 与 envd protobuf 实现。E2B 当前没有官方 Go SDK。
+
+## 能力
+
+worker 在 hello 中声明以下四项能力:
+
+| 工具 | 用途 | 沙箱生命周期 |
+| --- | --- | --- |
+| `echo` | 检查 console 与 worker 的调用链路 | 不创建沙箱 |
+| `pythonExec` | 在 Python 模板中执行一次代码 | 每次调用创建并销毁 |
+| `terminalExec` | 创建或复用终端 session 执行命令 | 按 lease 复用 |
+| `terminalResource` | 校验、读取或导出终端 session 中的文件 | 复用已有 session |
+
+请求和返回值都是 JSON 对象。未知字段会被忽略;缺少必填字段时返回 `invalid_payload`。
+
+### echo
+
+请求与返回字段:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `message` | string | 是 | 要原样返回的非空文本 |
+
+示例:`{"message":"ping"}` 返回 `{"message":"ping"}`。
+
+### pythonExec
+
+每次请求创建独立的 Python 模板沙箱,将代码写入 `/tmp/onlyboxes-pythonexec.py`,通过 `uv run` 执行,随后销毁沙箱。模板必须提供 `uv` 与 Python,并可使用 PEP 723 内联依赖。
+
+请求字段:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `code` | string | 是 | 要执行的非空 Python 源码 |
+
+返回字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `output` | string | 标准输出 |
+| `stderr` | string | 标准错误 |
+| `exit_code` | number | 进程退出码 |
+
+```json
+{"output":"...","stderr":"...","exit_code":0}
+```
+
+非零退出码属于执行结果,不会被改写为 worker 传输错误。命令 deadline 取消后,worker 仍会使用独立的短超时请求销毁沙箱。
+
+### terminalExec
+
+请求字段:
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- | --- |
+| `command` | string | 是 | — | 由 `/bin/bash -l -c` 执行的非空命令 |
+| `session_id` | string | 否 | 空 | 空值时创建新 session;非空时复用指定 session |
+| `create_if_missing` | boolean | 否 | `false` | 指定的 session 不存在时是否创建 |
+| `lease_ttl_sec` | number | 否 | `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | session lease,必须位于配置的最小值和最大值之间 |
+
+返回字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `session_id` | string | Onlyboxes session ID |
+| `created` | boolean | 本次调用是否创建了 session |
+| `stdout` | string | 标准输出 |
+| `stderr` | string | 标准错误 |
+| `exit_code` | number | 进程退出码 |
+| `stdout_truncated` | boolean | 标准输出是否因大小限制被截断 |
+| `stderr_truncated` | boolean | 标准错误是否因大小限制被截断 |
+| `lease_expires_unix_ms` | number | 当前 lease 到期时间,Unix 毫秒 |
+
+请求示例:
+
+```json
+{"command":"pwd","session_id":"optional","create_if_missing":false,"lease_ttl_sec":60}
+```
+
+- 未提供 `session_id` 时创建新 E2B 沙箱和 Onlyboxes session。
+- 相同 `session_id` 复用沙箱及其文件系统。
+- 未知 session 返回 `session_not_found`,除非 `create_if_missing=true`。
+- lease 只会延长,不会被较短的 TTL 缩短;worker 同步调用 E2B timeout API,避免本地 lease 长于 E2B 沙箱寿命。
+- 单 session 并发由 `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` 控制,并与 `terminalResource` 共用。
+- 达到并发上限返回 `session_busy`。
+- 创建中的 session 会阻塞后续调用,所有等待者共享创建结果。
+- 某个命令超时会把 session 标记为待销毁;已有并发命令继续完成,最后一个调用退出后才销毁沙箱。
+- 空闲 session 到期后由 janitor 销毁;worker 正常退出时销毁所有仍管理的沙箱。
+
+每个命令由独立的 `/bin/bash -l -c` 进程执行,因此共享文件系统,但不共享 cwd、shell 变量或当前进程环境。
+
+### terminalResource
+
+请求字段:
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- | --- |
+| `session_id` | string | 是 | — | 已存在的终端 session ID |
+| `file_path` | string | 是 | — | session 沙箱内的文件路径 |
+| `action` | string | 否 | `validate` | `validate`、`read` 或 `export`,不区分大小写 |
+| `signed_url` | string | 仅 `export` | 空 | 接收文件的 HTTP 预签名 URL |
+| `headers` | object | 否 | 空 | `export` 上传时附加的 HTTP 请求头 |
+
+返回字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `session_id` | string | 终端 session ID |
+| `file_path` | string | 被处理的文件路径 |
+| `mime_type` | string | 推断出的 MIME 类型;无法识别时为 `application/octet-stream` |
+| `size_bytes` | number | 文件大小 |
+| `blob` | string | 仅 `read` 返回;文件字节经过 JSON base64 编码 |
+
+请求示例:
+
+```json
+{"session_id":"required","file_path":"required","action":"validate|read|export","signed_url":"export required"}
+```
+
+- `validate` 返回 MIME 类型与大小。
+- `read` 返回 `blob`;JSON 编码后表现为 base64。
+- `read` 大小上限为 `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES`。
+- `export` 通过 HTTP `PUT` 上传到 console 提供的预签名 URL。
+- `WORKER_TERMINAL_EXPORT_MODE=sandbox` 时,终端模板中的 `python3` 直接从 E2B 沙箱上传,文件内容不经过 worker;这是默认模式。
+- `WORKER_TERMINAL_EXPORT_MODE=worker` 时,worker 从 E2B 流式下载并转发文件。
+- `WORKER_TERMINAL_EXPORT_MAX_BYTES=0` 表示不限制导出大小。
+- 终端模板必须提供 `python3`,用于安全地探测文件类型、大小和目录状态,并在 `sandbox` 导出模式下执行流式上传。
+
+领域错误为 `file_not_found`、`path_is_directory` 和 `file_too_large`。
+
+## 会话与心跳
+
+- hello 声明 `echo`、`pythonExec`、`terminalExec`、`terminalResource`,每项都有独立的 `max_inflight`。
+- heartbeat 报告 `active_session_count`。
+- worker 容忍一次 heartbeat ack 超时,连续两次超时后重连。
+- session 被 console 替换并返回 `FailedPrecondition` 时立即进入重连流程。
+- 其余断线使用最长 `15` 秒的指数退避。
+- 版本只来自构建期注入,开发构建报告 `dev`,运行时不能覆盖。
+
+## 本地验证
+
+普通测试不访问 E2B:
+
+```bash
+go test -race ./...
+```
+
+真实 E2B 冒烟会创建并自动销毁一个终端模板沙箱:
+
+```bash
+E2B_INTEGRATION=1 \
+E2B_API_KEY=... \
+E2B_TERMINAL_TEMPLATE=... \
+go test ./internal/e2b -run TestIntegrationSandboxCommandAndFile -count=1 -v
+```
diff --git a/worker/worker-bridge-e2b/cmd/worker-bridge-e2b/main.go b/worker/worker-bridge-e2b/cmd/worker-bridge-e2b/main.go
new file mode 100644
index 0000000..b1c6394
--- /dev/null
+++ b/worker/worker-bridge-e2b/cmd/worker-bridge-e2b/main.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "os/signal"
+ "syscall"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/runner"
+)
+
+func main() {
+ cfg := config.Load()
+ logging.Configure(cfg.LogLevel, cfg.LogFormat, cfg.LogAddSource)
+ if cfg.ConfigFile != "" {
+ logging.Infof("config file loaded: %s", cfg.ConfigFile)
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ if err := runner.Run(ctx, cfg); err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ logging.Infof("worker stopped: %v", err)
+ return
+ }
+ logging.Fatalf("worker stopped with error: %v", err)
+ }
+}
diff --git a/worker/worker-bridge-e2b/config.example.toml b/worker/worker-bridge-e2b/config.example.toml
new file mode 100644
index 0000000..b2a279b
--- /dev/null
+++ b/worker/worker-bridge-e2b/config.example.toml
@@ -0,0 +1,53 @@
+# worker-bridge-e2b configuration.
+# Copy to config.toml next to the binary, or set WORKER_CONFIG_FILE.
+# Environment variables override values in this file.
+
+# Identity returned by console when creating a worker.
+id = ""
+secret = ""
+
+# Console connection. Keep TLS enabled outside trusted local development.
+console_grpc_target = "127.0.0.1:50051"
+console_insecure = false
+node_name = ""
+
+heartbeat_interval_sec = 5
+heartbeat_jitter_pct = 20
+# Defaults to ceil(2.5 * heartbeat_interval_sec).
+# call_timeout_sec = 13
+
+# E2B credentials and templates. Both templates are required.
+e2b_api_key = ""
+e2b_api_url = "https://api.e2b.app"
+e2b_domain = "e2b.app"
+e2b_python_template = ""
+e2b_terminal_template = ""
+# Control API and envd connection/response-header timeout.
+# Established envd streams use the console command deadline.
+e2b_request_timeout_sec = 60
+e2b_python_timeout_sec = 300
+# Optional override for self-hosted E2B or integration tests.
+# e2b_sandbox_url = "https://sandbox.e2b.app"
+
+terminal_lease_min_sec = 60
+terminal_lease_max_sec = 1800
+terminal_lease_default_sec = 300
+terminal_output_limit_bytes = 1048576
+# 0 means no export size limit.
+terminal_export_max_bytes = 0
+# worker streams files through this process; sandbox uploads directly from E2B.
+terminal_export_mode = "sandbox"
+terminal_session_max_inflight = 128
+
+echo_max_inflight = 128
+python_exec_max_inflight = 32
+terminal_exec_max_inflight = 64
+terminal_resource_max_inflight = 128
+
+log_level = "info"
+log_format = "json"
+log_add_source = false
+
+[labels]
+# region = "cn"
+# owner = "team-a"
diff --git a/worker/worker-bridge-e2b/go.mod b/worker/worker-bridge-e2b/go.mod
new file mode 100644
index 0000000..95db34c
--- /dev/null
+++ b/worker/worker-bridge-e2b/go.mod
@@ -0,0 +1,21 @@
+module github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b
+
+go 1.24.0
+
+require (
+ connectrpc.com/connect v1.19.1
+ github.com/BurntSushi/toml v1.5.0
+ github.com/google/uuid v1.6.0
+ github.com/onlyboxes/onlyboxes/api v0.0.0
+ google.golang.org/grpc v1.79.1
+ google.golang.org/protobuf v1.36.10
+)
+
+require (
+ golang.org/x/net v0.48.0 // indirect
+ golang.org/x/sys v0.39.0 // indirect
+ golang.org/x/text v0.32.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
+)
+
+replace github.com/onlyboxes/onlyboxes/api => ../../api
diff --git a/worker/worker-bridge-e2b/go.sum b/worker/worker-bridge-e2b/go.sum
new file mode 100644
index 0000000..bfcb073
--- /dev/null
+++ b/worker/worker-bridge-e2b/go.sum
@@ -0,0 +1,42 @@
+connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
+connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
+go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
+go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
+go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
+go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
+go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
+go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
+go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
+google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
+google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
diff --git a/worker/worker-bridge-e2b/internal/buildinfo/buildinfo.go b/worker/worker-bridge-e2b/internal/buildinfo/buildinfo.go
new file mode 100644
index 0000000..1d140c1
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/buildinfo/buildinfo.go
@@ -0,0 +1,7 @@
+package buildinfo
+
+const defaultVersion = "dev"
+
+// Version is injected at build time via:
+// go build -ldflags "-X github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/buildinfo.Version="
+var Version = defaultVersion
diff --git a/worker/worker-bridge-e2b/internal/config/config.go b/worker/worker-bridge-e2b/internal/config/config.go
new file mode 100644
index 0000000..357241f
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/config/config.go
@@ -0,0 +1,256 @@
+package config
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ defaultConsoleTarget = "127.0.0.1:50051"
+ defaultHeartbeatInterval = 5
+ defaultHeartbeatJitter = 20
+ defaultExecutorKind = "e2b"
+ defaultE2BAPIURL = "https://api.e2b.app"
+ defaultE2BDomain = "e2b.app"
+ defaultE2BRequestTimeout = 60
+ defaultE2BPythonTimeout = 300
+ defaultTerminalLeaseMin = 60
+ defaultTerminalLeaseMax = 1800
+ defaultTerminalLeaseTTL = 300
+ defaultTerminalOutputMax = 1024 * 1024
+ defaultTerminalExportMode = "sandbox"
+ defaultTerminalSessionInflight = 128
+ defaultLogLevel = "info"
+ defaultLogFormat = "json"
+ defaultEchoMaxInflight = 128
+ defaultPythonExecMaxInflight = 32
+ defaultTerminalExecMaxInflight = 64
+ defaultResourceMaxInflight = 128
+)
+
+type Config struct {
+ ConfigFile string
+ ConsoleGRPCTarget string
+ ConsoleTLS bool
+ WorkerID string
+ WorkerSecret string
+ HeartbeatInterval time.Duration
+ HeartbeatJitter int
+ CallTimeout time.Duration
+ NodeName string
+ ExecutorKind string
+ Labels map[string]string
+ E2BAPIKey string
+ E2BAPIURL string
+ E2BDomain string
+ E2BSandboxURL string
+ E2BPythonTemplate string
+ E2BTerminalTemplate string
+ E2BRequestTimeout time.Duration
+ E2BPythonTimeoutSec int
+ TerminalLeaseMinSec int
+ TerminalLeaseMaxSec int
+ TerminalLeaseDefaultSec int
+ TerminalOutputLimitBytes int
+ TerminalExportMaxBytes int
+ TerminalExportMode string
+ TerminalSessionMaxInflight int
+ EchoMaxInflight int
+ PythonExecMaxInflight int
+ TerminalExecMaxInflight int
+ TerminalResourceMaxInflight int
+ LogLevel string
+ LogFormat string
+ LogAddSource bool
+}
+
+func Load() Config {
+ src := newSource()
+ heartbeatSec := src.positiveInt("WORKER_HEARTBEAT_INTERVAL_SEC", defaultHeartbeatInterval)
+ heartbeatJitter := src.percent("WORKER_HEARTBEAT_JITTER_PCT", defaultHeartbeatJitter)
+ callTimeoutSec := src.positiveInt("WORKER_CALL_TIMEOUT_SEC", defaultCallTimeoutSec(heartbeatSec))
+ terminalLeaseMinSec := src.positiveInt("WORKER_TERMINAL_LEASE_MIN_SEC", defaultTerminalLeaseMin)
+ terminalLeaseMaxSec := src.positiveInt("WORKER_TERMINAL_LEASE_MAX_SEC", defaultTerminalLeaseMax)
+ if terminalLeaseMaxSec < terminalLeaseMinSec {
+ terminalLeaseMaxSec = terminalLeaseMinSec
+ }
+ terminalLeaseDefaultSec := clampInt(
+ src.positiveInt("WORKER_TERMINAL_LEASE_DEFAULT_SEC", defaultTerminalLeaseTTL),
+ terminalLeaseMinSec,
+ terminalLeaseMaxSec,
+ )
+
+ e2bAPIKey := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_API_KEY", "E2B_API_KEY"))
+ e2bAPIURL := strings.TrimRight(strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_API_URL", "E2B_API_URL")), "/")
+ if e2bAPIURL == "" {
+ e2bAPIURL = defaultE2BAPIURL
+ }
+ e2bDomain := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_DOMAIN", "E2B_DOMAIN"))
+ if e2bDomain == "" {
+ e2bDomain = defaultE2BDomain
+ }
+ pythonTemplate := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_PYTHON_TEMPLATE", "E2B_PYTHON_EXEC_TEMPLATE"))
+ terminalTemplate := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_TERMINAL_TEMPLATE", "E2B_TERMINAL_EXEC_TEMPLATE"))
+
+ return Config{
+ ConfigFile: src.Path(),
+ ConsoleGRPCTarget: src.stringValue("WORKER_CONSOLE_GRPC_TARGET", defaultConsoleTarget),
+ ConsoleTLS: !src.boolValue("WORKER_CONSOLE_INSECURE", false),
+ WorkerID: strings.TrimSpace(src.get("WORKER_ID")),
+ WorkerSecret: strings.TrimSpace(src.get("WORKER_SECRET")),
+ HeartbeatInterval: time.Duration(heartbeatSec) * time.Second,
+ HeartbeatJitter: heartbeatJitter,
+ CallTimeout: time.Duration(callTimeoutSec) * time.Second,
+ NodeName: strings.TrimSpace(src.get("WORKER_NODE_NAME")),
+ ExecutorKind: defaultExecutorKind,
+ Labels: parseLabels(src.get("WORKER_LABELS")),
+ E2BAPIKey: e2bAPIKey,
+ E2BAPIURL: e2bAPIURL,
+ E2BDomain: e2bDomain,
+ E2BSandboxURL: strings.TrimRight(strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_SANDBOX_URL", "E2B_SANDBOX_URL")), "/"),
+ E2BPythonTemplate: pythonTemplate,
+ E2BTerminalTemplate: terminalTemplate,
+ E2BRequestTimeout: time.Duration(src.positiveInt("WORKER_E2B_REQUEST_TIMEOUT_SEC", defaultE2BRequestTimeout)) * time.Second,
+ E2BPythonTimeoutSec: positiveIntValue(src.getWithEnvAliases("WORKER_E2B_PYTHON_TIMEOUT_SEC", "E2B_SANDBOX_TIMEOUT_SEC"), defaultE2BPythonTimeout),
+ TerminalLeaseMinSec: terminalLeaseMinSec,
+ TerminalLeaseMaxSec: terminalLeaseMaxSec,
+ TerminalLeaseDefaultSec: terminalLeaseDefaultSec,
+ TerminalOutputLimitBytes: src.positiveInt("WORKER_TERMINAL_OUTPUT_LIMIT_BYTES", defaultTerminalOutputMax),
+ TerminalExportMaxBytes: src.nonNegativeInt("WORKER_TERMINAL_EXPORT_MAX_BYTES", 0),
+ TerminalExportMode: src.terminalExportMode("WORKER_TERMINAL_EXPORT_MODE", defaultTerminalExportMode),
+ TerminalSessionMaxInflight: src.positiveInt("WORKER_TERMINAL_SESSION_MAX_INFLIGHT", defaultTerminalSessionInflight),
+ EchoMaxInflight: src.positiveInt("WORKER_ECHO_MAX_INFLIGHT", defaultEchoMaxInflight),
+ PythonExecMaxInflight: src.positiveInt("WORKER_PYTHON_EXEC_MAX_INFLIGHT", defaultPythonExecMaxInflight),
+ TerminalExecMaxInflight: src.positiveInt("WORKER_TERMINAL_EXEC_MAX_INFLIGHT", defaultTerminalExecMaxInflight),
+ TerminalResourceMaxInflight: src.positiveInt("WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT", defaultResourceMaxInflight),
+ LogLevel: src.logLevel("WORKER_LOG_LEVEL", defaultLogLevel),
+ LogFormat: src.logFormat("WORKER_LOG_FORMAT", defaultLogFormat),
+ LogAddSource: src.boolValue("WORKER_LOG_ADD_SOURCE", false),
+ }
+}
+
+func positiveIntValue(raw string, fallback int) int {
+ value, err := strconv.Atoi(strings.TrimSpace(raw))
+ if err != nil || value <= 0 {
+ return fallback
+ }
+ return value
+}
+
+func (s source) stringValue(key, fallback string) string {
+ if value := strings.TrimSpace(s.get(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func (s source) positiveInt(key string, fallback int) int {
+ value, err := strconv.Atoi(strings.TrimSpace(s.get(key)))
+ if err != nil || value <= 0 {
+ return fallback
+ }
+ return value
+}
+
+func (s source) nonNegativeInt(key string, fallback int) int {
+ value, err := strconv.Atoi(strings.TrimSpace(s.get(key)))
+ if err != nil || value < 0 {
+ return fallback
+ }
+ return value
+}
+
+func (s source) percent(key string, fallback int) int {
+ value, err := strconv.Atoi(strings.TrimSpace(s.get(key)))
+ if err != nil || value < 0 || value > 100 {
+ return fallback
+ }
+ return value
+}
+
+func (s source) boolValue(key string, fallback bool) bool {
+ switch strings.ToLower(strings.TrimSpace(s.get(key))) {
+ case "1", "true", "yes", "on":
+ return true
+ case "0", "false", "no", "off":
+ return false
+ default:
+ return fallback
+ }
+}
+
+func (s source) logLevel(key, fallback string) string {
+ switch value := strings.ToLower(strings.TrimSpace(s.get(key))); value {
+ case "debug", "info", "warn", "error":
+ return value
+ default:
+ return fallback
+ }
+}
+
+func (s source) logFormat(key, fallback string) string {
+ switch value := strings.ToLower(strings.TrimSpace(s.get(key))); value {
+ case "json", "text":
+ return value
+ default:
+ return fallback
+ }
+}
+
+func (s source) terminalExportMode(key, fallback string) string {
+ switch value := strings.ToLower(strings.TrimSpace(s.get(key))); value {
+ case "worker", "sandbox":
+ return value
+ default:
+ return fallback
+ }
+}
+
+func defaultCallTimeoutSec(heartbeatSec int) int {
+ if heartbeatSec <= 0 {
+ heartbeatSec = defaultHeartbeatInterval
+ }
+ return (heartbeatSec*5 + 1) / 2
+}
+
+func parseLabels(raw string) map[string]string {
+ if strings.TrimSpace(raw) == "" {
+ return map[string]string{}
+ }
+ if strings.HasPrefix(strings.TrimSpace(raw), "{") {
+ labels := map[string]string{}
+ if json.Unmarshal([]byte(raw), &labels) == nil {
+ return normalizeLabels(labels)
+ }
+ }
+ labels := map[string]string{}
+ for _, item := range strings.Split(raw, ",") {
+ parts := strings.SplitN(strings.TrimSpace(item), "=", 2)
+ if len(parts) == 2 && strings.TrimSpace(parts[0]) != "" {
+ labels[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
+ }
+ }
+ return labels
+}
+
+func normalizeLabels(raw map[string]string) map[string]string {
+ labels := make(map[string]string, len(raw))
+ for key, value := range raw {
+ if key = strings.TrimSpace(key); key != "" {
+ labels[key] = strings.TrimSpace(value)
+ }
+ }
+ return labels
+}
+
+func clampInt(value, minValue, maxValue int) int {
+ if value < minValue {
+ return minValue
+ }
+ if value > maxValue {
+ return maxValue
+ }
+ return value
+}
diff --git a/worker/worker-bridge-e2b/internal/config/source.go b/worker/worker-bridge-e2b/internal/config/source.go
new file mode 100644
index 0000000..303cc2d
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/config/source.go
@@ -0,0 +1,181 @@
+package config
+
+import (
+ "encoding/json"
+ "log"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+)
+
+const (
+ configFileName = "config.toml"
+ configFileEnvKey = "WORKER_CONFIG_FILE"
+ envPrefix = "WORKER_"
+)
+
+// source resolves configuration values from environment variables first and
+// falls back to the values declared in config.toml.
+type source struct {
+ path string
+ values map[string]string
+}
+
+func newSource() source {
+ path, explicit := configFilePath()
+ if path == "" {
+ return source{values: map[string]string{}}
+ }
+
+ raw := map[string]any{}
+ if _, err := toml.DecodeFile(path, &raw); err != nil {
+ if os.IsNotExist(err) && !explicit {
+ return source{values: map[string]string{}}
+ }
+ log.Fatalf("failed to load config file %s: %v", path, err)
+ }
+
+ return source{path: path, values: flatten(raw)}
+}
+
+// configFilePath returns the config file to load and whether it was requested
+// explicitly through WORKER_CONFIG_FILE.
+func configFilePath() (string, bool) {
+ if explicit := strings.TrimSpace(os.Getenv(configFileEnvKey)); explicit != "" {
+ return explicit, true
+ }
+
+ if executable, err := os.Executable(); err == nil {
+ if resolved, err := filepath.EvalSymlinks(executable); err == nil {
+ executable = resolved
+ }
+ candidate := filepath.Join(filepath.Dir(executable), configFileName)
+ if fileExists(candidate) {
+ return candidate, false
+ }
+ }
+
+ if fileExists(configFileName) {
+ return configFileName, false
+ }
+
+ return "", false
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+
+// Path returns the loaded config file path, empty when no file was used.
+func (s source) Path() string {
+ return s.path
+}
+
+// get returns the value for an environment variable key, falling back to the
+// matching config file key (env key without the WORKER_ prefix, lowercased).
+func (s source) get(envKey string) string {
+ if value, ok := os.LookupEnv(envKey); ok {
+ return value
+ }
+ return s.values[fileKey(envKey)]
+}
+
+// getWithEnvAliases keeps config.toml mapped to the canonical WORKER_ key,
+// while accepting provider-standard environment variable names.
+func (s source) getWithEnvAliases(envKey string, aliases ...string) string {
+ if value, ok := os.LookupEnv(envKey); ok {
+ return value
+ }
+ for _, alias := range aliases {
+ if value, ok := os.LookupEnv(alias); ok {
+ return value
+ }
+ }
+ return s.values[fileKey(envKey)]
+}
+
+func fileKey(envKey string) string {
+ return strings.ToLower(strings.TrimPrefix(envKey, envPrefix))
+}
+
+// flatten converts TOML values into the same string form accepted by the
+// environment variable parsers. Nested tables are additionally exposed as
+// `parent_child` keys so that grouped sections map onto flat env names.
+func flatten(raw map[string]any) map[string]string {
+ values := make(map[string]string, len(raw))
+ flattenInto(values, "", raw)
+ return values
+}
+
+func flattenInto(values map[string]string, prefix string, table map[string]any) {
+ for key, value := range table {
+ fullKey := strings.ToLower(key)
+ if prefix != "" {
+ fullKey = prefix + "_" + fullKey
+ }
+ if nested, ok := value.(map[string]any); ok {
+ flattenInto(values, fullKey, nested)
+ }
+ if encoded, ok := encodeValue(value); ok {
+ values[fullKey] = encoded
+ }
+ }
+}
+
+func encodeValue(value any) (string, bool) {
+ switch typed := value.(type) {
+ case string:
+ return typed, true
+ case bool:
+ return strconv.FormatBool(typed), true
+ case int64:
+ return strconv.FormatInt(typed, 10), true
+ case float64:
+ return strconv.FormatFloat(typed, 'f', -1, 64), true
+ case []any:
+ return encodeArray(typed)
+ case map[string]any:
+ return encodeTable(typed), true
+ default:
+ return "", false
+ }
+}
+
+// encodeArray renders TOML arrays as JSON arrays of strings.
+func encodeArray(items []any) (string, bool) {
+ encoded := make([]string, 0, len(items))
+ for _, item := range items {
+ value, ok := encodeValue(item)
+ if !ok {
+ return "", false
+ }
+ encoded = append(encoded, value)
+ }
+ payload, err := json.Marshal(encoded)
+ if err != nil {
+ return "", false
+ }
+ return string(payload), true
+}
+
+// encodeTable renders TOML tables as JSON objects so delimiters in keys and
+// values remain unambiguous.
+func encodeTable(table map[string]any) string {
+ encoded := make(map[string]string, len(table))
+ for key, rawValue := range table {
+ value, ok := encodeValue(rawValue)
+ if !ok {
+ continue
+ }
+ encoded[key] = value
+ }
+ payload, err := json.Marshal(encoded)
+ if err != nil {
+ return "{}"
+ }
+ return string(payload)
+}
diff --git a/worker/worker-bridge-e2b/internal/config/source_test.go b/worker/worker-bridge-e2b/internal/config/source_test.go
new file mode 100644
index 0000000..9260fef
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/config/source_test.go
@@ -0,0 +1,201 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func writeConfigFile(t *testing.T, content string) string {
+ t.Helper()
+
+ path := filepath.Join(t.TempDir(), configFileName)
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("failed to write config file: %v", err)
+ }
+ t.Setenv(configFileEnvKey, path)
+ return path
+}
+
+func TestLoadReadsConfigFile(t *testing.T) {
+ writeConfigFile(t, `
+console_grpc_target = "10.0.0.1:50051"
+console_insecure = true
+id = "worker-1"
+secret = "s3cret"
+heartbeat_interval_sec = 7
+e2b_api_key = "test-api-key"
+e2b_python_template = "python-template"
+e2b_terminal_template = "terminal-template"
+e2b_request_timeout_sec = 12
+terminal_export_mode = "sandbox"
+log_level = "debug"
+log_add_source = true
+
+[labels]
+region = "cn"
+owner = "team-a"
+description = "gpu,shared"
+`)
+
+ cfg := Load()
+ if cfg.ConsoleGRPCTarget != "10.0.0.1:50051" {
+ t.Fatalf("unexpected console target %q", cfg.ConsoleGRPCTarget)
+ }
+ if cfg.ConsoleTLS {
+ t.Fatalf("expected console TLS disabled by console_insecure=true")
+ }
+ if cfg.WorkerID != "worker-1" || cfg.WorkerSecret != "s3cret" {
+ t.Fatalf("unexpected identity %q/%q", cfg.WorkerID, cfg.WorkerSecret)
+ }
+ if cfg.HeartbeatInterval != 7*time.Second {
+ t.Fatalf("unexpected heartbeat interval %s", cfg.HeartbeatInterval)
+ }
+ if cfg.CallTimeout != 18*time.Second {
+ t.Fatalf("expected dynamic call timeout 18s, got %s", cfg.CallTimeout)
+ }
+ if cfg.E2BAPIKey != "test-api-key" {
+ t.Fatalf("unexpected E2B API key")
+ }
+ if cfg.E2BPythonTemplate != "python-template" || cfg.E2BTerminalTemplate != "terminal-template" {
+ t.Fatalf("unexpected E2B templates %q/%q", cfg.E2BPythonTemplate, cfg.E2BTerminalTemplate)
+ }
+ if cfg.E2BRequestTimeout != 12*time.Second {
+ t.Fatalf("unexpected E2B request timeout %s", cfg.E2BRequestTimeout)
+ }
+ if cfg.TerminalExportMode != "sandbox" {
+ t.Fatalf("unexpected terminal export mode %q", cfg.TerminalExportMode)
+ }
+ if cfg.LogLevel != "debug" || !cfg.LogAddSource {
+ t.Fatalf("unexpected log config %q/%t", cfg.LogLevel, cfg.LogAddSource)
+ }
+ if cfg.Labels["region"] != "cn" || cfg.Labels["owner"] != "team-a" || cfg.Labels["description"] != "gpu,shared" {
+ t.Fatalf("unexpected labels %v", cfg.Labels)
+ }
+}
+
+func TestCloudDefaultsAndTerminalExportMode(t *testing.T) {
+ writeConfigFile(t, `terminal_export_mode = "invalid"`)
+
+ cfg := Load()
+ if got := cfg.TerminalExportMode; got != defaultTerminalExportMode {
+ t.Fatalf("expected default terminal export mode %q, got %q", defaultTerminalExportMode, got)
+ }
+ if got := cfg.TerminalSessionMaxInflight; got != defaultTerminalSessionInflight {
+ t.Fatalf("expected default terminal session max inflight %d, got %d", defaultTerminalSessionInflight, got)
+ }
+ if cfg.TerminalLeaseMaxSec != defaultTerminalLeaseMax ||
+ cfg.TerminalLeaseDefaultSec != defaultTerminalLeaseTTL ||
+ cfg.TerminalOutputLimitBytes != defaultTerminalOutputMax {
+ t.Fatalf(
+ "unexpected terminal defaults: max_lease=%d default_lease=%d output_limit=%d",
+ cfg.TerminalLeaseMaxSec,
+ cfg.TerminalLeaseDefaultSec,
+ cfg.TerminalOutputLimitBytes,
+ )
+ }
+ if cfg.EchoMaxInflight != defaultEchoMaxInflight ||
+ cfg.PythonExecMaxInflight != defaultPythonExecMaxInflight ||
+ cfg.TerminalExecMaxInflight != defaultTerminalExecMaxInflight ||
+ cfg.TerminalResourceMaxInflight != defaultResourceMaxInflight {
+ t.Fatalf(
+ "unexpected capability defaults: echo=%d python=%d terminal=%d resource=%d",
+ cfg.EchoMaxInflight,
+ cfg.PythonExecMaxInflight,
+ cfg.TerminalExecMaxInflight,
+ cfg.TerminalResourceMaxInflight,
+ )
+ }
+
+ t.Setenv("WORKER_TERMINAL_EXPORT_MODE", "WORKER")
+ if got := Load().TerminalExportMode; got != "worker" {
+ t.Fatalf("expected case-insensitive worker mode, got %q", got)
+ }
+}
+
+func TestEnvOverridesConfigFile(t *testing.T) {
+ writeConfigFile(t, `
+console_grpc_target = "10.0.0.1:50051"
+log_level = "debug"
+`)
+ t.Setenv("WORKER_CONSOLE_GRPC_TARGET", "127.0.0.1:60051")
+ t.Setenv("WORKER_LOG_LEVEL", "warn")
+
+ cfg := Load()
+ if cfg.ConsoleGRPCTarget != "127.0.0.1:60051" {
+ t.Fatalf("expected env override, got %q", cfg.ConsoleGRPCTarget)
+ }
+ if cfg.LogLevel != "warn" {
+ t.Fatalf("expected env override, got %q", cfg.LogLevel)
+ }
+}
+
+func TestEmptyEnvOverridesConfigFile(t *testing.T) {
+ writeConfigFile(t, `
+console_grpc_target = "10.0.0.1:50051"
+secret = "from-file"
+
+[labels]
+region = "cn"
+`)
+ t.Setenv("WORKER_CONSOLE_GRPC_TARGET", "")
+ t.Setenv("WORKER_SECRET", "")
+ t.Setenv("WORKER_LABELS", "")
+
+ cfg := Load()
+ if cfg.ConsoleGRPCTarget != defaultConsoleTarget {
+ t.Fatalf("expected empty env to select the default target, got %q", cfg.ConsoleGRPCTarget)
+ }
+ if cfg.WorkerSecret != "" {
+ t.Fatalf("expected empty env to clear worker secret, got %q", cfg.WorkerSecret)
+ }
+ if len(cfg.Labels) != 0 {
+ t.Fatalf("expected empty env to clear labels, got %v", cfg.Labels)
+ }
+}
+
+func TestLoadReportsConfigFilePath(t *testing.T) {
+ path := writeConfigFile(t, "log_format = \"text\"\n")
+
+ cfg := Load()
+ if cfg.ConfigFile != path {
+ t.Fatalf("expected config file path %q, got %q", path, cfg.ConfigFile)
+ }
+ if cfg.LogFormat != "text" {
+ t.Fatalf("unexpected log format %q", cfg.LogFormat)
+ }
+}
+
+func TestE2BStandardEnvironmentAliasesOverrideConfigFile(t *testing.T) {
+ writeConfigFile(t, `
+e2b_api_key = "from-file"
+e2b_python_template = "python-from-file"
+e2b_terminal_template = "terminal-from-file"
+`)
+ t.Setenv("E2B_API_KEY", "from-env")
+ t.Setenv("E2B_PYTHON_EXEC_TEMPLATE", "python-from-env")
+ t.Setenv("E2B_TERMINAL_EXEC_TEMPLATE", "terminal-from-env")
+ t.Setenv("E2B_SANDBOX_TIMEOUT_SEC", "420")
+
+ cfg := Load()
+ if cfg.E2BAPIKey != "from-env" {
+ t.Fatalf("expected standard E2B API key alias")
+ }
+ if cfg.E2BPythonTemplate != "python-from-env" || cfg.E2BTerminalTemplate != "terminal-from-env" {
+ t.Fatalf("unexpected aliased templates %q/%q", cfg.E2BPythonTemplate, cfg.E2BTerminalTemplate)
+ }
+ if cfg.E2BPythonTimeoutSec != 420 {
+ t.Fatalf("unexpected aliased sandbox timeout %d", cfg.E2BPythonTimeoutSec)
+ }
+}
+
+func TestCanonicalWorkerE2BEnvironmentWinsOverAlias(t *testing.T) {
+ writeConfigFile(t, `e2b_api_key = "from-file"`)
+ t.Setenv("E2B_API_KEY", "from-alias")
+ t.Setenv("WORKER_E2B_API_KEY", "from-worker")
+
+ if got := Load().E2BAPIKey; got != "from-worker" {
+ t.Fatalf("expected canonical worker variable, got %q", got)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/e2b/client.go b/worker/worker-bridge-e2b/internal/e2b/client.go
new file mode 100644
index 0000000..49bc60b
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/e2b/client.go
@@ -0,0 +1,436 @@
+package e2b
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "connectrpc.com/connect"
+ processv1 "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect"
+)
+
+const envdPort = 49983
+
+type Config struct {
+ APIKey string
+ APIURL string
+ Domain string
+ SandboxURL string
+ RequestTimeout time.Duration
+}
+
+type Client struct {
+ apiKey string
+ apiURL string
+ domain string
+ sandboxURL string
+ controlHTTP *http.Client
+ sandboxHTTP *http.Client
+}
+
+type Sandbox struct {
+ ID string
+ Domain string
+ EnvdVersion string
+ AccessToken string
+}
+
+type CommandResult struct {
+ Stdout string
+ Stderr string
+ ExitCode int
+ StdoutTruncated bool
+ StderrTruncated bool
+}
+
+type File struct {
+ Content []byte
+ MIMEType string
+ Size int64
+}
+
+type FileReader struct {
+ Body io.ReadCloser
+ MIMEType string
+ Size int64
+}
+
+type apiError struct {
+ Message string `json:"message"`
+ Code int `json:"code"`
+}
+
+type HTTPError struct {
+ StatusCode int
+ Status string
+ Message string
+}
+
+func (e *HTTPError) Error() string {
+ if e == nil {
+ return "E2B API request failed"
+ }
+ return fmt.Sprintf("E2B API returned %s: %s", e.Status, e.Message)
+}
+
+func NewClient(cfg Config) (*Client, error) {
+ if strings.TrimSpace(cfg.APIKey) == "" {
+ return nil, errors.New("E2B API key is required")
+ }
+ apiURL := strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/")
+ if apiURL == "" {
+ apiURL = "https://api.e2b.app"
+ }
+ domain := strings.TrimSpace(cfg.Domain)
+ if domain == "" {
+ domain = "e2b.app"
+ }
+ requestTimeout := cfg.RequestTimeout
+ if requestTimeout <= 0 {
+ requestTimeout = 60 * time.Second
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.ForceAttemptHTTP2 = true
+ sandboxTransport := transport.Clone()
+ sandboxTransport.DialContext = (&net.Dialer{
+ Timeout: requestTimeout,
+ KeepAlive: 30 * time.Second,
+ }).DialContext
+ sandboxTransport.TLSHandshakeTimeout = requestTimeout
+ sandboxTransport.ResponseHeaderTimeout = requestTimeout
+ return &Client{
+ apiKey: strings.TrimSpace(cfg.APIKey),
+ apiURL: apiURL,
+ domain: domain,
+ sandboxURL: strings.TrimRight(strings.TrimSpace(cfg.SandboxURL), "/"),
+ controlHTTP: &http.Client{Transport: transport.Clone(), Timeout: requestTimeout},
+ // Envd connection setup and response headers use the configured request
+ // timeout. Once headers arrive, streams use the console dispatch context.
+ sandboxHTTP: &http.Client{Transport: sandboxTransport},
+ }, nil
+}
+
+func (c *Client) Create(ctx context.Context, template string, timeoutSec int) (*Sandbox, error) {
+ template = strings.TrimSpace(template)
+ if template == "" {
+ return nil, errors.New("E2B template is required")
+ }
+ if timeoutSec <= 0 {
+ return nil, errors.New("E2B sandbox timeout must be positive")
+ }
+ body := struct {
+ TemplateID string `json:"templateID"`
+ Timeout int `json:"timeout"`
+ AutoPause bool `json:"autoPause"`
+ Secure bool `json:"secure"`
+ AllowInternetAccess bool `json:"allow_internet_access"`
+ Metadata map[string]string `json:"metadata"`
+ EnvVars map[string]string `json:"envVars"`
+ }{
+ TemplateID: template,
+ Timeout: timeoutSec,
+ AutoPause: false,
+ Secure: true,
+ AllowInternetAccess: true,
+ Metadata: map[string]string{"onlyboxes.worker": "worker-bridge-e2b"},
+ EnvVars: map[string]string{},
+ }
+ var response struct {
+ SandboxID string `json:"sandboxID"`
+ EnvdVersion string `json:"envdVersion"`
+ EnvdAccessToken string `json:"envdAccessToken"`
+ Domain *string `json:"domain"`
+ }
+ if err := c.controlJSON(ctx, http.MethodPost, "/sandboxes", body, &response); err != nil {
+ return nil, err
+ }
+ domain := c.domain
+ if response.Domain != nil && strings.TrimSpace(*response.Domain) != "" {
+ domain = strings.TrimSpace(*response.Domain)
+ }
+ if strings.TrimSpace(response.SandboxID) == "" {
+ return nil, errors.New("E2B create response did not include sandboxID")
+ }
+ return &Sandbox{
+ ID: response.SandboxID,
+ Domain: domain,
+ EnvdVersion: response.EnvdVersion,
+ AccessToken: response.EnvdAccessToken,
+ }, nil
+}
+
+func (c *Client) SetTimeout(ctx context.Context, sandboxID string, timeoutSec int) error {
+ if timeoutSec <= 0 {
+ return errors.New("E2B sandbox timeout must be positive")
+ }
+ path := "/sandboxes/" + url.PathEscape(strings.TrimSpace(sandboxID)) + "/timeout"
+ err := c.controlJSON(ctx, http.MethodPost, path, struct {
+ Timeout int `json:"timeout"`
+ }{Timeout: timeoutSec}, nil)
+ if isHTTPStatus(err, http.StatusNotFound) {
+ return fmt.Errorf("%w: %v", ErrSandboxNotFound, err)
+ }
+ return err
+}
+
+func (c *Client) Kill(ctx context.Context, sandboxID string) error {
+ err := c.controlJSON(ctx, http.MethodDelete, "/sandboxes/"+url.PathEscape(strings.TrimSpace(sandboxID)), nil, nil)
+ if isHTTPStatus(err, http.StatusNotFound) {
+ return nil
+ }
+ return err
+}
+
+func (c *Client) Run(ctx context.Context, sandbox *Sandbox, command string, maxOutputBytes int) (CommandResult, error) {
+ if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" {
+ return CommandResult{}, errors.New("sandbox is required")
+ }
+ baseURL := c.sandboxBaseURL(sandbox)
+ rpc := processv1connect.NewProcessClient(c.sandboxHTTP, baseURL, connect.WithProtoJSON())
+ stdin := false
+ req := connect.NewRequest(&processv1.StartRequest{
+ Process: &processv1.ProcessConfig{
+ Cmd: "/bin/bash",
+ Args: []string{"-l", "-c", command},
+ Envs: map[string]string{},
+ },
+ Stdin: &stdin,
+ })
+ c.applySandboxHeaders(req.Header(), sandbox)
+ req.Header().Set("Keepalive-Ping-Interval", "50")
+ stream, err := rpc.Start(ctx, req)
+ if err != nil {
+ if connect.CodeOf(err) == connect.CodeNotFound {
+ return CommandResult{}, fmt.Errorf("%w: %v", ErrSandboxNotFound, err)
+ }
+ return CommandResult{}, fmt.Errorf("start E2B command: %w", err)
+ }
+ var stdout, stderr limitedBuffer
+ stdout.limit = maxOutputBytes
+ stderr.limit = maxOutputBytes
+ result := CommandResult{}
+ ended := false
+ for stream.Receive() {
+ event := stream.Msg().GetEvent()
+ if event == nil {
+ continue
+ }
+ if data := event.GetData(); data != nil {
+ stdout.Write(data.GetStdout())
+ stderr.Write(data.GetStderr())
+ }
+ if end := event.GetEnd(); end != nil {
+ result.ExitCode = int(end.GetExitCode())
+ if end.GetError() != "" && result.ExitCode == 0 {
+ return CommandResult{}, fmt.Errorf("E2B command failed: %s", end.GetError())
+ }
+ ended = true
+ }
+ }
+ if err := stream.Err(); err != nil {
+ if connect.CodeOf(err) == connect.CodeNotFound {
+ return CommandResult{}, fmt.Errorf("%w: %v", ErrSandboxNotFound, err)
+ }
+ return CommandResult{}, fmt.Errorf("receive E2B command output: %w", err)
+ }
+ if !ended {
+ return CommandResult{}, errors.New("E2B command ended without an end event")
+ }
+ result.Stdout = stdout.String()
+ result.Stderr = stderr.String()
+ result.StdoutTruncated = stdout.truncated
+ result.StderrTruncated = stderr.truncated
+ return result, nil
+}
+
+func (c *Client) ReadFile(ctx context.Context, sandbox *Sandbox, filePath string, maxBytes int64) (File, error) {
+ opened, err := c.OpenFile(ctx, sandbox, filePath)
+ if err != nil {
+ return File{}, err
+ }
+ defer opened.Body.Close()
+ if maxBytes >= 0 && opened.Size > maxBytes {
+ return File{Size: opened.Size}, ErrFileTooLarge
+ }
+ reader := io.Reader(opened.Body)
+ if maxBytes >= 0 {
+ reader = io.LimitReader(opened.Body, maxBytes+1)
+ }
+ content, err := io.ReadAll(reader)
+ if err != nil {
+ return File{}, fmt.Errorf("read E2B file: %w", err)
+ }
+ if maxBytes >= 0 && int64(len(content)) > maxBytes {
+ return File{Size: int64(len(content))}, ErrFileTooLarge
+ }
+ mimeType := opened.MIMEType
+ if mimeType == "" {
+ mimeType = http.DetectContentType(content)
+ }
+ return File{Content: content, MIMEType: mimeType, Size: int64(len(content))}, nil
+}
+
+func (c *Client) OpenFile(ctx context.Context, sandbox *Sandbox, filePath string) (FileReader, error) {
+ if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" {
+ return FileReader{}, errors.New("sandbox is required")
+ }
+ endpoint := c.sandboxBaseURL(sandbox) + "/files?path=" + url.QueryEscape(filePath)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return FileReader{}, err
+ }
+ c.applySandboxHeaders(req.Header, sandbox)
+ resp, err := c.sandboxHTTP.Do(req)
+ if err != nil {
+ return FileReader{}, fmt.Errorf("download E2B file: %w", err)
+ }
+ if resp.StatusCode == http.StatusNotFound {
+ resp.Body.Close()
+ return FileReader{}, ErrFileNotFound
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ err := responseError(resp)
+ resp.Body.Close()
+ return FileReader{}, err
+ }
+ mimeType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
+ if mimeType == "" || mimeType == "application/octet-stream" {
+ mimeType = mime.TypeByExtension(filepath.Ext(filePath))
+ }
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+ return FileReader{Body: resp.Body, MIMEType: mimeType, Size: resp.ContentLength}, nil
+}
+
+var (
+ ErrFileNotFound = errors.New("file not found")
+ ErrFileTooLarge = errors.New("file exceeds limit")
+ ErrSandboxNotFound = errors.New("sandbox not found")
+)
+
+func (c *Client) controlJSON(ctx context.Context, method, path string, input, output any) error {
+ var body io.Reader
+ if input != nil {
+ payload, err := json.Marshal(input)
+ if err != nil {
+ return err
+ }
+ body = bytes.NewReader(payload)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.apiURL+path, body)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("X-API-Key", c.apiKey)
+ req.Header.Set("User-Agent", "onlyboxes-worker-bridge-e2b")
+ if input != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := c.controlHTTP.Do(req)
+ if err != nil {
+ return fmt.Errorf("E2B API request: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return responseError(resp)
+ }
+ if output == nil || resp.StatusCode == http.StatusNoContent {
+ _, _ = io.Copy(io.Discard, resp.Body)
+ return nil
+ }
+ if err := json.NewDecoder(resp.Body).Decode(output); err != nil {
+ return fmt.Errorf("decode E2B API response: %w", err)
+ }
+ return nil
+}
+
+func responseError(resp *http.Response) error {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ var decoded apiError
+ _ = json.Unmarshal(body, &decoded)
+ message := strings.TrimSpace(decoded.Message)
+ if message == "" {
+ message = strings.TrimSpace(string(body))
+ }
+ if message == "" {
+ message = resp.Status
+ }
+ return &HTTPError{
+ StatusCode: resp.StatusCode,
+ Status: resp.Status,
+ Message: message,
+ }
+}
+
+func isHTTPStatus(err error, statusCode int) bool {
+ var httpErr *HTTPError
+ return errors.As(err, &httpErr) && httpErr.StatusCode == statusCode
+}
+
+func (c *Client) sandboxBaseURL(sandbox *Sandbox) string {
+ if c.sandboxURL != "" {
+ return c.sandboxURL
+ }
+ domain := strings.TrimSpace(sandbox.Domain)
+ if domain == "" {
+ domain = c.domain
+ }
+ if domain == "e2b.app" {
+ return "https://sandbox.e2b.app"
+ }
+ return "https://" + strconv.Itoa(envdPort) + "-" + sandbox.ID + "." + domain
+}
+
+func (c *Client) applySandboxHeaders(header http.Header, sandbox *Sandbox) {
+ header.Set("E2b-Sandbox-Id", sandbox.ID)
+ header.Set("E2b-Sandbox-Port", strconv.Itoa(envdPort))
+ header.Set("User-Agent", "onlyboxes-worker-bridge-e2b")
+ if sandbox.AccessToken != "" {
+ header.Set("X-Access-Token", sandbox.AccessToken)
+ }
+}
+
+type limitedBuffer struct {
+ buf bytes.Buffer
+ limit int
+ truncated bool
+}
+
+func (b *limitedBuffer) Write(data []byte) {
+ if len(data) == 0 {
+ return
+ }
+ if b.limit < 0 {
+ _, _ = b.buf.Write(data)
+ return
+ }
+ if b.limit == 0 {
+ b.truncated = true
+ return
+ }
+ remaining := b.limit - b.buf.Len()
+ if remaining <= 0 {
+ b.truncated = true
+ return
+ }
+ if len(data) > remaining {
+ data = data[:remaining]
+ b.truncated = true
+ }
+ _, _ = b.buf.Write(data)
+}
+
+func (b *limitedBuffer) String() string { return b.buf.String() }
diff --git a/worker/worker-bridge-e2b/internal/e2b/client_integration_test.go b/worker/worker-bridge-e2b/internal/e2b/client_integration_test.go
new file mode 100644
index 0000000..cffbbda
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/e2b/client_integration_test.go
@@ -0,0 +1,64 @@
+package e2b
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestIntegrationSandboxCommandAndFile(t *testing.T) {
+ if os.Getenv("E2B_INTEGRATION") != "1" {
+ t.Skip("set E2B_INTEGRATION=1 to run against E2B")
+ }
+ apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY"))
+ template := strings.TrimSpace(os.Getenv("E2B_TERMINAL_TEMPLATE"))
+ if apiKey == "" || template == "" {
+ t.Fatal("E2B_API_KEY and E2B_TERMINAL_TEMPLATE are required")
+ }
+ client, err := NewClient(Config{
+ APIKey: apiKey,
+ APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")),
+ Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")),
+ RequestTimeout: 60 * time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ sandbox, err := client.Create(ctx, template, 120)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cleanupCancel()
+ if err := client.Kill(cleanupCtx, sandbox.ID); err != nil {
+ t.Errorf("cleanup sandbox: %v", err)
+ }
+ })
+ result, err := client.Run(
+ ctx,
+ sandbox,
+ `printf 'hello-e2b' > /tmp/onlyboxes-e2b-smoke.txt && printf 'command-ok'`,
+ 1024,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.ExitCode != 0 || result.Stdout != "command-ok" {
+ t.Fatalf("unexpected command result: %#v", result)
+ }
+ file, err := client.ReadFile(ctx, sandbox, "/tmp/onlyboxes-e2b-smoke.txt", 1024)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(file.Content) != "hello-e2b" {
+ t.Fatalf("unexpected file content %q", string(file.Content))
+ }
+ if err := client.SetTimeout(ctx, sandbox.ID, 120); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/e2b/client_test.go b/worker/worker-bridge-e2b/internal/e2b/client_test.go
new file mode 100644
index 0000000..82d1d70
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/e2b/client_test.go
@@ -0,0 +1,302 @@
+package e2b
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "connectrpc.com/connect"
+ processv1 "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect"
+)
+
+type processTestHandler struct {
+ processv1connect.UnimplementedProcessHandler
+ t *testing.T
+}
+
+func TestRequestTimeoutBoundsEnvdSetupWithoutBoundingStreams(t *testing.T) {
+ t.Parallel()
+ client, err := NewClient(Config{
+ APIKey: "test-key",
+ RequestTimeout: 250 * time.Millisecond,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if client.controlHTTP.Timeout != 250*time.Millisecond {
+ t.Fatalf("unexpected Control API timeout: %s", client.controlHTTP.Timeout)
+ }
+ if client.sandboxHTTP.Timeout != 0 {
+ t.Fatalf("envd must use the command context deadline, got client timeout %s", client.sandboxHTTP.Timeout)
+ }
+ transport, ok := client.sandboxHTTP.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("unexpected envd transport %T", client.sandboxHTTP.Transport)
+ }
+ if transport.TLSHandshakeTimeout != 250*time.Millisecond ||
+ transport.ResponseHeaderTimeout != 250*time.Millisecond {
+ t.Fatalf(
+ "unexpected envd setup timeouts: tls=%s response_headers=%s",
+ transport.TLSHandshakeTimeout,
+ transport.ResponseHeaderTimeout,
+ )
+ }
+}
+
+func (h processTestHandler) Start(
+ _ context.Context,
+ req *connect.Request[processv1.StartRequest],
+ stream *connect.ServerStream[processv1.StartResponse],
+) error {
+ if req.Header().Get("E2b-Sandbox-Id") != "sb-command" ||
+ req.Header().Get("X-Access-Token") != "command-token" {
+ h.t.Errorf("missing E2B routing headers: %#v", req.Header())
+ }
+ process := req.Msg.GetProcess()
+ if process.GetCmd() != "/bin/bash" || strings.Join(process.GetArgs(), " ") != "-l -c printf ok" {
+ h.t.Errorf("unexpected process request: %#v", process)
+ }
+ if err := stream.Send(&processv1.StartResponse{Event: &processv1.ProcessEvent{
+ Event: &processv1.ProcessEvent_Start{Start: &processv1.ProcessEvent_StartEvent{Pid: 42}},
+ }}); err != nil {
+ return err
+ }
+ if err := stream.Send(&processv1.StartResponse{Event: &processv1.ProcessEvent{
+ Event: &processv1.ProcessEvent_Data{Data: &processv1.ProcessEvent_DataEvent{
+ Output: &processv1.ProcessEvent_DataEvent_Stdout{Stdout: []byte("ok")},
+ }},
+ }}); err != nil {
+ return err
+ }
+ return stream.Send(&processv1.StartResponse{Event: &processv1.ProcessEvent{
+ Event: &processv1.ProcessEvent_End{End: &processv1.ProcessEvent_EndEvent{ExitCode: 0, Exited: true}},
+ }})
+}
+
+func TestControlPlaneLifecycle(t *testing.T) {
+ t.Parallel()
+ var mu sync.Mutex
+ var requests []struct {
+ Method string
+ Path string
+ Body map[string]any
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-API-Key") != "test-key" {
+ t.Errorf("missing API key")
+ }
+ body := map[string]any{}
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ }
+ mu.Lock()
+ requests = append(requests, struct {
+ Method string
+ Path string
+ Body map[string]any
+ }{r.Method, r.URL.Path, body})
+ mu.Unlock()
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/sandboxes":
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sandboxID": "sb-1",
+ "envdVersion": "0.6.2",
+ "envdAccessToken": "envd-token",
+ "domain": "example.test",
+ })
+ default:
+ w.WriteHeader(http.StatusNoContent)
+ }
+ }))
+ defer server.Close()
+
+ client, err := NewClient(Config{
+ APIKey: "test-key",
+ APIURL: server.URL,
+ RequestTimeout: time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sandbox, err := client.Create(context.Background(), "template-1", 90)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sandbox.ID != "sb-1" || sandbox.Domain != "example.test" || sandbox.AccessToken != "envd-token" {
+ t.Fatalf("unexpected sandbox: %#v", sandbox)
+ }
+ if err := client.SetTimeout(context.Background(), sandbox.ID, 120); err != nil {
+ t.Fatal(err)
+ }
+ if err := client.Kill(context.Background(), sandbox.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(requests) != 3 {
+ t.Fatalf("expected 3 requests, got %d", len(requests))
+ }
+ if requests[0].Method != http.MethodPost || requests[0].Path != "/sandboxes" {
+ t.Fatalf("unexpected create request: %#v", requests[0])
+ }
+ if requests[0].Body["templateID"] != "template-1" || requests[0].Body["timeout"] != float64(90) {
+ t.Fatalf("unexpected create body: %#v", requests[0].Body)
+ }
+ if requests[0].Body["allow_internet_access"] != true || requests[0].Body["secure"] != true {
+ t.Fatalf("missing create security/network fields: %#v", requests[0].Body)
+ }
+ if requests[1].Path != "/sandboxes/sb-1/timeout" || requests[1].Body["timeout"] != float64(120) {
+ t.Fatalf("unexpected timeout request: %#v", requests[1])
+ }
+ if requests[2].Method != http.MethodDelete || requests[2].Path != "/sandboxes/sb-1" {
+ t.Fatalf("unexpected delete request: %#v", requests[2])
+ }
+}
+
+func TestReadFileUsesSandboxRoutingHeadersAndLimit(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/files" || r.URL.Query().Get("path") != "/tmp/a b.txt" {
+ t.Errorf("unexpected file URL: %s", r.URL.String())
+ }
+ if r.Header.Get("E2b-Sandbox-Id") != "sb-2" ||
+ r.Header.Get("E2b-Sandbox-Port") != "49983" ||
+ r.Header.Get("X-Access-Token") != "token-2" {
+ t.Errorf("missing routing headers: %#v", r.Header)
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ w.Header().Set("Content-Length", "5")
+ _, _ = io.WriteString(w, "hello")
+ }))
+ defer server.Close()
+ client, err := NewClient(Config{
+ APIKey: "test-key",
+ SandboxURL: server.URL,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sandbox := &Sandbox{ID: "sb-2", AccessToken: "token-2", Domain: "e2b.app"}
+ file, err := client.ReadFile(context.Background(), sandbox, "/tmp/a b.txt", 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(file.Content) != "hello" || file.MIMEType != "text/plain" || file.Size != 5 {
+ t.Fatalf("unexpected file: %#v", file)
+ }
+ _, err = client.ReadFile(context.Background(), sandbox, "/tmp/a b.txt", 4)
+ if err == nil || !strings.Contains(err.Error(), ErrFileTooLarge.Error()) {
+ t.Fatalf("expected file-too-large error, got %v", err)
+ }
+}
+
+func TestEnvdResponseHeaderTimeout(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ time.Sleep(150 * time.Millisecond)
+ _, _ = io.WriteString(w, "late")
+ }))
+ defer server.Close()
+ client, err := NewClient(Config{
+ APIKey: "test-key",
+ SandboxURL: server.URL,
+ RequestTimeout: 30 * time.Millisecond,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ startedAt := time.Now()
+ _, err = client.ReadFile(ctx, &Sandbox{ID: "slow-headers"}, "/tmp/file", 1024)
+ if err == nil {
+ t.Fatal("expected envd response-header timeout")
+ }
+ if elapsed := time.Since(startedAt); elapsed > 120*time.Millisecond {
+ t.Fatalf("envd response-header timeout took too long: %s", elapsed)
+ }
+}
+
+func TestEnvdStreamCanOutliveResponseHeaderTimeout(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.Header().Set("Content-Length", "5")
+ w.WriteHeader(http.StatusOK)
+ w.(http.Flusher).Flush()
+ time.Sleep(100 * time.Millisecond)
+ _, _ = io.WriteString(w, "hello")
+ }))
+ defer server.Close()
+ client, err := NewClient(Config{
+ APIKey: "test-key",
+ SandboxURL: server.URL,
+ RequestTimeout: 30 * time.Millisecond,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ file, err := client.ReadFile(ctx, &Sandbox{ID: "slow-body"}, "/tmp/file", 1024)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(file.Content) != "hello" {
+ t.Fatalf("unexpected streamed content %q", file.Content)
+ }
+}
+
+func TestCreateSurfacesAPIErrorWithoutLeakingKey(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = io.WriteString(w, `{"message":"invalid credentials"}`)
+ }))
+ defer server.Close()
+ client, err := NewClient(Config{APIKey: "secret-key", APIURL: server.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = client.Create(context.Background(), "template", 60)
+ if err == nil || !strings.Contains(err.Error(), "invalid credentials") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if strings.Contains(err.Error(), "secret-key") {
+ t.Fatalf("error leaked API key: %v", err)
+ }
+}
+
+func TestRunUsesEnvdConnectJSONStream(t *testing.T) {
+ t.Parallel()
+ path, handler := processv1connect.NewProcessHandler(processTestHandler{t: t})
+ mux := http.NewServeMux()
+ mux.Handle(path, handler)
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ client, err := NewClient(Config{APIKey: "test-key", SandboxURL: server.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := client.Run(
+ context.Background(),
+ &Sandbox{ID: "sb-command", AccessToken: "command-token"},
+ "printf ok",
+ 1024,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Stdout != "ok" || result.Stderr != "" || result.ExitCode != 0 {
+ t.Fatalf("unexpected command result: %#v", result)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/e2b/generate.go b/worker/worker-bridge-e2b/internal/e2b/generate.go
new file mode 100644
index 0000000..76548a2
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/e2b/generate.go
@@ -0,0 +1,3 @@
+package e2b
+
+//go:generate protoc --go_out=. --go_opt=paths=source_relative --connect-go_out=. --connect-go_opt=paths=source_relative process/v1/process.proto
diff --git a/worker/worker-bridge-e2b/internal/e2b/process/v1/process.pb.go b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.pb.go
new file mode 100644
index 0000000..810ec12
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.pb.go
@@ -0,0 +1,2057 @@
+// Source: https://github.com/e2b-dev/E2B/blob/main/spec/envd/process/process.proto
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.5
+// protoc v7.34.0
+// source: process/v1/process.proto
+
+package processv1
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type Signal int32
+
+const (
+ Signal_SIGNAL_UNSPECIFIED Signal = 0
+ Signal_SIGNAL_SIGTERM Signal = 15
+ Signal_SIGNAL_SIGKILL Signal = 9
+)
+
+// Enum value maps for Signal.
+var (
+ Signal_name = map[int32]string{
+ 0: "SIGNAL_UNSPECIFIED",
+ 15: "SIGNAL_SIGTERM",
+ 9: "SIGNAL_SIGKILL",
+ }
+ Signal_value = map[string]int32{
+ "SIGNAL_UNSPECIFIED": 0,
+ "SIGNAL_SIGTERM": 15,
+ "SIGNAL_SIGKILL": 9,
+ }
+)
+
+func (x Signal) Enum() *Signal {
+ p := new(Signal)
+ *p = x
+ return p
+}
+
+func (x Signal) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Signal) Descriptor() protoreflect.EnumDescriptor {
+ return file_process_v1_process_proto_enumTypes[0].Descriptor()
+}
+
+func (Signal) Type() protoreflect.EnumType {
+ return &file_process_v1_process_proto_enumTypes[0]
+}
+
+func (x Signal) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Signal.Descriptor instead.
+func (Signal) EnumDescriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{0}
+}
+
+type PTY struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *PTY) Reset() {
+ *x = PTY{}
+ mi := &file_process_v1_process_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *PTY) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PTY) ProtoMessage() {}
+
+func (x *PTY) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use PTY.ProtoReflect.Descriptor instead.
+func (*PTY) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *PTY) GetSize() *PTY_Size {
+ if x != nil {
+ return x.Size
+ }
+ return nil
+}
+
+type ProcessConfig struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"`
+ Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
+ Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+ Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessConfig) Reset() {
+ *x = ProcessConfig{}
+ mi := &file_process_v1_process_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessConfig) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessConfig) ProtoMessage() {}
+
+func (x *ProcessConfig) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead.
+func (*ProcessConfig) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ProcessConfig) GetCmd() string {
+ if x != nil {
+ return x.Cmd
+ }
+ return ""
+}
+
+func (x *ProcessConfig) GetArgs() []string {
+ if x != nil {
+ return x.Args
+ }
+ return nil
+}
+
+func (x *ProcessConfig) GetEnvs() map[string]string {
+ if x != nil {
+ return x.Envs
+ }
+ return nil
+}
+
+func (x *ProcessConfig) GetCwd() string {
+ if x != nil && x.Cwd != nil {
+ return *x.Cwd
+ }
+ return ""
+}
+
+type ListRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ListRequest) Reset() {
+ *x = ListRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ListRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListRequest) ProtoMessage() {}
+
+func (x *ListRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead.
+func (*ListRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{2}
+}
+
+type ProcessInfo struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"`
+ Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"`
+ Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessInfo) Reset() {
+ *x = ProcessInfo{}
+ mi := &file_process_v1_process_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessInfo) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessInfo) ProtoMessage() {}
+
+func (x *ProcessInfo) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[3]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead.
+func (*ProcessInfo) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ProcessInfo) GetConfig() *ProcessConfig {
+ if x != nil {
+ return x.Config
+ }
+ return nil
+}
+
+func (x *ProcessInfo) GetPid() uint32 {
+ if x != nil {
+ return x.Pid
+ }
+ return 0
+}
+
+func (x *ProcessInfo) GetTag() string {
+ if x != nil && x.Tag != nil {
+ return *x.Tag
+ }
+ return ""
+}
+
+type ListResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ListResponse) Reset() {
+ *x = ListResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ListResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListResponse) ProtoMessage() {}
+
+func (x *ListResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[4]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead.
+func (*ListResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *ListResponse) GetProcesses() []*ProcessInfo {
+ if x != nil {
+ return x.Processes
+ }
+ return nil
+}
+
+type StartRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"`
+ Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"`
+ Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"`
+ Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StartRequest) Reset() {
+ *x = StartRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StartRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StartRequest) ProtoMessage() {}
+
+func (x *StartRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[5]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead.
+func (*StartRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *StartRequest) GetProcess() *ProcessConfig {
+ if x != nil {
+ return x.Process
+ }
+ return nil
+}
+
+func (x *StartRequest) GetPty() *PTY {
+ if x != nil {
+ return x.Pty
+ }
+ return nil
+}
+
+func (x *StartRequest) GetTag() string {
+ if x != nil && x.Tag != nil {
+ return *x.Tag
+ }
+ return ""
+}
+
+func (x *StartRequest) GetStdin() bool {
+ if x != nil && x.Stdin != nil {
+ return *x.Stdin
+ }
+ return false
+}
+
+type UpdateRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"`
+ Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UpdateRequest) Reset() {
+ *x = UpdateRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UpdateRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateRequest) ProtoMessage() {}
+
+func (x *UpdateRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[6]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead.
+func (*UpdateRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *UpdateRequest) GetProcess() *ProcessSelector {
+ if x != nil {
+ return x.Process
+ }
+ return nil
+}
+
+func (x *UpdateRequest) GetPty() *PTY {
+ if x != nil {
+ return x.Pty
+ }
+ return nil
+}
+
+type UpdateResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UpdateResponse) Reset() {
+ *x = UpdateResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UpdateResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateResponse) ProtoMessage() {}
+
+func (x *UpdateResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[7]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead.
+func (*UpdateResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{7}
+}
+
+type ProcessEvent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Event:
+ //
+ // *ProcessEvent_Start
+ // *ProcessEvent_Data
+ // *ProcessEvent_End
+ // *ProcessEvent_Keepalive
+ Event isProcessEvent_Event `protobuf_oneof:"event"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessEvent) Reset() {
+ *x = ProcessEvent{}
+ mi := &file_process_v1_process_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessEvent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessEvent) ProtoMessage() {}
+
+func (x *ProcessEvent) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead.
+func (*ProcessEvent) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *ProcessEvent) GetEvent() isProcessEvent_Event {
+ if x != nil {
+ return x.Event
+ }
+ return nil
+}
+
+func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent {
+ if x != nil {
+ if x, ok := x.Event.(*ProcessEvent_Start); ok {
+ return x.Start
+ }
+ }
+ return nil
+}
+
+func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent {
+ if x != nil {
+ if x, ok := x.Event.(*ProcessEvent_Data); ok {
+ return x.Data
+ }
+ }
+ return nil
+}
+
+func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent {
+ if x != nil {
+ if x, ok := x.Event.(*ProcessEvent_End); ok {
+ return x.End
+ }
+ }
+ return nil
+}
+
+func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive {
+ if x != nil {
+ if x, ok := x.Event.(*ProcessEvent_Keepalive); ok {
+ return x.Keepalive
+ }
+ }
+ return nil
+}
+
+type isProcessEvent_Event interface {
+ isProcessEvent_Event()
+}
+
+type ProcessEvent_Start struct {
+ Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"`
+}
+
+type ProcessEvent_Data struct {
+ Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"`
+}
+
+type ProcessEvent_End struct {
+ End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"`
+}
+
+type ProcessEvent_Keepalive struct {
+ Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"`
+}
+
+func (*ProcessEvent_Start) isProcessEvent_Event() {}
+
+func (*ProcessEvent_Data) isProcessEvent_Event() {}
+
+func (*ProcessEvent_End) isProcessEvent_Event() {}
+
+func (*ProcessEvent_Keepalive) isProcessEvent_Event() {}
+
+type StartResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StartResponse) Reset() {
+ *x = StartResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[9]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StartResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StartResponse) ProtoMessage() {}
+
+func (x *StartResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[9]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead.
+func (*StartResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *StartResponse) GetEvent() *ProcessEvent {
+ if x != nil {
+ return x.Event
+ }
+ return nil
+}
+
+type ConnectResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ConnectResponse) Reset() {
+ *x = ConnectResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ConnectResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ConnectResponse) ProtoMessage() {}
+
+func (x *ConnectResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[10]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead.
+func (*ConnectResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *ConnectResponse) GetEvent() *ProcessEvent {
+ if x != nil {
+ return x.Event
+ }
+ return nil
+}
+
+type SendInputRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"`
+ Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SendInputRequest) Reset() {
+ *x = SendInputRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[11]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SendInputRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SendInputRequest) ProtoMessage() {}
+
+func (x *SendInputRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[11]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead.
+func (*SendInputRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *SendInputRequest) GetProcess() *ProcessSelector {
+ if x != nil {
+ return x.Process
+ }
+ return nil
+}
+
+func (x *SendInputRequest) GetInput() *ProcessInput {
+ if x != nil {
+ return x.Input
+ }
+ return nil
+}
+
+type SendInputResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SendInputResponse) Reset() {
+ *x = SendInputResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[12]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SendInputResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SendInputResponse) ProtoMessage() {}
+
+func (x *SendInputResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[12]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead.
+func (*SendInputResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{12}
+}
+
+type ProcessInput struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Input:
+ //
+ // *ProcessInput_Stdin
+ // *ProcessInput_Pty
+ Input isProcessInput_Input `protobuf_oneof:"input"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessInput) Reset() {
+ *x = ProcessInput{}
+ mi := &file_process_v1_process_proto_msgTypes[13]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessInput) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessInput) ProtoMessage() {}
+
+func (x *ProcessInput) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[13]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead.
+func (*ProcessInput) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *ProcessInput) GetInput() isProcessInput_Input {
+ if x != nil {
+ return x.Input
+ }
+ return nil
+}
+
+func (x *ProcessInput) GetStdin() []byte {
+ if x != nil {
+ if x, ok := x.Input.(*ProcessInput_Stdin); ok {
+ return x.Stdin
+ }
+ }
+ return nil
+}
+
+func (x *ProcessInput) GetPty() []byte {
+ if x != nil {
+ if x, ok := x.Input.(*ProcessInput_Pty); ok {
+ return x.Pty
+ }
+ }
+ return nil
+}
+
+type isProcessInput_Input interface {
+ isProcessInput_Input()
+}
+
+type ProcessInput_Stdin struct {
+ Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"`
+}
+
+type ProcessInput_Pty struct {
+ Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"`
+}
+
+func (*ProcessInput_Stdin) isProcessInput_Input() {}
+
+func (*ProcessInput_Pty) isProcessInput_Input() {}
+
+type StreamInputRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Event:
+ //
+ // *StreamInputRequest_Start
+ // *StreamInputRequest_Data
+ // *StreamInputRequest_Keepalive
+ Event isStreamInputRequest_Event `protobuf_oneof:"event"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StreamInputRequest) Reset() {
+ *x = StreamInputRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[14]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StreamInputRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StreamInputRequest) ProtoMessage() {}
+
+func (x *StreamInputRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[14]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead.
+func (*StreamInputRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{14}
+}
+
+func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event {
+ if x != nil {
+ return x.Event
+ }
+ return nil
+}
+
+func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent {
+ if x != nil {
+ if x, ok := x.Event.(*StreamInputRequest_Start); ok {
+ return x.Start
+ }
+ }
+ return nil
+}
+
+func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent {
+ if x != nil {
+ if x, ok := x.Event.(*StreamInputRequest_Data); ok {
+ return x.Data
+ }
+ }
+ return nil
+}
+
+func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive {
+ if x != nil {
+ if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok {
+ return x.Keepalive
+ }
+ }
+ return nil
+}
+
+type isStreamInputRequest_Event interface {
+ isStreamInputRequest_Event()
+}
+
+type StreamInputRequest_Start struct {
+ Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"`
+}
+
+type StreamInputRequest_Data struct {
+ Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"`
+}
+
+type StreamInputRequest_Keepalive struct {
+ Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"`
+}
+
+func (*StreamInputRequest_Start) isStreamInputRequest_Event() {}
+
+func (*StreamInputRequest_Data) isStreamInputRequest_Event() {}
+
+func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {}
+
+type StreamInputResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StreamInputResponse) Reset() {
+ *x = StreamInputResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[15]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StreamInputResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StreamInputResponse) ProtoMessage() {}
+
+func (x *StreamInputResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[15]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead.
+func (*StreamInputResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{15}
+}
+
+type SendSignalRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"`
+ Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SendSignalRequest) Reset() {
+ *x = SendSignalRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[16]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SendSignalRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SendSignalRequest) ProtoMessage() {}
+
+func (x *SendSignalRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[16]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead.
+func (*SendSignalRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{16}
+}
+
+func (x *SendSignalRequest) GetProcess() *ProcessSelector {
+ if x != nil {
+ return x.Process
+ }
+ return nil
+}
+
+func (x *SendSignalRequest) GetSignal() Signal {
+ if x != nil {
+ return x.Signal
+ }
+ return Signal_SIGNAL_UNSPECIFIED
+}
+
+type SendSignalResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SendSignalResponse) Reset() {
+ *x = SendSignalResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[17]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SendSignalResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SendSignalResponse) ProtoMessage() {}
+
+func (x *SendSignalResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[17]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead.
+func (*SendSignalResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{17}
+}
+
+type CloseStdinRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CloseStdinRequest) Reset() {
+ *x = CloseStdinRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[18]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CloseStdinRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CloseStdinRequest) ProtoMessage() {}
+
+func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[18]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead.
+func (*CloseStdinRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{18}
+}
+
+func (x *CloseStdinRequest) GetProcess() *ProcessSelector {
+ if x != nil {
+ return x.Process
+ }
+ return nil
+}
+
+type CloseStdinResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CloseStdinResponse) Reset() {
+ *x = CloseStdinResponse{}
+ mi := &file_process_v1_process_proto_msgTypes[19]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CloseStdinResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CloseStdinResponse) ProtoMessage() {}
+
+func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[19]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead.
+func (*CloseStdinResponse) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{19}
+}
+
+type ConnectRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ConnectRequest) Reset() {
+ *x = ConnectRequest{}
+ mi := &file_process_v1_process_proto_msgTypes[20]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ConnectRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ConnectRequest) ProtoMessage() {}
+
+func (x *ConnectRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[20]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead.
+func (*ConnectRequest) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{20}
+}
+
+func (x *ConnectRequest) GetProcess() *ProcessSelector {
+ if x != nil {
+ return x.Process
+ }
+ return nil
+}
+
+type ProcessSelector struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Selector:
+ //
+ // *ProcessSelector_Pid
+ // *ProcessSelector_Tag
+ Selector isProcessSelector_Selector `protobuf_oneof:"selector"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessSelector) Reset() {
+ *x = ProcessSelector{}
+ mi := &file_process_v1_process_proto_msgTypes[21]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessSelector) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessSelector) ProtoMessage() {}
+
+func (x *ProcessSelector) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[21]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead.
+func (*ProcessSelector) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{21}
+}
+
+func (x *ProcessSelector) GetSelector() isProcessSelector_Selector {
+ if x != nil {
+ return x.Selector
+ }
+ return nil
+}
+
+func (x *ProcessSelector) GetPid() uint32 {
+ if x != nil {
+ if x, ok := x.Selector.(*ProcessSelector_Pid); ok {
+ return x.Pid
+ }
+ }
+ return 0
+}
+
+func (x *ProcessSelector) GetTag() string {
+ if x != nil {
+ if x, ok := x.Selector.(*ProcessSelector_Tag); ok {
+ return x.Tag
+ }
+ }
+ return ""
+}
+
+type isProcessSelector_Selector interface {
+ isProcessSelector_Selector()
+}
+
+type ProcessSelector_Pid struct {
+ Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"`
+}
+
+type ProcessSelector_Tag struct {
+ Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"`
+}
+
+func (*ProcessSelector_Pid) isProcessSelector_Selector() {}
+
+func (*ProcessSelector_Tag) isProcessSelector_Selector() {}
+
+type PTY_Size struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"`
+ Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *PTY_Size) Reset() {
+ *x = PTY_Size{}
+ mi := &file_process_v1_process_proto_msgTypes[22]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *PTY_Size) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PTY_Size) ProtoMessage() {}
+
+func (x *PTY_Size) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[22]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead.
+func (*PTY_Size) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{0, 0}
+}
+
+func (x *PTY_Size) GetCols() uint32 {
+ if x != nil {
+ return x.Cols
+ }
+ return 0
+}
+
+func (x *PTY_Size) GetRows() uint32 {
+ if x != nil {
+ return x.Rows
+ }
+ return 0
+}
+
+type ProcessEvent_StartEvent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessEvent_StartEvent) Reset() {
+ *x = ProcessEvent_StartEvent{}
+ mi := &file_process_v1_process_proto_msgTypes[24]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessEvent_StartEvent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessEvent_StartEvent) ProtoMessage() {}
+
+func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[24]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead.
+func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{8, 0}
+}
+
+func (x *ProcessEvent_StartEvent) GetPid() uint32 {
+ if x != nil {
+ return x.Pid
+ }
+ return 0
+}
+
+type ProcessEvent_DataEvent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Output:
+ //
+ // *ProcessEvent_DataEvent_Stdout
+ // *ProcessEvent_DataEvent_Stderr
+ // *ProcessEvent_DataEvent_Pty
+ Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessEvent_DataEvent) Reset() {
+ *x = ProcessEvent_DataEvent{}
+ mi := &file_process_v1_process_proto_msgTypes[25]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessEvent_DataEvent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessEvent_DataEvent) ProtoMessage() {}
+
+func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[25]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead.
+func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{8, 1}
+}
+
+func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output {
+ if x != nil {
+ return x.Output
+ }
+ return nil
+}
+
+func (x *ProcessEvent_DataEvent) GetStdout() []byte {
+ if x != nil {
+ if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok {
+ return x.Stdout
+ }
+ }
+ return nil
+}
+
+func (x *ProcessEvent_DataEvent) GetStderr() []byte {
+ if x != nil {
+ if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok {
+ return x.Stderr
+ }
+ }
+ return nil
+}
+
+func (x *ProcessEvent_DataEvent) GetPty() []byte {
+ if x != nil {
+ if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok {
+ return x.Pty
+ }
+ }
+ return nil
+}
+
+type isProcessEvent_DataEvent_Output interface {
+ isProcessEvent_DataEvent_Output()
+}
+
+type ProcessEvent_DataEvent_Stdout struct {
+ Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"`
+}
+
+type ProcessEvent_DataEvent_Stderr struct {
+ Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"`
+}
+
+type ProcessEvent_DataEvent_Pty struct {
+ Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"`
+}
+
+func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {}
+
+func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {}
+
+func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {}
+
+type ProcessEvent_EndEvent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
+ Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"`
+ Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
+ Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessEvent_EndEvent) Reset() {
+ *x = ProcessEvent_EndEvent{}
+ mi := &file_process_v1_process_proto_msgTypes[26]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessEvent_EndEvent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessEvent_EndEvent) ProtoMessage() {}
+
+func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[26]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead.
+func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{8, 2}
+}
+
+func (x *ProcessEvent_EndEvent) GetExitCode() int32 {
+ if x != nil {
+ return x.ExitCode
+ }
+ return 0
+}
+
+func (x *ProcessEvent_EndEvent) GetExited() bool {
+ if x != nil {
+ return x.Exited
+ }
+ return false
+}
+
+func (x *ProcessEvent_EndEvent) GetStatus() string {
+ if x != nil {
+ return x.Status
+ }
+ return ""
+}
+
+func (x *ProcessEvent_EndEvent) GetError() string {
+ if x != nil && x.Error != nil {
+ return *x.Error
+ }
+ return ""
+}
+
+type ProcessEvent_KeepAlive struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ProcessEvent_KeepAlive) Reset() {
+ *x = ProcessEvent_KeepAlive{}
+ mi := &file_process_v1_process_proto_msgTypes[27]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ProcessEvent_KeepAlive) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProcessEvent_KeepAlive) ProtoMessage() {}
+
+func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[27]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead.
+func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{8, 3}
+}
+
+type StreamInputRequest_StartEvent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StreamInputRequest_StartEvent) Reset() {
+ *x = StreamInputRequest_StartEvent{}
+ mi := &file_process_v1_process_proto_msgTypes[28]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StreamInputRequest_StartEvent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StreamInputRequest_StartEvent) ProtoMessage() {}
+
+func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[28]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead.
+func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{14, 0}
+}
+
+func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector {
+ if x != nil {
+ return x.Process
+ }
+ return nil
+}
+
+type StreamInputRequest_DataEvent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StreamInputRequest_DataEvent) Reset() {
+ *x = StreamInputRequest_DataEvent{}
+ mi := &file_process_v1_process_proto_msgTypes[29]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StreamInputRequest_DataEvent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StreamInputRequest_DataEvent) ProtoMessage() {}
+
+func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[29]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead.
+func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{14, 1}
+}
+
+func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput {
+ if x != nil {
+ return x.Input
+ }
+ return nil
+}
+
+type StreamInputRequest_KeepAlive struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StreamInputRequest_KeepAlive) Reset() {
+ *x = StreamInputRequest_KeepAlive{}
+ mi := &file_process_v1_process_proto_msgTypes[30]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StreamInputRequest_KeepAlive) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StreamInputRequest_KeepAlive) ProtoMessage() {}
+
+func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message {
+ mi := &file_process_v1_process_proto_msgTypes[30]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead.
+func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) {
+ return file_process_v1_process_proto_rawDescGZIP(), []int{14, 2}
+}
+
+var File_process_v1_process_proto protoreflect.FileDescriptor
+
+var file_process_v1_process_proto_rawDesc = string([]byte{
+ 0x0a, 0x18, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x72, 0x6f,
+ 0x63, 0x65, 0x73, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x70, 0x72, 0x6f, 0x63,
+ 0x65, 0x73, 0x73, 0x22, 0x5c, 0x0a, 0x03, 0x50, 0x54, 0x59, 0x12, 0x25, 0x0a, 0x04, 0x73, 0x69,
+ 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65,
+ 0x73, 0x73, 0x2e, 0x50, 0x54, 0x59, 0x2e, 0x53, 0x69, 0x7a, 0x65, 0x52, 0x04, 0x73, 0x69, 0x7a,
+ 0x65, 0x1a, 0x2e, 0x0a, 0x04, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x6c,
+ 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x12, 0x12, 0x0a,
+ 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x72, 0x6f, 0x77,
+ 0x73, 0x22, 0xc3, 0x01, 0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e,
+ 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x6d, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x03, 0x63, 0x6d, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x02, 0x20,
+ 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x04, 0x65, 0x6e, 0x76,
+ 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73,
+ 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e,
+ 0x45, 0x6e, 0x76, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, 0x65, 0x6e, 0x76, 0x73, 0x12,
+ 0x15, 0x0a, 0x03, 0x63, 0x77, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03,
+ 0x63, 0x77, 0x64, 0x88, 0x01, 0x01, 0x1a, 0x37, 0x0a, 0x09, 0x45, 0x6e, 0x76, 0x73, 0x45, 0x6e,
+ 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42,
+ 0x06, 0x0a, 0x04, 0x5f, 0x63, 0x77, 0x64, 0x22, 0x0d, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52,
+ 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x6e, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73,
+ 0x73, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2e, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e,
+ 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
+ 0x28, 0x0d, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x03,
+ 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, 0x74, 0x61, 0x67, 0x88, 0x01, 0x01, 0x42, 0x06,
+ 0x0a, 0x04, 0x5f, 0x74, 0x61, 0x67, 0x22, 0x42, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65,
+ 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73,
+ 0x73, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x70, 0x72, 0x6f, 0x63,
+ 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x6e, 0x66, 0x6f, 0x52,
+ 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x65, 0x73, 0x22, 0xb1, 0x01, 0x0a, 0x0c, 0x53,
+ 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x30, 0x0a, 0x07, 0x70,
+ 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70,
+ 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a,
+ 0x03, 0x70, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x72, 0x6f,
+ 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x54, 0x59, 0x48, 0x00, 0x52, 0x03, 0x70, 0x74, 0x79, 0x88,
+ 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48,
+ 0x01, 0x52, 0x03, 0x74, 0x61, 0x67, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x73, 0x74, 0x64,
+ 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x05, 0x73, 0x74, 0x64, 0x69,
+ 0x6e, 0x88, 0x01, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x70, 0x74, 0x79, 0x42, 0x06, 0x0a, 0x04,
+ 0x5f, 0x74, 0x61, 0x67, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x22, 0x70,
+ 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+ 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65,
+ 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63,
+ 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x03, 0x70, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x0c, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x54, 0x59, 0x48, 0x00,
+ 0x52, 0x03, 0x70, 0x74, 0x79, 0x88, 0x01, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x70, 0x74, 0x79,
+ 0x22, 0x10, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x22, 0x87, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76,
+ 0x65, 0x6e, 0x74, 0x12, 0x38, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f,
+ 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x45,
+ 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x35, 0x0a,
+ 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, 0x65,
+ 0x6e, 0x74, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x04,
+ 0x64, 0x61, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63,
+ 0x65, 0x73, 0x73, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x64, 0x45, 0x76, 0x65, 0x6e,
+ 0x74, 0x48, 0x00, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x3f, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70,
+ 0x61, 0x6c, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, 0x65,
+ 0x6e, 0x74, 0x2e, 0x4b, 0x65, 0x65, 0x70, 0x41, 0x6c, 0x69, 0x76, 0x65, 0x48, 0x00, 0x52, 0x09,
+ 0x6b, 0x65, 0x65, 0x70, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x1a, 0x1e, 0x0a, 0x0a, 0x53, 0x74, 0x61,
+ 0x72, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x70, 0x69, 0x64, 0x1a, 0x5d, 0x0a, 0x09, 0x44, 0x61, 0x74,
+ 0x61, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74,
+ 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,
+ 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, 0x72, 0x12, 0x12, 0x0a, 0x03, 0x70, 0x74,
+ 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x03, 0x70, 0x74, 0x79, 0x42, 0x08,
+ 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x1a, 0x7c, 0x0a, 0x08, 0x45, 0x6e, 0x64, 0x45,
+ 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64,
+ 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x11, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64,
+ 0x65, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x69, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x06, 0x65, 0x78, 0x69, 0x74, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61,
+ 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
+ 0x73, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+ 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06,
+ 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0x0b, 0x0a, 0x09, 0x4b, 0x65, 0x65, 0x70, 0x41, 0x6c,
+ 0x69, 0x76, 0x65, 0x42, 0x07, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x3c, 0x0a, 0x0d,
+ 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a,
+ 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70,
+ 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76,
+ 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x3e, 0x0a, 0x0f, 0x43, 0x6f,
+ 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a,
+ 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70,
+ 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76,
+ 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x73, 0x0a, 0x10, 0x53, 0x65,
+ 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32,
+ 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73,
+ 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65,
+ 0x73, 0x73, 0x12, 0x2b, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63,
+ 0x65, 0x73, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x22,
+ 0x13, 0x0a, 0x11, 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x43, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49,
+ 0x6e, 0x70, 0x75, 0x74, 0x12, 0x16, 0x0a, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x03,
+ 0x70, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x03, 0x70, 0x74, 0x79,
+ 0x42, 0x07, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x22, 0xea, 0x02, 0x0a, 0x12, 0x53, 0x74,
+ 0x72, 0x65, 0x61, 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+ 0x12, 0x3e, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d,
+ 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x74, 0x61,
+ 0x72, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74,
+ 0x12, 0x3b, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25,
+ 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49,
+ 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x61, 0x74, 0x61,
+ 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x45, 0x0a,
+ 0x09, 0x6b, 0x65, 0x65, 0x70, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61,
+ 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4b, 0x65,
+ 0x65, 0x70, 0x41, 0x6c, 0x69, 0x76, 0x65, 0x48, 0x00, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x61,
+ 0x6c, 0x69, 0x76, 0x65, 0x1a, 0x40, 0x0a, 0x0a, 0x53, 0x74, 0x61, 0x72, 0x74, 0x45, 0x76, 0x65,
+ 0x6e, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70,
+ 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x1a, 0x38, 0x0a, 0x09, 0x44, 0x61, 0x74, 0x61, 0x45, 0x76,
+ 0x65, 0x6e, 0x74, 0x12, 0x2b, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01,
+ 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f,
+ 0x63, 0x65, 0x73, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74,
+ 0x1a, 0x0b, 0x0a, 0x09, 0x4b, 0x65, 0x65, 0x70, 0x41, 0x6c, 0x69, 0x76, 0x65, 0x42, 0x07, 0x0a,
+ 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x15, 0x0a, 0x13, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d,
+ 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x70, 0x0a,
+ 0x11, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70,
+ 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x27, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73,
+ 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x22,
+ 0x14, 0x0a, 0x12, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x47, 0x0a, 0x11, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x74,
+ 0x64, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c,
+ 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x22, 0x14,
+ 0x0a, 0x12, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x52,
+ 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73,
+ 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73,
+ 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f,
+ 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x22, 0x45, 0x0a, 0x0f, 0x50, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x12, 0x0a,
+ 0x03, 0x70, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69,
+ 0x64, 0x12, 0x12, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
+ 0x52, 0x03, 0x74, 0x61, 0x67, 0x42, 0x0a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f,
+ 0x72, 0x2a, 0x48, 0x0a, 0x06, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x12, 0x53,
+ 0x49, 0x47, 0x4e, 0x41, 0x4c, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45,
+ 0x44, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x49, 0x47, 0x4e, 0x41, 0x4c, 0x5f, 0x53, 0x49,
+ 0x47, 0x54, 0x45, 0x52, 0x4d, 0x10, 0x0f, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x49, 0x47, 0x4e, 0x41,
+ 0x4c, 0x5f, 0x53, 0x49, 0x47, 0x4b, 0x49, 0x4c, 0x4c, 0x10, 0x09, 0x32, 0x91, 0x04, 0x0a, 0x07,
+ 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x33, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12,
+ 0x14, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e,
+ 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07,
+ 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73,
+ 0x73, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+ 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65,
+ 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x38, 0x0a, 0x05,
+ 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e,
+ 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x70,
+ 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x39, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+ 0x12, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74,
+ 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65,
+ 0x73, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+ 0x65, 0x12, 0x4a, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74,
+ 0x12, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61,
+ 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e,
+ 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x6e,
+ 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x42, 0x0a,
+ 0x09, 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x19, 0x2e, 0x70, 0x72, 0x6f,
+ 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e,
+ 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+ 0x65, 0x12, 0x45, 0x0a, 0x0a, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12,
+ 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69,
+ 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x0a, 0x43, 0x6c, 0x6f, 0x73,
+ 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73,
+ 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x43, 0x6c, 0x6f,
+ 0x73, 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42,
+ 0x5b, 0x5a, 0x59, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x6e,
+ 0x6c, 0x79, 0x62, 0x6f, 0x78, 0x65, 0x73, 0x2f, 0x6f, 0x6e, 0x6c, 0x79, 0x62, 0x6f, 0x78, 0x65,
+ 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x2f, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x2d,
+ 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x2d, 0x65, 0x32, 0x62, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72,
+ 0x6e, 0x61, 0x6c, 0x2f, 0x65, 0x32, 0x62, 0x2f, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2f,
+ 0x76, 0x31, 0x3b, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72,
+ 0x6f, 0x74, 0x6f, 0x33,
+})
+
+var (
+ file_process_v1_process_proto_rawDescOnce sync.Once
+ file_process_v1_process_proto_rawDescData []byte
+)
+
+func file_process_v1_process_proto_rawDescGZIP() []byte {
+ file_process_v1_process_proto_rawDescOnce.Do(func() {
+ file_process_v1_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_v1_process_proto_rawDesc), len(file_process_v1_process_proto_rawDesc)))
+ })
+ return file_process_v1_process_proto_rawDescData
+}
+
+var file_process_v1_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_process_v1_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31)
+var file_process_v1_process_proto_goTypes = []any{
+ (Signal)(0), // 0: process.Signal
+ (*PTY)(nil), // 1: process.PTY
+ (*ProcessConfig)(nil), // 2: process.ProcessConfig
+ (*ListRequest)(nil), // 3: process.ListRequest
+ (*ProcessInfo)(nil), // 4: process.ProcessInfo
+ (*ListResponse)(nil), // 5: process.ListResponse
+ (*StartRequest)(nil), // 6: process.StartRequest
+ (*UpdateRequest)(nil), // 7: process.UpdateRequest
+ (*UpdateResponse)(nil), // 8: process.UpdateResponse
+ (*ProcessEvent)(nil), // 9: process.ProcessEvent
+ (*StartResponse)(nil), // 10: process.StartResponse
+ (*ConnectResponse)(nil), // 11: process.ConnectResponse
+ (*SendInputRequest)(nil), // 12: process.SendInputRequest
+ (*SendInputResponse)(nil), // 13: process.SendInputResponse
+ (*ProcessInput)(nil), // 14: process.ProcessInput
+ (*StreamInputRequest)(nil), // 15: process.StreamInputRequest
+ (*StreamInputResponse)(nil), // 16: process.StreamInputResponse
+ (*SendSignalRequest)(nil), // 17: process.SendSignalRequest
+ (*SendSignalResponse)(nil), // 18: process.SendSignalResponse
+ (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest
+ (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse
+ (*ConnectRequest)(nil), // 21: process.ConnectRequest
+ (*ProcessSelector)(nil), // 22: process.ProcessSelector
+ (*PTY_Size)(nil), // 23: process.PTY.Size
+ nil, // 24: process.ProcessConfig.EnvsEntry
+ (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent
+ (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent
+ (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent
+ (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive
+ (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent
+ (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent
+ (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive
+}
+var file_process_v1_process_proto_depIdxs = []int32{
+ 23, // 0: process.PTY.size:type_name -> process.PTY.Size
+ 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry
+ 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig
+ 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo
+ 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig
+ 1, // 5: process.StartRequest.pty:type_name -> process.PTY
+ 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector
+ 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY
+ 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent
+ 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent
+ 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent
+ 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive
+ 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent
+ 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent
+ 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector
+ 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput
+ 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent
+ 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent
+ 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive
+ 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector
+ 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal
+ 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector
+ 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector
+ 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector
+ 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput
+ 3, // 25: process.Process.List:input_type -> process.ListRequest
+ 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest
+ 6, // 27: process.Process.Start:input_type -> process.StartRequest
+ 7, // 28: process.Process.Update:input_type -> process.UpdateRequest
+ 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest
+ 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest
+ 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest
+ 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest
+ 5, // 33: process.Process.List:output_type -> process.ListResponse
+ 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse
+ 10, // 35: process.Process.Start:output_type -> process.StartResponse
+ 8, // 36: process.Process.Update:output_type -> process.UpdateResponse
+ 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse
+ 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse
+ 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse
+ 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse
+ 33, // [33:41] is the sub-list for method output_type
+ 25, // [25:33] is the sub-list for method input_type
+ 25, // [25:25] is the sub-list for extension type_name
+ 25, // [25:25] is the sub-list for extension extendee
+ 0, // [0:25] is the sub-list for field type_name
+}
+
+func init() { file_process_v1_process_proto_init() }
+func file_process_v1_process_proto_init() {
+ if File_process_v1_process_proto != nil {
+ return
+ }
+ file_process_v1_process_proto_msgTypes[1].OneofWrappers = []any{}
+ file_process_v1_process_proto_msgTypes[3].OneofWrappers = []any{}
+ file_process_v1_process_proto_msgTypes[5].OneofWrappers = []any{}
+ file_process_v1_process_proto_msgTypes[6].OneofWrappers = []any{}
+ file_process_v1_process_proto_msgTypes[8].OneofWrappers = []any{
+ (*ProcessEvent_Start)(nil),
+ (*ProcessEvent_Data)(nil),
+ (*ProcessEvent_End)(nil),
+ (*ProcessEvent_Keepalive)(nil),
+ }
+ file_process_v1_process_proto_msgTypes[13].OneofWrappers = []any{
+ (*ProcessInput_Stdin)(nil),
+ (*ProcessInput_Pty)(nil),
+ }
+ file_process_v1_process_proto_msgTypes[14].OneofWrappers = []any{
+ (*StreamInputRequest_Start)(nil),
+ (*StreamInputRequest_Data)(nil),
+ (*StreamInputRequest_Keepalive)(nil),
+ }
+ file_process_v1_process_proto_msgTypes[21].OneofWrappers = []any{
+ (*ProcessSelector_Pid)(nil),
+ (*ProcessSelector_Tag)(nil),
+ }
+ file_process_v1_process_proto_msgTypes[25].OneofWrappers = []any{
+ (*ProcessEvent_DataEvent_Stdout)(nil),
+ (*ProcessEvent_DataEvent_Stderr)(nil),
+ (*ProcessEvent_DataEvent_Pty)(nil),
+ }
+ file_process_v1_process_proto_msgTypes[26].OneofWrappers = []any{}
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_v1_process_proto_rawDesc), len(file_process_v1_process_proto_rawDesc)),
+ NumEnums: 1,
+ NumMessages: 31,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_process_v1_process_proto_goTypes,
+ DependencyIndexes: file_process_v1_process_proto_depIdxs,
+ EnumInfos: file_process_v1_process_proto_enumTypes,
+ MessageInfos: file_process_v1_process_proto_msgTypes,
+ }.Build()
+ File_process_v1_process_proto = out.File
+ file_process_v1_process_proto_goTypes = nil
+ file_process_v1_process_proto_depIdxs = nil
+}
diff --git a/worker/worker-bridge-e2b/internal/e2b/process/v1/process.proto b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.proto
new file mode 100644
index 0000000..8209e7c
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.proto
@@ -0,0 +1,160 @@
+// Source: https://github.com/e2b-dev/E2B/blob/main/spec/envd/process/process.proto
+syntax = "proto3";
+
+package process;
+
+option go_package = "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1;processv1";
+
+service Process {
+ rpc List(ListRequest) returns (ListResponse);
+ rpc Connect(ConnectRequest) returns (stream ConnectResponse);
+ rpc Start(StartRequest) returns (stream StartResponse);
+ rpc Update(UpdateRequest) returns (UpdateResponse);
+ rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse);
+ rpc SendInput(SendInputRequest) returns (SendInputResponse);
+ rpc SendSignal(SendSignalRequest) returns (SendSignalResponse);
+ rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse);
+}
+
+message PTY {
+ Size size = 1;
+ message Size {
+ uint32 cols = 1;
+ uint32 rows = 2;
+ }
+}
+
+message ProcessConfig {
+ string cmd = 1;
+ repeated string args = 2;
+ map envs = 3;
+ optional string cwd = 4;
+}
+
+message ListRequest {}
+
+message ProcessInfo {
+ ProcessConfig config = 1;
+ uint32 pid = 2;
+ optional string tag = 3;
+}
+
+message ListResponse {
+ repeated ProcessInfo processes = 1;
+}
+
+message StartRequest {
+ ProcessConfig process = 1;
+ optional PTY pty = 2;
+ optional string tag = 3;
+ optional bool stdin = 4;
+}
+
+message UpdateRequest {
+ ProcessSelector process = 1;
+ optional PTY pty = 2;
+}
+
+message UpdateResponse {}
+
+message ProcessEvent {
+ oneof event {
+ StartEvent start = 1;
+ DataEvent data = 2;
+ EndEvent end = 3;
+ KeepAlive keepalive = 4;
+ }
+
+ message StartEvent {
+ uint32 pid = 1;
+ }
+
+ message DataEvent {
+ oneof output {
+ bytes stdout = 1;
+ bytes stderr = 2;
+ bytes pty = 3;
+ }
+ }
+
+ message EndEvent {
+ sint32 exit_code = 1;
+ bool exited = 2;
+ string status = 3;
+ optional string error = 4;
+ }
+
+ message KeepAlive {}
+}
+
+message StartResponse {
+ ProcessEvent event = 1;
+}
+
+message ConnectResponse {
+ ProcessEvent event = 1;
+}
+
+message SendInputRequest {
+ ProcessSelector process = 1;
+ ProcessInput input = 2;
+}
+
+message SendInputResponse {}
+
+message ProcessInput {
+ oneof input {
+ bytes stdin = 1;
+ bytes pty = 2;
+ }
+}
+
+message StreamInputRequest {
+ oneof event {
+ StartEvent start = 1;
+ DataEvent data = 2;
+ KeepAlive keepalive = 3;
+ }
+
+ message StartEvent {
+ ProcessSelector process = 1;
+ }
+
+ message DataEvent {
+ ProcessInput input = 2;
+ }
+
+ message KeepAlive {}
+}
+
+message StreamInputResponse {}
+
+enum Signal {
+ SIGNAL_UNSPECIFIED = 0;
+ SIGNAL_SIGTERM = 15;
+ SIGNAL_SIGKILL = 9;
+}
+
+message SendSignalRequest {
+ ProcessSelector process = 1;
+ Signal signal = 2;
+}
+
+message SendSignalResponse {}
+
+message CloseStdinRequest {
+ ProcessSelector process = 1;
+}
+
+message CloseStdinResponse {}
+
+message ConnectRequest {
+ ProcessSelector process = 1;
+}
+
+message ProcessSelector {
+ oneof selector {
+ uint32 pid = 1;
+ string tag = 2;
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect/process.connect.go b/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect/process.connect.go
new file mode 100644
index 0000000..86bb3fe
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect/process.connect.go
@@ -0,0 +1,306 @@
+// Source: https://github.com/e2b-dev/E2B/blob/main/spec/envd/process/process.proto
+
+// Code generated by protoc-gen-connect-go. DO NOT EDIT.
+//
+// Source: process/v1/process.proto
+
+package processv1connect
+
+import (
+ connect "connectrpc.com/connect"
+ context "context"
+ errors "errors"
+ v1 "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1"
+ http "net/http"
+ strings "strings"
+)
+
+// This is a compile-time assertion to ensure that this generated file and the connect package are
+// compatible. If you get a compiler error that this constant is not defined, this code was
+// generated with a version of connect newer than the one compiled into your binary. You can fix the
+// problem by either regenerating this code with an older version of connect or updating the connect
+// version compiled into your binary.
+const _ = connect.IsAtLeastVersion1_13_0
+
+const (
+ // ProcessName is the fully-qualified name of the Process service.
+ ProcessName = "process.Process"
+)
+
+// These constants are the fully-qualified names of the RPCs defined in this package. They're
+// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
+//
+// Note that these are different from the fully-qualified method names used by
+// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
+// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
+// period.
+const (
+ // ProcessListProcedure is the fully-qualified name of the Process's List RPC.
+ ProcessListProcedure = "/process.Process/List"
+ // ProcessConnectProcedure is the fully-qualified name of the Process's Connect RPC.
+ ProcessConnectProcedure = "/process.Process/Connect"
+ // ProcessStartProcedure is the fully-qualified name of the Process's Start RPC.
+ ProcessStartProcedure = "/process.Process/Start"
+ // ProcessUpdateProcedure is the fully-qualified name of the Process's Update RPC.
+ ProcessUpdateProcedure = "/process.Process/Update"
+ // ProcessStreamInputProcedure is the fully-qualified name of the Process's StreamInput RPC.
+ ProcessStreamInputProcedure = "/process.Process/StreamInput"
+ // ProcessSendInputProcedure is the fully-qualified name of the Process's SendInput RPC.
+ ProcessSendInputProcedure = "/process.Process/SendInput"
+ // ProcessSendSignalProcedure is the fully-qualified name of the Process's SendSignal RPC.
+ ProcessSendSignalProcedure = "/process.Process/SendSignal"
+ // ProcessCloseStdinProcedure is the fully-qualified name of the Process's CloseStdin RPC.
+ ProcessCloseStdinProcedure = "/process.Process/CloseStdin"
+)
+
+// ProcessClient is a client for the process.Process service.
+type ProcessClient interface {
+ List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error)
+ Connect(context.Context, *connect.Request[v1.ConnectRequest]) (*connect.ServerStreamForClient[v1.ConnectResponse], error)
+ Start(context.Context, *connect.Request[v1.StartRequest]) (*connect.ServerStreamForClient[v1.StartResponse], error)
+ Update(context.Context, *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error)
+ StreamInput(context.Context) *connect.ClientStreamForClient[v1.StreamInputRequest, v1.StreamInputResponse]
+ SendInput(context.Context, *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error)
+ SendSignal(context.Context, *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error)
+ CloseStdin(context.Context, *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error)
+}
+
+// NewProcessClient constructs a client for the process.Process service. By default, it uses the
+// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends
+// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or
+// connect.WithGRPCWeb() options.
+//
+// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
+// http://api.acme.com or https://acme.com/grpc).
+func NewProcessClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProcessClient {
+ baseURL = strings.TrimRight(baseURL, "/")
+ processMethods := v1.File_process_v1_process_proto.Services().ByName("Process").Methods()
+ return &processClient{
+ list: connect.NewClient[v1.ListRequest, v1.ListResponse](
+ httpClient,
+ baseURL+ProcessListProcedure,
+ connect.WithSchema(processMethods.ByName("List")),
+ connect.WithClientOptions(opts...),
+ ),
+ connect: connect.NewClient[v1.ConnectRequest, v1.ConnectResponse](
+ httpClient,
+ baseURL+ProcessConnectProcedure,
+ connect.WithSchema(processMethods.ByName("Connect")),
+ connect.WithClientOptions(opts...),
+ ),
+ start: connect.NewClient[v1.StartRequest, v1.StartResponse](
+ httpClient,
+ baseURL+ProcessStartProcedure,
+ connect.WithSchema(processMethods.ByName("Start")),
+ connect.WithClientOptions(opts...),
+ ),
+ update: connect.NewClient[v1.UpdateRequest, v1.UpdateResponse](
+ httpClient,
+ baseURL+ProcessUpdateProcedure,
+ connect.WithSchema(processMethods.ByName("Update")),
+ connect.WithClientOptions(opts...),
+ ),
+ streamInput: connect.NewClient[v1.StreamInputRequest, v1.StreamInputResponse](
+ httpClient,
+ baseURL+ProcessStreamInputProcedure,
+ connect.WithSchema(processMethods.ByName("StreamInput")),
+ connect.WithClientOptions(opts...),
+ ),
+ sendInput: connect.NewClient[v1.SendInputRequest, v1.SendInputResponse](
+ httpClient,
+ baseURL+ProcessSendInputProcedure,
+ connect.WithSchema(processMethods.ByName("SendInput")),
+ connect.WithClientOptions(opts...),
+ ),
+ sendSignal: connect.NewClient[v1.SendSignalRequest, v1.SendSignalResponse](
+ httpClient,
+ baseURL+ProcessSendSignalProcedure,
+ connect.WithSchema(processMethods.ByName("SendSignal")),
+ connect.WithClientOptions(opts...),
+ ),
+ closeStdin: connect.NewClient[v1.CloseStdinRequest, v1.CloseStdinResponse](
+ httpClient,
+ baseURL+ProcessCloseStdinProcedure,
+ connect.WithSchema(processMethods.ByName("CloseStdin")),
+ connect.WithClientOptions(opts...),
+ ),
+ }
+}
+
+// processClient implements ProcessClient.
+type processClient struct {
+ list *connect.Client[v1.ListRequest, v1.ListResponse]
+ connect *connect.Client[v1.ConnectRequest, v1.ConnectResponse]
+ start *connect.Client[v1.StartRequest, v1.StartResponse]
+ update *connect.Client[v1.UpdateRequest, v1.UpdateResponse]
+ streamInput *connect.Client[v1.StreamInputRequest, v1.StreamInputResponse]
+ sendInput *connect.Client[v1.SendInputRequest, v1.SendInputResponse]
+ sendSignal *connect.Client[v1.SendSignalRequest, v1.SendSignalResponse]
+ closeStdin *connect.Client[v1.CloseStdinRequest, v1.CloseStdinResponse]
+}
+
+// List calls process.Process.List.
+func (c *processClient) List(ctx context.Context, req *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) {
+ return c.list.CallUnary(ctx, req)
+}
+
+// Connect calls process.Process.Connect.
+func (c *processClient) Connect(ctx context.Context, req *connect.Request[v1.ConnectRequest]) (*connect.ServerStreamForClient[v1.ConnectResponse], error) {
+ return c.connect.CallServerStream(ctx, req)
+}
+
+// Start calls process.Process.Start.
+func (c *processClient) Start(ctx context.Context, req *connect.Request[v1.StartRequest]) (*connect.ServerStreamForClient[v1.StartResponse], error) {
+ return c.start.CallServerStream(ctx, req)
+}
+
+// Update calls process.Process.Update.
+func (c *processClient) Update(ctx context.Context, req *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error) {
+ return c.update.CallUnary(ctx, req)
+}
+
+// StreamInput calls process.Process.StreamInput.
+func (c *processClient) StreamInput(ctx context.Context) *connect.ClientStreamForClient[v1.StreamInputRequest, v1.StreamInputResponse] {
+ return c.streamInput.CallClientStream(ctx)
+}
+
+// SendInput calls process.Process.SendInput.
+func (c *processClient) SendInput(ctx context.Context, req *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error) {
+ return c.sendInput.CallUnary(ctx, req)
+}
+
+// SendSignal calls process.Process.SendSignal.
+func (c *processClient) SendSignal(ctx context.Context, req *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error) {
+ return c.sendSignal.CallUnary(ctx, req)
+}
+
+// CloseStdin calls process.Process.CloseStdin.
+func (c *processClient) CloseStdin(ctx context.Context, req *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error) {
+ return c.closeStdin.CallUnary(ctx, req)
+}
+
+// ProcessHandler is an implementation of the process.Process service.
+type ProcessHandler interface {
+ List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error)
+ Connect(context.Context, *connect.Request[v1.ConnectRequest], *connect.ServerStream[v1.ConnectResponse]) error
+ Start(context.Context, *connect.Request[v1.StartRequest], *connect.ServerStream[v1.StartResponse]) error
+ Update(context.Context, *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error)
+ StreamInput(context.Context, *connect.ClientStream[v1.StreamInputRequest]) (*connect.Response[v1.StreamInputResponse], error)
+ SendInput(context.Context, *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error)
+ SendSignal(context.Context, *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error)
+ CloseStdin(context.Context, *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error)
+}
+
+// NewProcessHandler builds an HTTP handler from the service implementation. It returns the path on
+// which to mount the handler and the handler itself.
+//
+// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
+// and JSON codecs. They also support gzip compression.
+func NewProcessHandler(svc ProcessHandler, opts ...connect.HandlerOption) (string, http.Handler) {
+ processMethods := v1.File_process_v1_process_proto.Services().ByName("Process").Methods()
+ processListHandler := connect.NewUnaryHandler(
+ ProcessListProcedure,
+ svc.List,
+ connect.WithSchema(processMethods.ByName("List")),
+ connect.WithHandlerOptions(opts...),
+ )
+ processConnectHandler := connect.NewServerStreamHandler(
+ ProcessConnectProcedure,
+ svc.Connect,
+ connect.WithSchema(processMethods.ByName("Connect")),
+ connect.WithHandlerOptions(opts...),
+ )
+ processStartHandler := connect.NewServerStreamHandler(
+ ProcessStartProcedure,
+ svc.Start,
+ connect.WithSchema(processMethods.ByName("Start")),
+ connect.WithHandlerOptions(opts...),
+ )
+ processUpdateHandler := connect.NewUnaryHandler(
+ ProcessUpdateProcedure,
+ svc.Update,
+ connect.WithSchema(processMethods.ByName("Update")),
+ connect.WithHandlerOptions(opts...),
+ )
+ processStreamInputHandler := connect.NewClientStreamHandler(
+ ProcessStreamInputProcedure,
+ svc.StreamInput,
+ connect.WithSchema(processMethods.ByName("StreamInput")),
+ connect.WithHandlerOptions(opts...),
+ )
+ processSendInputHandler := connect.NewUnaryHandler(
+ ProcessSendInputProcedure,
+ svc.SendInput,
+ connect.WithSchema(processMethods.ByName("SendInput")),
+ connect.WithHandlerOptions(opts...),
+ )
+ processSendSignalHandler := connect.NewUnaryHandler(
+ ProcessSendSignalProcedure,
+ svc.SendSignal,
+ connect.WithSchema(processMethods.ByName("SendSignal")),
+ connect.WithHandlerOptions(opts...),
+ )
+ processCloseStdinHandler := connect.NewUnaryHandler(
+ ProcessCloseStdinProcedure,
+ svc.CloseStdin,
+ connect.WithSchema(processMethods.ByName("CloseStdin")),
+ connect.WithHandlerOptions(opts...),
+ )
+ return "/process.Process/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case ProcessListProcedure:
+ processListHandler.ServeHTTP(w, r)
+ case ProcessConnectProcedure:
+ processConnectHandler.ServeHTTP(w, r)
+ case ProcessStartProcedure:
+ processStartHandler.ServeHTTP(w, r)
+ case ProcessUpdateProcedure:
+ processUpdateHandler.ServeHTTP(w, r)
+ case ProcessStreamInputProcedure:
+ processStreamInputHandler.ServeHTTP(w, r)
+ case ProcessSendInputProcedure:
+ processSendInputHandler.ServeHTTP(w, r)
+ case ProcessSendSignalProcedure:
+ processSendSignalHandler.ServeHTTP(w, r)
+ case ProcessCloseStdinProcedure:
+ processCloseStdinHandler.ServeHTTP(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+}
+
+// UnimplementedProcessHandler returns CodeUnimplemented from all methods.
+type UnimplementedProcessHandler struct{}
+
+func (UnimplementedProcessHandler) List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.List is not implemented"))
+}
+
+func (UnimplementedProcessHandler) Connect(context.Context, *connect.Request[v1.ConnectRequest], *connect.ServerStream[v1.ConnectResponse]) error {
+ return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Connect is not implemented"))
+}
+
+func (UnimplementedProcessHandler) Start(context.Context, *connect.Request[v1.StartRequest], *connect.ServerStream[v1.StartResponse]) error {
+ return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Start is not implemented"))
+}
+
+func (UnimplementedProcessHandler) Update(context.Context, *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Update is not implemented"))
+}
+
+func (UnimplementedProcessHandler) StreamInput(context.Context, *connect.ClientStream[v1.StreamInputRequest]) (*connect.Response[v1.StreamInputResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.StreamInput is not implemented"))
+}
+
+func (UnimplementedProcessHandler) SendInput(context.Context, *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendInput is not implemented"))
+}
+
+func (UnimplementedProcessHandler) SendSignal(context.Context, *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendSignal is not implemented"))
+}
+
+func (UnimplementedProcessHandler) CloseStdin(context.Context, *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.CloseStdin is not implemented"))
+}
diff --git a/worker/worker-bridge-e2b/internal/logging/logging.go b/worker/worker-bridge-e2b/internal/logging/logging.go
new file mode 100644
index 0000000..7283bcf
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/logging/logging.go
@@ -0,0 +1,70 @@
+package logging
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+)
+
+const (
+ defaultLogLevel = "info"
+ defaultLogFormat = "json"
+ defaultLogAddSource = false
+)
+
+var singleLineReplacer = strings.NewReplacer(
+ "\r\n", "\\n",
+ "\n", "\\n",
+ "\r", "\\r",
+)
+
+func formatSingleLine(format string, args ...any) string {
+ return singleLineReplacer.Replace(fmt.Sprintf(format, args...))
+}
+
+func init() {
+ Configure(defaultLogLevel, defaultLogFormat, defaultLogAddSource)
+}
+
+func Configure(level string, format string, addSource bool) {
+ slog.SetDefault(newLogger(level, format, addSource))
+}
+
+func Infof(format string, args ...any) {
+ slog.Info(formatSingleLine(format, args...))
+}
+
+func Warnf(format string, args ...any) {
+ slog.Warn(formatSingleLine(format, args...))
+}
+
+func Errorf(format string, args ...any) {
+ slog.Error(formatSingleLine(format, args...))
+}
+
+func Fatalf(format string, args ...any) {
+ slog.Error(formatSingleLine(format, args...))
+ os.Exit(1)
+}
+
+func newLogger(level string, format string, addSource bool) *slog.Logger {
+ resolvedLevel := slog.LevelInfo
+ switch strings.TrimSpace(strings.ToLower(level)) {
+ case "debug":
+ resolvedLevel = slog.LevelDebug
+ case "warn":
+ resolvedLevel = slog.LevelWarn
+ case "error":
+ resolvedLevel = slog.LevelError
+ }
+
+ options := &slog.HandlerOptions{
+ Level: resolvedLevel,
+ AddSource: addSource,
+ }
+ if strings.TrimSpace(strings.ToLower(format)) == "text" {
+ return slog.New(slog.NewTextHandler(os.Stdout, options))
+ }
+ return slog.New(slog.NewJSONHandler(os.Stdout, options))
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/capability_dispatch_test.go b/worker/worker-bridge-e2b/internal/runner/capability_dispatch_test.go
new file mode 100644
index 0000000..f4e350b
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/capability_dispatch_test.go
@@ -0,0 +1,136 @@
+package runner
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1"
+)
+
+func TestAllCapabilityDispatchersEncodeResults(t *testing.T) {
+ originalPython := runPythonExec
+ originalTerminal := runTerminalExec
+ originalResource := runTerminalResource
+ t.Cleanup(func() {
+ runPythonExec = originalPython
+ runTerminalExec = originalTerminal
+ runTerminalResource = originalResource
+ })
+ runPythonExec = func(_ context.Context, code string) (pythonExecRunResult, error) {
+ if code != "print(1)" {
+ t.Fatalf("unexpected Python code %q", code)
+ }
+ return pythonExecRunResult{Output: "1\n", ExitCode: 0}, nil
+ }
+ runTerminalExec = func(_ context.Context, req terminalExecRequest) (terminalExecRunResult, error) {
+ if req.Command != "pwd" || req.SessionID != "session-1" {
+ t.Fatalf("unexpected terminal request: %#v", req)
+ }
+ return terminalExecRunResult{
+ SessionID: req.SessionID,
+ Stdout: "/workspace\n",
+ LeaseExpiresUnixMS: 1234,
+ }, nil
+ }
+ runTerminalResource = func(_ context.Context, req terminalResourceRequest) (terminalResourceRunResult, error) {
+ if req.SessionID != "session-1" || req.FilePath != "/tmp/a.txt" || req.Action != "read" {
+ t.Fatalf("unexpected resource request: %#v", req)
+ }
+ return terminalResourceRunResult{
+ SessionID: req.SessionID,
+ FilePath: req.FilePath,
+ MIMEType: "text/plain",
+ SizeBytes: 1,
+ Blob: []byte("a"),
+ }, nil
+ }
+
+ tests := []struct {
+ name string
+ capability string
+ payload string
+ assert func(*testing.T, []byte)
+ }{
+ {
+ name: "echo",
+ capability: "echo",
+ payload: `{"message":"hello"}`,
+ assert: func(t *testing.T, payload []byte) {
+ if string(payload) != `{"message":"hello"}` {
+ t.Fatalf("unexpected echo payload %s", payload)
+ }
+ },
+ },
+ {
+ name: "pythonExec",
+ capability: "pythonExec",
+ payload: `{"code":"print(1)"}`,
+ assert: func(t *testing.T, payload []byte) {
+ var result pythonExecResult
+ if json.Unmarshal(payload, &result) != nil || result.Output != "1\n" || result.ExitCode != 0 {
+ t.Fatalf("unexpected Python result %s", payload)
+ }
+ },
+ },
+ {
+ name: "terminalExec",
+ capability: "terminalExec",
+ payload: `{"command":"pwd","session_id":"session-1"}`,
+ assert: func(t *testing.T, payload []byte) {
+ var result terminalExecRunResult
+ if json.Unmarshal(payload, &result) != nil || result.SessionID != "session-1" || result.Stdout != "/workspace\n" {
+ t.Fatalf("unexpected terminal result %s", payload)
+ }
+ },
+ },
+ {
+ name: "terminalResource",
+ capability: "terminalResource",
+ payload: `{"session_id":"session-1","file_path":"/tmp/a.txt","action":"read"}`,
+ assert: func(t *testing.T, payload []byte) {
+ var result terminalResourceRunResult
+ if json.Unmarshal(payload, &result) != nil || string(result.Blob) != "a" || result.MIMEType != "text/plain" {
+ t.Fatalf("unexpected resource result %s", payload)
+ }
+ },
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ request := buildCommandResultWithContext(context.Background(), ®istryv1.CommandDispatch{
+ CommandId: "command-" + tc.name,
+ Capability: tc.capability,
+ PayloadJson: []byte(tc.payload),
+ })
+ result := request.GetCommandResult()
+ if result == nil || result.GetError() != nil || result.GetCompletedUnixMs() == 0 {
+ t.Fatalf("unexpected command result: %#v", result)
+ }
+ tc.assert(t, result.GetPayloadJson())
+ })
+ }
+}
+
+func TestExpiredDispatchDoesNotInvokeTool(t *testing.T) {
+ originalTerminal := runTerminalExec
+ t.Cleanup(func() { runTerminalExec = originalTerminal })
+ called := false
+ runTerminalExec = func(context.Context, terminalExecRequest) (terminalExecRunResult, error) {
+ called = true
+ return terminalExecRunResult{}, nil
+ }
+ request := buildCommandResultWithContext(context.Background(), ®istryv1.CommandDispatch{
+ CommandId: "expired",
+ Capability: "terminalExec",
+ PayloadJson: []byte(`{"command":"pwd"}`),
+ DeadlineUnixMs: time.Now().Add(-time.Second).UnixMilli(),
+ })
+ if called {
+ t.Fatal("expired dispatch invoked the tool")
+ }
+ if code := request.GetCommandResult().GetError().GetCode(); code != "deadline_exceeded" {
+ t.Fatalf("unexpected error code %q", code)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/capability_executor.go b/worker/worker-bridge-e2b/internal/runner/capability_executor.go
new file mode 100644
index 0000000..bf5d03c
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/capability_executor.go
@@ -0,0 +1,309 @@
+package runner
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1"
+)
+
+func buildCommandResult(dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest {
+ return buildCommandResultWithContext(context.Background(), dispatch)
+}
+
+func buildCommandResultWithContext(baseCtx context.Context, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest {
+ commandID := strings.TrimSpace(dispatch.GetCommandId())
+ if commandID == "" {
+ return ®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_CommandResult{
+ CommandResult: ®istryv1.CommandResult{
+ CommandId: commandID,
+ Error: ®istryv1.CommandError{
+ Code: "invalid_command_id",
+ Message: "command_id is required",
+ },
+ },
+ },
+ }
+ }
+
+ if deadline := dispatch.GetDeadlineUnixMs(); deadline > 0 && time.Now().UnixMilli() > deadline {
+ return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded")
+ }
+
+ capability := strings.TrimSpace(strings.ToLower(dispatch.GetCapability()))
+ switch capability {
+ case echoCapabilityName:
+ return buildEchoCommandResult(commandID, dispatch)
+ case pythonExecCapabilityName:
+ return buildPythonExecCommandResult(baseCtx, commandID, dispatch)
+ case terminalExecCapabilityName:
+ return buildTerminalExecCommandResult(baseCtx, commandID, dispatch)
+ case terminalResourceCapabilityName:
+ return buildTerminalResourceCommandResult(baseCtx, commandID, dispatch)
+ default:
+ return commandErrorResult(commandID, "unsupported_capability", fmt.Sprintf("capability %q is not supported", dispatch.GetCapability()))
+ }
+}
+
+func buildEchoCommandResult(commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest {
+ message := ""
+ resultPayload := append([]byte(nil), dispatch.GetPayloadJson()...)
+ if len(resultPayload) > 0 {
+ decoded := struct {
+ Message string `json:"message"`
+ }{}
+ if err := json.Unmarshal(resultPayload, &decoded); err != nil {
+ return commandErrorResult(commandID, "invalid_payload", "payload_json is not valid echo payload")
+ }
+ message = decoded.Message
+ }
+ if strings.TrimSpace(message) == "" {
+ return commandErrorResult(commandID, "invalid_payload", "echo payload is required")
+ }
+ if len(resultPayload) == 0 {
+ encoded, err := json.Marshal(struct {
+ Message string `json:"message"`
+ }{
+ Message: message,
+ })
+ if err != nil {
+ return commandErrorResult(commandID, "encode_failed", "failed to encode echo payload")
+ }
+ resultPayload = encoded
+ }
+
+ return ®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_CommandResult{
+ CommandResult: ®istryv1.CommandResult{
+ CommandId: commandID,
+ PayloadJson: resultPayload,
+ CompletedUnixMs: time.Now().UnixMilli(),
+ },
+ },
+ }
+}
+
+type pythonExecPayload struct {
+ Code string `json:"code"`
+}
+
+type pythonExecResult struct {
+ Output string `json:"output"`
+ Stderr string `json:"stderr"`
+ ExitCode int `json:"exit_code"`
+}
+
+type pythonExecRunResult struct {
+ Output string
+ Stderr string
+ ExitCode int
+}
+
+func buildPythonExecCommandResult(baseCtx context.Context, commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest {
+ payload := append([]byte(nil), dispatch.GetPayloadJson()...)
+ if len(payload) == 0 {
+ return commandErrorResult(commandID, "invalid_payload", "pythonExec payload is required")
+ }
+
+ decoded := pythonExecPayload{}
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return commandErrorResult(commandID, "invalid_payload", "payload_json is not valid pythonExec payload")
+ }
+ if strings.TrimSpace(decoded.Code) == "" {
+ return commandErrorResult(commandID, "invalid_payload", "pythonExec code is required")
+ }
+
+ commandCtx := baseCtx
+ if commandCtx == nil {
+ commandCtx = context.Background()
+ }
+ cancel := func() {}
+ if deadlineUnixMS := dispatch.GetDeadlineUnixMs(); deadlineUnixMS > 0 {
+ commandCtx, cancel = context.WithDeadline(commandCtx, time.UnixMilli(deadlineUnixMS))
+ }
+ defer cancel()
+
+ execResult, err := runPythonExec(commandCtx, decoded.Code)
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded")
+ }
+ return commandErrorResult(commandID, "execution_failed", fmt.Sprintf("pythonExec execution failed: %v", err))
+ }
+
+ resultPayload, err := json.Marshal(pythonExecResult{
+ Output: execResult.Output,
+ Stderr: execResult.Stderr,
+ ExitCode: execResult.ExitCode,
+ })
+ if err != nil {
+ return commandErrorResult(commandID, "encode_failed", "failed to encode pythonExec payload")
+ }
+
+ return ®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_CommandResult{
+ CommandResult: ®istryv1.CommandResult{
+ CommandId: commandID,
+ PayloadJson: resultPayload,
+ CompletedUnixMs: time.Now().UnixMilli(),
+ },
+ },
+ }
+}
+
+func buildTerminalExecCommandResult(baseCtx context.Context, commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest {
+ payload := append([]byte(nil), dispatch.GetPayloadJson()...)
+ if len(payload) == 0 {
+ return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalExec payload is required")
+ }
+
+ decoded := terminalExecPayload{}
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "payload_json is not valid terminalExec payload")
+ }
+ if strings.TrimSpace(decoded.Command) == "" {
+ return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalExec command is required")
+ }
+
+ commandCtx := baseCtx
+ if commandCtx == nil {
+ commandCtx = context.Background()
+ }
+ cancel := func() {}
+ if deadlineUnixMS := dispatch.GetDeadlineUnixMs(); deadlineUnixMS > 0 {
+ commandCtx, cancel = context.WithDeadline(commandCtx, time.UnixMilli(deadlineUnixMS))
+ }
+ defer cancel()
+
+ execResult, err := runTerminalExec(commandCtx, terminalExecRequest{
+ Command: decoded.Command,
+ SessionID: decoded.SessionID,
+ CreateIfMissing: decoded.CreateIfMissing,
+ LeaseTTLSec: decoded.LeaseTTLSec,
+ })
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded")
+ }
+ var terminalErr *terminalExecError
+ if errors.As(err, &terminalErr) {
+ return commandErrorResult(commandID, terminalErr.Code(), terminalErr.Error())
+ }
+ return commandErrorResult(commandID, "execution_failed", fmt.Sprintf("terminalExec execution failed: %v", err))
+ }
+
+ resultPayload, err := json.Marshal(execResult)
+ if err != nil {
+ return commandErrorResult(commandID, "encode_failed", "failed to encode terminalExec payload")
+ }
+
+ return ®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_CommandResult{
+ CommandResult: ®istryv1.CommandResult{
+ CommandId: commandID,
+ PayloadJson: resultPayload,
+ CompletedUnixMs: time.Now().UnixMilli(),
+ },
+ },
+ }
+}
+
+func buildTerminalResourceCommandResult(baseCtx context.Context, commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest {
+ payload := append([]byte(nil), dispatch.GetPayloadJson()...)
+ if len(payload) == 0 {
+ return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalResource payload is required")
+ }
+
+ decoded := terminalResourcePayload{}
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "payload_json is not valid terminalResource payload")
+ }
+ if strings.TrimSpace(decoded.SessionID) == "" || strings.TrimSpace(decoded.FilePath) == "" {
+ return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalResource session_id and file_path are required")
+ }
+ if strings.EqualFold(strings.TrimSpace(decoded.Action), terminalResourceActionExport) && strings.TrimSpace(decoded.SignedURL) == "" {
+ return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalResource signed_url is required for export")
+ }
+
+ commandCtx := baseCtx
+ if commandCtx == nil {
+ commandCtx = context.Background()
+ }
+ cancel := func() {}
+ if deadlineUnixMS := dispatch.GetDeadlineUnixMs(); deadlineUnixMS > 0 {
+ commandCtx, cancel = context.WithDeadline(commandCtx, time.UnixMilli(deadlineUnixMS))
+ }
+ defer cancel()
+
+ resourceResult, err := runTerminalResource(commandCtx, terminalResourceRequest{
+ SessionID: decoded.SessionID,
+ FilePath: decoded.FilePath,
+ Action: decoded.Action,
+ SignedURL: decoded.SignedURL,
+ Headers: cloneStringMap(decoded.Headers),
+ })
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded")
+ }
+ var terminalErr *terminalExecError
+ if errors.As(err, &terminalErr) {
+ return commandErrorResult(commandID, terminalErr.Code(), terminalErr.Error())
+ }
+ return commandErrorResult(commandID, "execution_failed", fmt.Sprintf("terminalResource execution failed: %v", err))
+ }
+
+ resultPayload, err := json.Marshal(resourceResult)
+ if err != nil {
+ return commandErrorResult(commandID, "encode_failed", "failed to encode terminalResource payload")
+ }
+
+ return ®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_CommandResult{
+ CommandResult: ®istryv1.CommandResult{
+ CommandId: commandID,
+ PayloadJson: resultPayload,
+ CompletedUnixMs: time.Now().UnixMilli(),
+ },
+ },
+ }
+}
+
+func cloneStringMap(values map[string]string) map[string]string {
+ if len(values) == 0 {
+ return nil
+ }
+ cloned := make(map[string]string, len(values))
+ for key, value := range values {
+ cloned[key] = value
+ }
+ return cloned
+}
+
+func commandErrorResult(commandID string, code string, message string) *registryv1.ConnectRequest {
+ return ®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_CommandResult{
+ CommandResult: ®istryv1.CommandResult{
+ CommandId: commandID,
+ CompletedUnixMs: time.Now().UnixMilli(),
+ Error: ®istryv1.CommandError{
+ Code: code,
+ Message: message,
+ },
+ },
+ },
+ }
+}
+
+func runTerminalExecUnavailable(context.Context, terminalExecRequest) (terminalExecRunResult, error) {
+ return terminalExecRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage)
+}
+
+func runTerminalResourceUnavailable(context.Context, terminalResourceRequest) (terminalResourceRunResult, error) {
+ return terminalResourceRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage)
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/console_session_test.go b/worker/worker-bridge-e2b/internal/runner/console_session_test.go
new file mode 100644
index 0000000..0529978
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/console_session_test.go
@@ -0,0 +1,298 @@
+package runner
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+func TestConsoleSessionContract(t *testing.T) {
+ originalActiveSessionCount := activeSessionCountFn
+ activeSessionCountFn = func() int32 { return 3 }
+ t.Cleanup(func() { activeSessionCountFn = originalActiveSessionCount })
+
+ service := &consoleContractService{}
+ server := grpc.NewServer()
+ registryv1.RegisterWorkerRegistryServiceServer(server, service)
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ server.Stop()
+ _ = listener.Close()
+ })
+ go func() { _ = server.Serve(listener) }()
+
+ cfg := consoleTestConfig()
+ cfg.ConsoleGRPCTarget = listener.Addr().String()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ connected, err := runSessionWithStatus(ctx, cfg)
+ if !connected {
+ t.Fatal("expected the session handshake to complete")
+ }
+ if status.Code(err) != codes.FailedPrecondition {
+ t.Fatalf("expected FailedPrecondition, got %v", err)
+ }
+ if atomic.LoadInt32(&service.commandResults) != 1 {
+ t.Fatalf("expected one command result")
+ }
+}
+
+func TestHeartbeatToleratesOneMissAndFailsAfterTwo(t *testing.T) {
+ cfg := consoleTestConfig()
+ cfg.CallTimeout = 20 * time.Millisecond
+
+ outbound := make(chan *registryv1.ConnectRequest, 8)
+ acks := make(chan *registryv1.HeartbeatAck, 1)
+ sessionErrors := make(chan error, 1)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() {
+ done <- heartbeatLoop(ctx, outbound, acks, sessionErrors, cfg, "session-1", 5*time.Millisecond)
+ }()
+ <-outbound // First heartbeat times out.
+ second := <-outbound
+ if second.GetHeartbeat() == nil {
+ t.Fatalf("expected second heartbeat")
+ }
+ acks <- ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1}
+ cancel()
+ if err := <-done; !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected recovery after one missed ack, got %v", err)
+ }
+
+ outbound = make(chan *registryv1.ConnectRequest, 8)
+ err := heartbeatLoop(context.Background(), outbound, make(chan *registryv1.HeartbeatAck), make(chan error), cfg, "session-2", 5*time.Millisecond)
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected failure after two missed acks, got %v", err)
+ }
+}
+
+func TestConsoleSessionDispatchesAllCapabilities(t *testing.T) {
+ originalPython := runPythonExec
+ originalTerminal := runTerminalExec
+ originalResource := runTerminalResource
+ t.Cleanup(func() {
+ runPythonExec = originalPython
+ runTerminalExec = originalTerminal
+ runTerminalResource = originalResource
+ })
+ runPythonExec = func(context.Context, string) (pythonExecRunResult, error) {
+ return pythonExecRunResult{Output: "python-ok", ExitCode: 0}, nil
+ }
+ runTerminalExec = func(context.Context, terminalExecRequest) (terminalExecRunResult, error) {
+ return terminalExecRunResult{SessionID: "terminal-session", Stdout: "terminal-ok"}, nil
+ }
+ runTerminalResource = func(context.Context, terminalResourceRequest) (terminalResourceRunResult, error) {
+ return terminalResourceRunResult{
+ SessionID: "terminal-session",
+ FilePath: "/tmp/file.txt",
+ MIMEType: "text/plain",
+ SizeBytes: 2,
+ Blob: []byte("ok"),
+ }, nil
+ }
+
+ service := &allCapabilityContractService{}
+ server := grpc.NewServer()
+ registryv1.RegisterWorkerRegistryServiceServer(server, service)
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ server.Stop()
+ _ = listener.Close()
+ })
+ go func() { _ = server.Serve(listener) }()
+ cfg := consoleTestConfig()
+ cfg.ConsoleGRPCTarget = listener.Addr().String()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ if err := runSession(ctx, cfg); status.Code(err) != codes.FailedPrecondition {
+ t.Fatalf("expected FailedPrecondition after all results, got %v", err)
+ }
+ if got := atomic.LoadInt32(&service.results); got != 4 {
+ t.Fatalf("expected all four capability results, got %d", got)
+ }
+}
+
+func consoleTestConfig() config.Config {
+ return config.Config{
+ ConsoleGRPCTarget: "127.0.0.1:1",
+ ConsoleTLS: false,
+ WorkerID: "worker-e2b-1",
+ WorkerSecret: "worker-secret",
+ HeartbeatInterval: 10 * time.Millisecond,
+ HeartbeatJitter: 0,
+ CallTimeout: time.Second,
+ NodeName: "e2b-test",
+ ExecutorKind: "e2b",
+ EchoMaxInflight: 4,
+ PythonExecMaxInflight: 4,
+ TerminalExecMaxInflight: 4,
+ TerminalResourceMaxInflight: 4,
+ }
+}
+
+type consoleContractService struct {
+ registryv1.UnimplementedWorkerRegistryServiceServer
+ commandResults int32
+}
+
+type allCapabilityContractService struct {
+ registryv1.UnimplementedWorkerRegistryServiceServer
+ results int32
+}
+
+func (s *allCapabilityContractService) Connect(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error {
+ first, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ if first.GetHello() == nil {
+ return status.Error(codes.InvalidArgument, "hello required")
+ }
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_ConnectAck{
+ ConnectAck: ®istryv1.ConnectAck{SessionId: "all-capabilities", HeartbeatIntervalSec: 1},
+ },
+ }); err != nil {
+ return err
+ }
+ firstHeartbeat, err := stream.Recv()
+ if err != nil || firstHeartbeat.GetHeartbeat() == nil {
+ return status.Error(codes.InvalidArgument, "heartbeat required")
+ }
+ dispatches := []*registryv1.CommandDispatch{
+ {CommandId: "echo", Capability: "echo", PayloadJson: []byte(`{"message":"echo-ok"}`)},
+ {CommandId: "python", Capability: "pythonExec", PayloadJson: []byte(`{"code":"print(1)"}`)},
+ {CommandId: "terminal", Capability: "terminalExec", PayloadJson: []byte(`{"command":"pwd"}`)},
+ {CommandId: "resource", Capability: "terminalResource", PayloadJson: []byte(`{"session_id":"terminal-session","file_path":"/tmp/file.txt","action":"read"}`)},
+ }
+ for _, dispatch := range dispatches {
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_CommandDispatch{CommandDispatch: dispatch},
+ }); err != nil {
+ return err
+ }
+ }
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_HeartbeatAck{
+ HeartbeatAck: ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1},
+ },
+ }); err != nil {
+ return err
+ }
+ seen := map[string]bool{}
+ for len(seen) < len(dispatches) {
+ frame, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ result := frame.GetCommandResult()
+ if result == nil {
+ continue
+ }
+ if result.GetError() != nil || len(result.GetPayloadJson()) == 0 {
+ return status.Errorf(codes.InvalidArgument, "capability %s failed", result.GetCommandId())
+ }
+ seen[result.GetCommandId()] = true
+ }
+ atomic.StoreInt32(&s.results, int32(len(seen)))
+ return status.Error(codes.FailedPrecondition, "contract complete")
+}
+
+func (s *consoleContractService) Connect(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error {
+ first, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ hello := first.GetHello()
+ if hello == nil || hello.GetNodeId() != "worker-e2b-1" || hello.GetWorkerSecret() != "worker-secret" {
+ return status.Error(codes.Unauthenticated, "invalid hello identity")
+ }
+ if hello.GetExecutorKind() != "e2b" || !strings.HasPrefix(hello.GetNodeName(), "e2b-") {
+ return status.Error(codes.InvalidArgument, "invalid hello metadata")
+ }
+ capabilities := map[string]int32{}
+ for _, declaration := range hello.GetCapabilities() {
+ capabilities[declaration.GetName()] = declaration.GetMaxInflight()
+ }
+ for _, name := range []string{"echo", "pythonExec", "terminalExec", "terminalResource"} {
+ if capabilities[name] != 4 {
+ return status.Errorf(codes.InvalidArgument, "invalid capability %s", name)
+ }
+ }
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_ConnectAck{
+ ConnectAck: ®istryv1.ConnectAck{
+ SessionId: "console-session",
+ HeartbeatIntervalSec: 1,
+ },
+ },
+ }); err != nil {
+ return err
+ }
+
+ heartbeatFrame, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ heartbeat := heartbeatFrame.GetHeartbeat()
+ if heartbeat == nil ||
+ heartbeat.GetNodeId() != hello.GetNodeId() ||
+ heartbeat.GetSessionId() != "console-session" ||
+ heartbeat.GetActiveSessionCount() != 3 {
+ return status.Error(codes.InvalidArgument, "invalid heartbeat")
+ }
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_CommandDispatch{
+ CommandDispatch: ®istryv1.CommandDispatch{
+ CommandId: "echo-command",
+ Capability: "echo",
+ PayloadJson: []byte(`{"message":"from-console"}`),
+ },
+ },
+ }); err != nil {
+ return err
+ }
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_HeartbeatAck{
+ HeartbeatAck: ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1},
+ },
+ }); err != nil {
+ return err
+ }
+ for {
+ frame, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ if result := frame.GetCommandResult(); result != nil {
+ var payload struct {
+ Message string `json:"message"`
+ }
+ if result.GetCommandId() != "echo-command" ||
+ json.Unmarshal(result.GetPayloadJson(), &payload) != nil ||
+ payload.Message != "from-console" {
+ return status.Error(codes.InvalidArgument, "invalid command result")
+ }
+ atomic.AddInt32(&s.commandResults, 1)
+ return status.Error(codes.FailedPrecondition, "session replaced")
+ }
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/e2b_backend.go b/worker/worker-bridge-e2b/internal/runner/e2b_backend.go
new file mode 100644
index 0000000..1c2c74c
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/e2b_backend.go
@@ -0,0 +1,16 @@
+package runner
+
+import (
+ "context"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+)
+
+type e2bBackend interface {
+ Create(context.Context, string, int) (*e2b.Sandbox, error)
+ SetTimeout(context.Context, string, int) error
+ Kill(context.Context, string) error
+ Run(context.Context, *e2b.Sandbox, string, int) (e2b.CommandResult, error)
+ ReadFile(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error)
+ OpenFile(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error)
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/full_worker_integration_test.go b/worker/worker-bridge-e2b/internal/runner/full_worker_integration_test.go
new file mode 100644
index 0000000..d57f1bb
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/full_worker_integration_test.go
@@ -0,0 +1,251 @@
+package runner
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "os"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+func TestIntegrationConsoleDispatchesAllCapabilitiesToE2B(t *testing.T) {
+ if os.Getenv("E2B_INTEGRATION") != "1" {
+ t.Skip("set E2B_INTEGRATION=1 to run against E2B")
+ }
+ apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY"))
+ pythonTemplate := strings.TrimSpace(os.Getenv("E2B_PYTHON_TEMPLATE"))
+ terminalTemplate := strings.TrimSpace(os.Getenv("E2B_TERMINAL_TEMPLATE"))
+ if apiKey == "" || pythonTemplate == "" || terminalTemplate == "" {
+ t.Fatal("E2B_API_KEY, E2B_PYTHON_TEMPLATE and E2B_TERMINAL_TEMPLATE are required")
+ }
+ client, err := e2b.NewClient(e2b.Config{
+ APIKey: apiKey,
+ APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")),
+ Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")),
+ RequestTimeout: 60 * time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ backend := &recordingE2BBackend{e2bBackend: client}
+ manager := newLiveTerminalManager(backend, terminalTemplate, 2)
+ defer manager.Close()
+ python := newPythonExecRunner(backend, pythonTemplate, 120)
+
+ originalPython := runPythonExec
+ originalTerminal := runTerminalExec
+ originalResource := runTerminalResource
+ originalActiveSessions := activeSessionCountFn
+ runPythonExec = python.Execute
+ runTerminalExec = manager.Execute
+ runTerminalResource = manager.ResolveResource
+ activeSessionCountFn = manager.ActiveSessionCount
+ t.Cleanup(func() {
+ runPythonExec = originalPython
+ runTerminalExec = originalTerminal
+ runTerminalResource = originalResource
+ activeSessionCountFn = originalActiveSessions
+ })
+
+ service := &liveAllCapabilityService{}
+ server := grpc.NewServer()
+ registryv1.RegisterWorkerRegistryServiceServer(server, service)
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ server.Stop()
+ _ = listener.Close()
+ })
+ go func() { _ = server.Serve(listener) }()
+
+ cfg := consoleTestConfig()
+ cfg.ConsoleGRPCTarget = listener.Addr().String()
+ cfg.CallTimeout = 3 * time.Second
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ if err := runSession(ctx, cfg); status.Code(err) != codes.FailedPrecondition {
+ t.Fatalf("full worker session failed: %v", err)
+ }
+ if atomic.LoadInt32(&service.verifiedResults) != 4 {
+ t.Fatalf("expected four verified capability results, got %d", service.verifiedResults)
+ }
+ manager.Close()
+ if backend.killCount() != 2 {
+ t.Fatalf("expected Python and terminal sandboxes to be cleaned up, kill_count=%d", backend.killCount())
+ }
+}
+
+type liveAllCapabilityService struct {
+ registryv1.UnimplementedWorkerRegistryServiceServer
+ verifiedResults int32
+}
+
+func (s *liveAllCapabilityService) Connect(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error {
+ first, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ if first.GetHello() == nil {
+ return status.Error(codes.InvalidArgument, "hello required")
+ }
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_ConnectAck{
+ ConnectAck: ®istryv1.ConnectAck{SessionId: "live-all-tools", HeartbeatIntervalSec: 1},
+ },
+ }); err != nil {
+ return err
+ }
+ if err := s.waitForHeartbeat(stream); err != nil {
+ return err
+ }
+ deadline := time.Now().Add(90 * time.Second).UnixMilli()
+ for _, dispatch := range []*registryv1.CommandDispatch{
+ {
+ CommandId: "live-echo",
+ Capability: "echo",
+ PayloadJson: []byte(`{"message":"echo-dispatch-live"}`),
+ DeadlineUnixMs: deadline,
+ },
+ {
+ CommandId: "live-python",
+ Capability: "pythonExec",
+ PayloadJson: []byte(`{"code":"print(\"python-dispatch-live\")"}`),
+ DeadlineUnixMs: deadline,
+ },
+ {
+ CommandId: "live-terminal",
+ Capability: "terminalExec",
+ PayloadJson: []byte(`{"command":"printf 'resource-dispatch-live' > /tmp/onlyboxes-dispatch-live.txt; printf 'terminal-dispatch-live'"}`),
+ DeadlineUnixMs: deadline,
+ },
+ } {
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_CommandDispatch{CommandDispatch: dispatch},
+ }); err != nil {
+ return err
+ }
+ }
+
+ seen := map[string]bool{}
+ terminalSessionID := ""
+ for len(seen) < 3 {
+ frame, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ if heartbeat := frame.GetHeartbeat(); heartbeat != nil {
+ if err := s.sendHeartbeatAck(stream); err != nil {
+ return err
+ }
+ continue
+ }
+ result := frame.GetCommandResult()
+ if result == nil || result.GetError() != nil {
+ return status.Errorf(codes.InvalidArgument, "tool failed: %#v", result)
+ }
+ switch result.GetCommandId() {
+ case "live-echo":
+ var decoded struct {
+ Message string `json:"message"`
+ }
+ if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil || decoded.Message != "echo-dispatch-live" {
+ return status.Error(codes.InvalidArgument, "invalid echo result")
+ }
+ case "live-python":
+ var decoded pythonExecResult
+ if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil ||
+ strings.TrimSpace(decoded.Output) != "python-dispatch-live" ||
+ decoded.ExitCode != 0 {
+ return status.Error(codes.InvalidArgument, "invalid Python result")
+ }
+ case "live-terminal":
+ var decoded terminalExecRunResult
+ if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil ||
+ decoded.Stdout != "terminal-dispatch-live" ||
+ decoded.SessionID == "" {
+ return status.Error(codes.InvalidArgument, "invalid terminal result")
+ }
+ terminalSessionID = decoded.SessionID
+ default:
+ return status.Error(codes.InvalidArgument, "unknown command result")
+ }
+ seen[result.GetCommandId()] = true
+ }
+ if terminalSessionID == "" {
+ return status.Error(codes.InvalidArgument, "terminal session missing")
+ }
+ resourcePayload, _ := json.Marshal(terminalResourcePayload{
+ SessionID: terminalSessionID,
+ FilePath: "/tmp/onlyboxes-dispatch-live.txt",
+ Action: terminalResourceActionRead,
+ })
+ if err := stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_CommandDispatch{
+ CommandDispatch: ®istryv1.CommandDispatch{
+ CommandId: "live-resource",
+ Capability: "terminalResource",
+ PayloadJson: resourcePayload,
+ DeadlineUnixMs: deadline,
+ },
+ },
+ }); err != nil {
+ return err
+ }
+ for {
+ frame, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ if frame.GetHeartbeat() != nil {
+ if err := s.sendHeartbeatAck(stream); err != nil {
+ return err
+ }
+ continue
+ }
+ result := frame.GetCommandResult()
+ if result == nil {
+ continue
+ }
+ if result.GetCommandId() != "live-resource" || result.GetError() != nil {
+ return status.Errorf(codes.InvalidArgument, "resource failed: %#v", result)
+ }
+ var decoded terminalResourceRunResult
+ if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil ||
+ string(decoded.Blob) != "resource-dispatch-live" {
+ return status.Error(codes.InvalidArgument, "invalid resource result")
+ }
+ atomic.StoreInt32(&s.verifiedResults, 4)
+ return status.Error(codes.FailedPrecondition, "live capability contract complete")
+ }
+}
+
+func (s *liveAllCapabilityService) waitForHeartbeat(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error {
+ for {
+ frame, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ if frame.GetHeartbeat() != nil {
+ return s.sendHeartbeatAck(stream)
+ }
+ }
+}
+
+func (s *liveAllCapabilityService) sendHeartbeatAck(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error {
+ return stream.Send(®istryv1.ConnectResponse{
+ Payload: ®istryv1.ConnectResponse_HeartbeatAck{
+ HeartbeatAck: ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1},
+ },
+ })
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/hello_builder.go b/worker/worker-bridge-e2b/internal/runner/hello_builder.go
new file mode 100644
index 0000000..41bd4ea
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/hello_builder.go
@@ -0,0 +1,49 @@
+package runner
+
+import (
+ "fmt"
+ "strings"
+
+ registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/buildinfo"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config"
+)
+
+func buildHello(cfg config.Config) (*registryv1.ConnectHello, error) {
+ nodeName := strings.TrimSpace(cfg.NodeName)
+ if nodeName == "" {
+ suffix := cfg.WorkerID
+ if len(suffix) > 8 {
+ suffix = suffix[:8]
+ }
+ nodeName = fmt.Sprintf("worker-bridge-e2b-%s", suffix)
+ }
+
+ hello := ®istryv1.ConnectHello{
+ NodeId: cfg.WorkerID,
+ NodeName: nodeName,
+ ExecutorKind: cfg.ExecutorKind,
+ Labels: cfg.Labels,
+ Version: buildinfo.Version,
+ WorkerSecret: cfg.WorkerSecret,
+ Capabilities: []*registryv1.CapabilityDeclaration{
+ {
+ Name: echoCapabilityName,
+ MaxInflight: int32(cfg.EchoMaxInflight),
+ },
+ {
+ Name: pythonExecCapabilityDeclared,
+ MaxInflight: int32(cfg.PythonExecMaxInflight),
+ },
+ {
+ Name: terminalExecCapabilityDeclared,
+ MaxInflight: int32(cfg.TerminalExecMaxInflight),
+ },
+ {
+ Name: terminalResourceCapabilityDeclared,
+ MaxInflight: int32(cfg.TerminalResourceMaxInflight),
+ },
+ },
+ }
+ return hello, nil
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/python_runtime.go b/worker/worker-bridge-e2b/internal/runner/python_runtime.go
new file mode 100644
index 0000000..030cc11
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/python_runtime.go
@@ -0,0 +1,69 @@
+package runner
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging"
+)
+
+const pythonExecCleanupTimeout = 10 * time.Second
+
+type pythonExecRunner struct {
+ backend e2bBackend
+ template string
+ timeoutSec int
+}
+
+func newPythonExecRunner(backend e2bBackend, template string, timeoutSec int) *pythonExecRunner {
+ return &pythonExecRunner{
+ backend: backend,
+ template: strings.TrimSpace(template),
+ timeoutSec: timeoutSec,
+ }
+}
+
+func (r *pythonExecRunner) Execute(ctx context.Context, code string) (pythonExecRunResult, error) {
+ if r == nil || r.backend == nil {
+ return pythonExecRunResult{}, errors.New("E2B python executor is unavailable")
+ }
+ timeoutSec := r.timeoutSec
+ if deadline, ok := ctx.Deadline(); ok {
+ remaining := int(time.Until(deadline).Seconds()) + 1
+ if remaining > 0 && remaining < timeoutSec {
+ timeoutSec = remaining
+ }
+ }
+ if timeoutSec <= 0 {
+ timeoutSec = 60
+ }
+ sandbox, err := r.backend.Create(ctx, r.template, timeoutSec)
+ if err != nil {
+ return pythonExecRunResult{}, fmt.Errorf("create E2B python sandbox: %w", err)
+ }
+ defer r.killSandbox(sandbox.ID)
+
+ encoded := base64.StdEncoding.EncodeToString([]byte(code))
+ command := "printf '%s' '" + encoded + "' | base64 -d > /tmp/onlyboxes-pythonexec.py && uv run /tmp/onlyboxes-pythonexec.py"
+ result, err := r.backend.Run(ctx, sandbox, command, -1)
+ if err != nil {
+ return pythonExecRunResult{}, err
+ }
+ return pythonExecRunResult{
+ Output: result.Stdout,
+ Stderr: result.Stderr,
+ ExitCode: result.ExitCode,
+ }, nil
+}
+
+func (r *pythonExecRunner) killSandbox(sandboxID string) {
+ ctx, cancel := context.WithTimeout(context.Background(), pythonExecCleanupTimeout)
+ defer cancel()
+ if err := r.backend.Kill(ctx, sandboxID); err != nil {
+ logging.Warnf("pythonExec cleanup failed: sandbox_id=%s err=%v", sandboxID, err)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/python_runtime_integration_test.go b/worker/worker-bridge-e2b/internal/runner/python_runtime_integration_test.go
new file mode 100644
index 0000000..83153e1
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/python_runtime_integration_test.go
@@ -0,0 +1,43 @@
+package runner
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+)
+
+func TestIntegrationPythonExec(t *testing.T) {
+ if os.Getenv("E2B_INTEGRATION") != "1" {
+ t.Skip("set E2B_INTEGRATION=1 to run against E2B")
+ }
+ apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY"))
+ template := strings.TrimSpace(os.Getenv("E2B_PYTHON_TEMPLATE"))
+ if apiKey == "" || template == "" {
+ t.Fatal("E2B_API_KEY and E2B_PYTHON_TEMPLATE are required")
+ }
+ backend, err := e2b.NewClient(e2b.Config{
+ APIKey: apiKey,
+ APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")),
+ Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")),
+ RequestTimeout: 60 * time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ result, err := newPythonExecRunner(backend, template, 120).Execute(
+ ctx,
+ `print("python-template-ok")`,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.ExitCode != 0 || strings.TrimSpace(result.Output) != "python-template-ok" {
+ t.Fatalf("unexpected python result: %#v", result)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/python_runtime_test.go b/worker/worker-bridge-e2b/internal/runner/python_runtime_test.go
new file mode 100644
index 0000000..0774090
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/python_runtime_test.go
@@ -0,0 +1,120 @@
+package runner
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+)
+
+type fakePythonBackend struct {
+ mu sync.Mutex
+ template string
+ timeoutSec int
+ command string
+ outputLimit int
+ killed []string
+ runResult e2b.CommandResult
+ runErr error
+}
+
+func (f *fakePythonBackend) Create(_ context.Context, template string, timeoutSec int) (*e2b.Sandbox, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.template = template
+ f.timeoutSec = timeoutSec
+ return &e2b.Sandbox{ID: "python-sandbox"}, nil
+}
+
+func (f *fakePythonBackend) SetTimeout(context.Context, string, int) error { return nil }
+
+func (f *fakePythonBackend) Kill(_ context.Context, sandboxID string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.killed = append(f.killed, sandboxID)
+ return nil
+}
+
+func (f *fakePythonBackend) Run(_ context.Context, _ *e2b.Sandbox, command string, outputLimit int) (e2b.CommandResult, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.command = command
+ f.outputLimit = outputLimit
+ return f.runResult, f.runErr
+}
+
+func (f *fakePythonBackend) ReadFile(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error) {
+ return e2b.File{}, nil
+}
+
+func (f *fakePythonBackend) OpenFile(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) {
+ return e2b.FileReader{Body: io.NopCloser(bytes.NewReader(nil))}, nil
+}
+
+func TestPythonExecUsesOneShotUVSandboxAndAlwaysCleansUp(t *testing.T) {
+ t.Parallel()
+ backend := &fakePythonBackend{
+ runResult: e2b.CommandResult{Stdout: "ok\n", Stderr: "warning\n", ExitCode: 3},
+ }
+ runner := newPythonExecRunner(backend, "python-template", 300)
+ result, err := runner.Execute(context.Background(), `print("sensitive source")`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Output != "ok\n" || result.Stderr != "warning\n" || result.ExitCode != 3 {
+ t.Fatalf("unexpected result: %#v", result)
+ }
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ if backend.template != "python-template" || backend.timeoutSec != 300 {
+ t.Fatalf("unexpected create request: template=%q timeout=%d", backend.template, backend.timeoutSec)
+ }
+ if !strings.Contains(backend.command, "uv run /tmp/onlyboxes-pythonexec.py") {
+ t.Fatalf("python command does not invoke uv: %q", backend.command)
+ }
+ if strings.Contains(backend.command, "sensitive source") {
+ t.Fatalf("raw source was embedded in the shell command")
+ }
+ if backend.outputLimit != -1 {
+ t.Fatalf("python output should be unbounded, got %d", backend.outputLimit)
+ }
+ if len(backend.killed) != 1 || backend.killed[0] != "python-sandbox" {
+ t.Fatalf("sandbox was not cleaned up: %v", backend.killed)
+ }
+}
+
+func TestPythonExecBoundsSandboxTTLByCommandDeadline(t *testing.T) {
+ t.Parallel()
+ backend := &fakePythonBackend{}
+ runner := newPythonExecRunner(backend, "python-template", 300)
+ ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
+ defer cancel()
+ if _, err := runner.Execute(ctx, "pass"); err != nil {
+ t.Fatal(err)
+ }
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ if backend.timeoutSec < 1 || backend.timeoutSec > 2 {
+ t.Fatalf("expected deadline-bounded sandbox timeout, got %d", backend.timeoutSec)
+ }
+}
+
+func TestPythonExecCleansUpAfterRunFailure(t *testing.T) {
+ t.Parallel()
+ backend := &fakePythonBackend{runErr: errors.New("stream failed")}
+ runner := newPythonExecRunner(backend, "python-template", 60)
+ if _, err := runner.Execute(context.Background(), "pass"); err == nil {
+ t.Fatal("expected run error")
+ }
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ if len(backend.killed) != 1 || backend.killed[0] != "python-sandbox" {
+ t.Fatalf("sandbox was not cleaned up: %v", backend.killed)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/runner.go b/worker/worker-bridge-e2b/internal/runner/runner.go
new file mode 100644
index 0000000..9273b49
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/runner.go
@@ -0,0 +1,136 @@
+package runner
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+const (
+ minHeartbeatInterval = 1 * time.Second
+ initialReconnectDelay = 1 * time.Second
+ maxReconnectDelay = 15 * time.Second
+ echoCapabilityName = "echo"
+ pythonExecCapabilityName = "pythonexec"
+ pythonExecCapabilityDeclared = "pythonExec"
+)
+
+var waitReconnect = waitReconnectDelay
+var applyJitter = jitterDuration
+var runPythonExec = func(context.Context, string) (pythonExecRunResult, error) {
+ return pythonExecRunResult{}, errors.New("E2B python executor is unavailable")
+}
+var runTerminalExec = runTerminalExecUnavailable
+var runTerminalResource = runTerminalResourceUnavailable
+var activeSessionCountFn = func() int32 { return 0 }
+
+func Run(ctx context.Context, cfg config.Config) error {
+ if strings.TrimSpace(cfg.WorkerID) == "" {
+ return errors.New("WORKER_ID is required")
+ }
+ if strings.TrimSpace(cfg.WorkerSecret) == "" {
+ return errors.New("WORKER_SECRET is required")
+ }
+ if strings.TrimSpace(cfg.E2BAPIKey) == "" {
+ return errors.New("WORKER_E2B_API_KEY is required")
+ }
+ if strings.TrimSpace(cfg.E2BPythonTemplate) == "" {
+ return errors.New("WORKER_E2B_PYTHON_TEMPLATE is required")
+ }
+ if strings.TrimSpace(cfg.E2BTerminalTemplate) == "" {
+ return errors.New("WORKER_E2B_TERMINAL_TEMPLATE is required")
+ }
+
+ backend, err := e2b.NewClient(e2b.Config{
+ APIKey: cfg.E2BAPIKey,
+ APIURL: cfg.E2BAPIURL,
+ Domain: cfg.E2BDomain,
+ SandboxURL: cfg.E2BSandboxURL,
+ RequestTimeout: cfg.E2BRequestTimeout,
+ })
+ if err != nil {
+ return fmt.Errorf("configure E2B client: %w", err)
+ }
+
+ terminalManager := newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: cfg.E2BTerminalTemplate,
+ LeaseMinSec: cfg.TerminalLeaseMinSec,
+ LeaseMaxSec: cfg.TerminalLeaseMaxSec,
+ LeaseDefaultSec: cfg.TerminalLeaseDefaultSec,
+ OutputLimitBytes: cfg.TerminalOutputLimitBytes,
+ ExportMaxBytes: cfg.TerminalExportMaxBytes,
+ ExportMode: cfg.TerminalExportMode,
+ SessionMaxInflight: cfg.TerminalSessionMaxInflight,
+ })
+ pythonRunner := newPythonExecRunner(
+ backend,
+ cfg.E2BPythonTemplate,
+ cfg.E2BPythonTimeoutSec,
+ )
+ originalRunPythonExec := runPythonExec
+ runPythonExec = pythonRunner.Execute
+ originalRunTerminalExec := runTerminalExec
+ runTerminalExec = terminalManager.Execute
+ originalRunTerminalResource := runTerminalResource
+ runTerminalResource = terminalManager.ResolveResource
+ originalActiveSessionCountFn := activeSessionCountFn
+ activeSessionCountFn = terminalManager.ActiveSessionCount
+ defer func() {
+ runPythonExec = originalRunPythonExec
+ runTerminalExec = originalRunTerminalExec
+ runTerminalResource = originalRunTerminalResource
+ activeSessionCountFn = originalActiveSessionCountFn
+ terminalManager.Close()
+ }()
+
+ logging.Infof("pythonExec configured: backend=e2b template=%s", cfg.E2BPythonTemplate)
+ logging.Infof("terminalExec configured: backend=e2b template=%s", cfg.E2BTerminalTemplate)
+ logging.Infof(
+ "terminalExec configured: lease_min_sec=%d lease_max_sec=%d lease_default_sec=%d output_limit_bytes=%d export_mode=%s",
+ terminalManager.leaseMinSec,
+ terminalManager.leaseMaxSec,
+ terminalManager.leaseDefaultSec,
+ terminalManager.outputLimitBytes,
+ terminalManager.exportMode,
+ )
+
+ reconnectDelay := initialReconnectDelay
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ connected, err := runSessionWithStatus(ctx, cfg)
+ if err == nil {
+ return nil
+ }
+ if connected {
+ reconnectDelay = initialReconnectDelay
+ }
+
+ if errCtx := ctx.Err(); errCtx != nil {
+ return errCtx
+ }
+
+ if status.Code(err) == codes.FailedPrecondition {
+ logging.Warnf("registry session replaced for node_id=%s, reconnecting", cfg.WorkerID)
+ reconnectDelay = initialReconnectDelay
+ } else {
+ logging.Warnf("registry session interrupted: %v", err)
+ }
+
+ if err := waitReconnect(ctx, reconnectDelay); err != nil {
+ return err
+ }
+ reconnectDelay = nextReconnectDelay(reconnectDelay)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/session_client.go b/worker/worker-bridge-e2b/internal/runner/session_client.go
new file mode 100644
index 0000000..716aad2
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/session_client.go
@@ -0,0 +1,423 @@
+package runner
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/big"
+ "strconv"
+ "strings"
+ "time"
+
+ registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/protobuf/proto"
+)
+
+func runSession(ctx context.Context, cfg config.Config) error {
+ _, err := runSessionWithStatus(ctx, cfg)
+ return err
+}
+
+func runSessionWithStatus(ctx context.Context, cfg config.Config) (bool, error) {
+ conn, err := dial(ctx, cfg)
+ if err != nil {
+ return false, fmt.Errorf("dial console: %w", err)
+ }
+ defer conn.Close()
+
+ client := registryv1.NewWorkerRegistryServiceClient(conn)
+ stream, err := client.Connect(ctx)
+ if err != nil {
+ return false, fmt.Errorf("open connect stream: %w", err)
+ }
+ defer stream.CloseSend()
+
+ hello, err := buildHello(cfg)
+ if err != nil {
+ return false, fmt.Errorf("build hello: %w", err)
+ }
+
+ if err := stream.Send(®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_Hello{Hello: hello},
+ }); err != nil {
+ return false, fmt.Errorf("send hello: %w", err)
+ }
+
+ resp, err := recvWithTimeout(ctx, cfg.CallTimeout, stream.Recv)
+ if err != nil {
+ return false, fmt.Errorf("recv connect_ack: %w", err)
+ }
+ ack := resp.GetConnectAck()
+ if ack == nil {
+ return false, fmt.Errorf("unexpected first response frame")
+ }
+ sessionID := strings.TrimSpace(ack.GetSessionId())
+ if sessionID == "" {
+ return false, fmt.Errorf("connect_ack.session_id is required")
+ }
+
+ heartbeatInterval := durationFromServer(ack.GetHeartbeatIntervalSec(), cfg.HeartbeatInterval)
+ logging.Infof("worker connected: node_id=%s node_name=%s session_id=%s", hello.GetNodeId(), hello.GetNodeName(), sessionID)
+
+ sessionCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ outbound := make(chan *registryv1.ConnectRequest, 64)
+ heartbeatAckCh := make(chan *registryv1.HeartbeatAck, 16)
+ sessionErrCh := make(chan error, 4)
+
+ go senderLoop(sessionCtx, stream, outbound, sessionErrCh)
+ go receiverLoop(sessionCtx, stream, outbound, heartbeatAckCh, sessionErrCh)
+
+ return true, heartbeatLoop(sessionCtx, outbound, heartbeatAckCh, sessionErrCh, cfg, sessionID, heartbeatInterval)
+}
+
+func dial(ctx context.Context, cfg config.Config) (*grpc.ClientConn, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ var creds grpc.DialOption
+ if cfg.ConsoleTLS {
+ creds = grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))
+ } else {
+ creds = grpc.WithTransportCredentials(insecure.NewCredentials())
+ }
+ return grpc.NewClient(cfg.ConsoleGRPCTarget, creds)
+}
+
+func senderLoop(
+ ctx context.Context,
+ stream grpc.BidiStreamingClient[registryv1.ConnectRequest, registryv1.ConnectResponse],
+ outbound <-chan *registryv1.ConnectRequest,
+ errCh chan<- error,
+) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case req := <-outbound:
+ if req == nil {
+ continue
+ }
+ if err := stream.Send(req); err != nil {
+ reportSessionErr(errCh, fmt.Errorf("stream send failed: %w", err))
+ return
+ }
+ }
+ }
+}
+
+func receiverLoop(
+ ctx context.Context,
+ stream grpc.BidiStreamingClient[registryv1.ConnectRequest, registryv1.ConnectResponse],
+ outbound chan<- *registryv1.ConnectRequest,
+ heartbeatAckCh chan<- *registryv1.HeartbeatAck,
+ errCh chan<- error,
+) {
+ for {
+ resp, err := stream.Recv()
+ if err != nil {
+ reportSessionErr(errCh, fmt.Errorf("stream receive failed: %w", err))
+ return
+ }
+
+ switch {
+ case resp.GetHeartbeatAck() != nil:
+ select {
+ case <-ctx.Done():
+ return
+ case heartbeatAckCh <- resp.GetHeartbeatAck():
+ }
+ case resp.GetCommandDispatch() != nil:
+ dispatch := resp.GetCommandDispatch()
+ capability := strings.TrimSpace(strings.ToLower(dispatch.GetCapability()))
+ commandID := strings.TrimSpace(dispatch.GetCommandId())
+ summary := commandDispatchSummaryForLog(capability, dispatch.GetPayloadJson())
+ logging.Infof("command dispatch received: command_id=%s capability=%s summary=%s", commandID, capability, summary)
+
+ dispatchCopy, ok := proto.Clone(dispatch).(*registryv1.CommandDispatch)
+ if !ok || dispatchCopy == nil {
+ reportSessionErr(errCh, errors.New("clone command dispatch failed"))
+ return
+ }
+
+ go func(dispatch *registryv1.CommandDispatch) {
+ resultReq := buildCommandResultWithContext(ctx, dispatch)
+ if sendErr := enqueueRequest(ctx, outbound, resultReq); sendErr != nil {
+ if errors.Is(sendErr, context.Canceled) || errors.Is(sendErr, context.DeadlineExceeded) {
+ return
+ }
+ reportSessionErr(errCh, fmt.Errorf("enqueue command result: %w", sendErr))
+ }
+ }(dispatchCopy)
+ default:
+ reportSessionErr(errCh, errors.New("unexpected response frame"))
+ return
+ }
+ }
+}
+
+func commandDispatchSummaryForLog(capability string, payload []byte) string {
+ parseFailed := fmt.Sprintf("payload_len=%d summary=parse_failed", len(payload))
+
+ switch strings.TrimSpace(strings.ToLower(capability)) {
+ case echoCapabilityName:
+ decoded := struct {
+ Message string `json:"message"`
+ }{}
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return parseFailed
+ }
+ if strings.TrimSpace(decoded.Message) == "" {
+ return parseFailed
+ }
+ return fmt.Sprintf("message_len=%d", len(decoded.Message))
+ case pythonExecCapabilityName:
+ decoded := pythonExecPayload{}
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return parseFailed
+ }
+ if strings.TrimSpace(decoded.Code) == "" {
+ return parseFailed
+ }
+ return fmt.Sprintf("code_len=%d", len(decoded.Code))
+ case terminalExecCapabilityName:
+ decoded := terminalExecPayload{}
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return parseFailed
+ }
+ if strings.TrimSpace(decoded.Command) == "" {
+ return parseFailed
+ }
+
+ leaseTTLSec := "default"
+ if decoded.LeaseTTLSec != nil {
+ leaseTTLSec = strconv.Itoa(*decoded.LeaseTTLSec)
+ }
+ return fmt.Sprintf(
+ "command_len=%d session_id_present=%t create_if_missing=%t lease_ttl_sec=%s",
+ len(decoded.Command),
+ strings.TrimSpace(decoded.SessionID) != "",
+ decoded.CreateIfMissing,
+ leaseTTLSec,
+ )
+ case terminalResourceCapabilityName:
+ decoded := terminalResourcePayload{}
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return parseFailed
+ }
+ sessionPresent := strings.TrimSpace(decoded.SessionID) != ""
+ path := strings.TrimSpace(decoded.FilePath)
+ if !sessionPresent || path == "" {
+ return parseFailed
+ }
+
+ actionSummary := "default"
+ switch strings.TrimSpace(strings.ToLower(decoded.Action)) {
+ case "":
+ actionSummary = "default"
+ case terminalResourceActionValidate:
+ actionSummary = terminalResourceActionValidate
+ case terminalResourceActionRead:
+ actionSummary = terminalResourceActionRead
+ case terminalResourceActionExport:
+ actionSummary = terminalResourceActionExport
+ default:
+ actionSummary = "invalid"
+ }
+ signedURLPresent := strings.TrimSpace(decoded.SignedURL) != ""
+ return fmt.Sprintf(
+ "action=%s session_id_present=%t file_path_len=%d signed_url_present=%t",
+ actionSummary,
+ sessionPresent,
+ len(path),
+ signedURLPresent,
+ )
+ default:
+ return fmt.Sprintf("payload_len=%d summary=unsupported_capability", len(payload))
+ }
+}
+
+func heartbeatLoop(
+ ctx context.Context,
+ outbound chan<- *registryv1.ConnectRequest,
+ heartbeatAckCh <-chan *registryv1.HeartbeatAck,
+ sessionErrCh <-chan error,
+ cfg config.Config,
+ sessionID string,
+ heartbeatInterval time.Duration,
+) error {
+ interval := heartbeatInterval
+ consecutiveAckTimeouts := 0
+
+ for {
+ waitFor := applyJitter(interval, cfg.HeartbeatJitter)
+ timer := time.NewTimer(waitFor)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return ctx.Err()
+ case err := <-sessionErrCh:
+ timer.Stop()
+ return err
+ case <-timer.C:
+ }
+
+ if err := enqueueRequest(ctx, outbound, ®istryv1.ConnectRequest{
+ Payload: ®istryv1.ConnectRequest_Heartbeat{
+ Heartbeat: ®istryv1.HeartbeatFrame{
+ NodeId: cfg.WorkerID,
+ SessionId: sessionID,
+ ActiveSessionCount: activeSessionCountFn(),
+ },
+ },
+ }); err != nil {
+ return fmt.Errorf("enqueue heartbeat: %w", err)
+ }
+
+ ackTimer := time.NewTimer(cfg.CallTimeout)
+ waitAck := true
+ for waitAck {
+ select {
+ case <-ctx.Done():
+ ackTimer.Stop()
+ return ctx.Err()
+ case err := <-sessionErrCh:
+ ackTimer.Stop()
+ return err
+ case <-ackTimer.C:
+ consecutiveAckTimeouts++
+ if consecutiveAckTimeouts >= 2 {
+ return context.DeadlineExceeded
+ }
+ waitAck = false
+ case heartbeatAck := <-heartbeatAckCh:
+ ackTimer.Stop()
+ consecutiveAckTimeouts = 0
+ interval = durationFromServer(heartbeatAck.GetHeartbeatIntervalSec(), interval)
+ waitAck = false
+ }
+ }
+ }
+}
+
+func enqueueRequest(ctx context.Context, outbound chan<- *registryv1.ConnectRequest, req *registryv1.ConnectRequest) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case outbound <- req:
+ return nil
+ }
+}
+
+func reportSessionErr(errCh chan<- error, err error) {
+ if err == nil {
+ return
+ }
+ select {
+ case errCh <- err:
+ default:
+ }
+}
+
+func recvWithTimeout(
+ ctx context.Context,
+ timeout time.Duration,
+ recv func() (*registryv1.ConnectResponse, error),
+) (*registryv1.ConnectResponse, error) {
+ if timeout <= 0 {
+ return recv()
+ }
+
+ type recvResult struct {
+ resp *registryv1.ConnectResponse
+ err error
+ }
+
+ resultCh := make(chan recvResult, 1)
+ go func() {
+ resp, err := recv()
+ resultCh <- recvResult{resp: resp, err: err}
+ }()
+
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-timer.C:
+ return nil, context.DeadlineExceeded
+ case result := <-resultCh:
+ return result.resp, result.err
+ }
+}
+
+func durationFromServer(seconds int32, fallback time.Duration) time.Duration {
+ if seconds > 0 {
+ return time.Duration(seconds) * time.Second
+ }
+ if fallback <= 0 {
+ return 5 * time.Second
+ }
+ return fallback
+}
+
+func jitterDuration(base time.Duration, jitterPct int) time.Duration {
+ if base <= 0 {
+ base = minHeartbeatInterval
+ }
+ if jitterPct <= 0 {
+ return base
+ }
+
+ maxDelta := int64(base) * int64(jitterPct) / 100
+ if maxDelta <= 0 {
+ return base
+ }
+
+ random, err := rand.Int(rand.Reader, big.NewInt(maxDelta*2+1))
+ if err != nil {
+ return base
+ }
+ delta := random.Int64() - maxDelta
+ jittered := base + time.Duration(delta)
+ if jittered < minHeartbeatInterval {
+ return minHeartbeatInterval
+ }
+ return jittered
+}
+
+func waitReconnectDelay(ctx context.Context, delay time.Duration) error {
+ if delay <= 0 {
+ delay = initialReconnectDelay
+ }
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
+
+func nextReconnectDelay(current time.Duration) time.Duration {
+ if current <= 0 {
+ return initialReconnectDelay
+ }
+ next := current * 2
+ if next > maxReconnectDelay {
+ return maxReconnectDelay
+ }
+ return next
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/session_client_test.go b/worker/worker-bridge-e2b/internal/runner/session_client_test.go
new file mode 100644
index 0000000..4949d17
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/session_client_test.go
@@ -0,0 +1,106 @@
+package runner
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestCommandDispatchSummaryForLog(t *testing.T) {
+ t.Parallel()
+
+ leaseTTL := 90
+ invalidJSONPayload := []byte("not-json")
+ missingRequiredFieldsPayload := []byte(`{"session_id":"s1","file_path":" "}`)
+ unsupportedPayload := []byte(`{"x":1}`)
+ tests := []struct {
+ name string
+ capability string
+ payload []byte
+ want string
+ }{
+ {
+ name: "echo_payload_logs_message_length",
+ capability: echoCapabilityName,
+ payload: []byte(`{"message":"hello"}`),
+ want: "message_len=5",
+ },
+ {
+ name: "python_exec_payload_logs_code_length",
+ capability: pythonExecCapabilityName,
+ payload: []byte(`{"code":"abc"}`),
+ want: "code_len=3",
+ },
+ {
+ name: "terminal_exec_payload_logs_fields_with_default_lease",
+ capability: terminalExecCapabilityName,
+ payload: []byte(`{"command":"pwd","session_id":"sess-1"}`),
+ want: "command_len=3 session_id_present=true create_if_missing=false lease_ttl_sec=default",
+ },
+ {
+ name: "terminal_exec_payload_logs_fields_with_explicit_lease",
+ capability: terminalExecCapabilityName,
+ payload: []byte(fmt.Sprintf(`{"command":"ls","create_if_missing":true,"lease_ttl_sec":%d}`, leaseTTL)),
+ want: "command_len=2 session_id_present=false create_if_missing=true lease_ttl_sec=90",
+ },
+ {
+ name: "terminal_resource_payload_logs_read_action",
+ capability: terminalResourceCapabilityName,
+ payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"read"}`),
+ want: "action=read session_id_present=true file_path_len=6 signed_url_present=false",
+ },
+ {
+ name: "terminal_resource_payload_logs_validate_action",
+ capability: terminalResourceCapabilityName,
+ payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"validate"}`),
+ want: "action=validate session_id_present=true file_path_len=6 signed_url_present=false",
+ },
+ {
+ name: "terminal_resource_payload_logs_default_action",
+ capability: terminalResourceCapabilityName,
+ payload: []byte(`{"session_id":"s1","file_path":"/tmp/a"}`),
+ want: "action=default session_id_present=true file_path_len=6 signed_url_present=false",
+ },
+ {
+ name: "terminal_resource_payload_logs_export_action",
+ capability: terminalResourceCapabilityName,
+ payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"export","signed_url":"https://uploads.example.com/put"}`),
+ want: "action=export session_id_present=true file_path_len=6 signed_url_present=true",
+ },
+ {
+ name: "terminal_resource_payload_logs_invalid_action",
+ capability: terminalResourceCapabilityName,
+ payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"download"}`),
+ want: "action=invalid session_id_present=true file_path_len=6 signed_url_present=false",
+ },
+ {
+ name: "invalid_json_falls_back_to_parse_failed",
+ capability: pythonExecCapabilityName,
+ payload: invalidJSONPayload,
+ want: fmt.Sprintf("payload_len=%d summary=parse_failed", len(invalidJSONPayload)),
+ },
+ {
+ name: "missing_required_fields_falls_back_to_parse_failed",
+ capability: terminalResourceCapabilityName,
+ payload: missingRequiredFieldsPayload,
+ want: fmt.Sprintf("payload_len=%d summary=parse_failed", len(missingRequiredFieldsPayload)),
+ },
+ {
+ name: "unsupported_capability_logs_fallback_summary",
+ capability: "unknown",
+ payload: unsupportedPayload,
+ want: fmt.Sprintf("payload_len=%d summary=unsupported_capability", len(unsupportedPayload)),
+ },
+ }
+
+ for _, tc := range tests {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := commandDispatchSummaryForLog(tc.capability, tc.payload)
+ if got != tc.want {
+ t.Fatalf("expected %q, got %q", tc.want, got)
+ }
+ })
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go
new file mode 100644
index 0000000..3186541
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go
@@ -0,0 +1,534 @@
+package runner
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging"
+)
+
+const (
+ terminalExecCapabilityName = "terminalexec"
+ terminalExecCapabilityDeclared = "terminalExec"
+ terminalExecJanitorInterval = 5 * time.Second
+ terminalExecCleanupTimeout = 10 * time.Second
+ terminalExecNoSessionMessage = "session not found"
+ terminalExecBusyMessage = "session is busy"
+ terminalExecNotReadyMessage = "terminal executor is unavailable"
+ defaultTerminalLeaseMinSec = 60
+ defaultTerminalLeaseMaxSec = 1800
+ defaultTerminalLeaseSec = 300
+ defaultTerminalOutputLimitBytes = 1024 * 1024
+ defaultTerminalSessionMaxInflight = 128
+)
+
+const (
+ terminalExecCodeSessionNotFound = "session_not_found"
+ terminalExecCodeSessionBusy = "session_busy"
+ terminalExecCodeInvalidPayload = "invalid_payload"
+)
+
+type terminalExecPayload struct {
+ Command string `json:"command"`
+ SessionID string `json:"session_id,omitempty"`
+ CreateIfMissing bool `json:"create_if_missing,omitempty"`
+ LeaseTTLSec *int `json:"lease_ttl_sec,omitempty"`
+}
+
+type terminalExecRequest struct {
+ Command string
+ SessionID string
+ CreateIfMissing bool
+ LeaseTTLSec *int
+}
+
+type terminalExecRunResult struct {
+ SessionID string `json:"session_id"`
+ Created bool `json:"created"`
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ ExitCode int `json:"exit_code"`
+ StdoutTruncated bool `json:"stdout_truncated"`
+ StderrTruncated bool `json:"stderr_truncated"`
+ LeaseExpiresUnixMS int64 `json:"lease_expires_unix_ms"`
+}
+
+type terminalExecError struct {
+ code string
+ message string
+}
+
+func (e *terminalExecError) Error() string {
+ if e == nil {
+ return "terminal execution failed"
+ }
+ return e.message
+}
+
+func (e *terminalExecError) Code() string {
+ if e == nil {
+ return ""
+ }
+ return e.code
+}
+
+func newTerminalExecError(code, message string) *terminalExecError {
+ return &terminalExecError{code: strings.TrimSpace(code), message: strings.TrimSpace(message)}
+}
+
+type terminalSession struct {
+ sessionID string
+ sandbox *e2b.Sandbox
+ desiredLeaseExpiresAt time.Time
+ confirmedLeaseExpiresAt time.Time
+ remoteTimeoutExpiresAt time.Time
+ leaseSyncMu sync.Mutex
+ inflight int
+ destroying bool
+ ready chan struct{}
+ initErr error
+}
+
+type terminalSessionManagerConfig struct {
+ Backend e2bBackend
+ Template string
+ LeaseMinSec int
+ LeaseMaxSec int
+ LeaseDefaultSec int
+ OutputLimitBytes int
+ ExportMaxBytes int
+ ExportMode string
+ SessionMaxInflight int
+ // JanitorInterval is test-only tuning in practice; zero selects the
+ // production interval.
+ JanitorInterval time.Duration
+}
+
+type terminalSessionManager struct {
+ mu sync.Mutex
+ sessions map[string]*terminalSession
+ closed bool
+ createWG sync.WaitGroup
+
+ backend e2bBackend
+ template string
+ leaseMinSec int
+ leaseMaxSec int
+ leaseDefaultSec int
+ outputLimitBytes int
+ exportMaxBytes int
+ exportMode string
+ sessionMaxInflight int
+ janitorInterval time.Duration
+
+ stopCh chan struct{}
+ doneCh chan struct{}
+ closeOnce sync.Once
+}
+
+func newTerminalSessionManager(cfg terminalSessionManagerConfig) *terminalSessionManager {
+ leaseMin := cfg.LeaseMinSec
+ if leaseMin <= 0 {
+ leaseMin = defaultTerminalLeaseMinSec
+ }
+ leaseMax := cfg.LeaseMaxSec
+ if leaseMax <= 0 {
+ leaseMax = defaultTerminalLeaseMaxSec
+ }
+ if leaseMax < leaseMin {
+ leaseMax = leaseMin
+ }
+ leaseDefault := cfg.LeaseDefaultSec
+ if leaseDefault <= 0 {
+ leaseDefault = defaultTerminalLeaseSec
+ }
+ leaseDefault = clampTerminalValue(leaseDefault, leaseMin, leaseMax)
+ outputLimit := cfg.OutputLimitBytes
+ if outputLimit <= 0 {
+ outputLimit = defaultTerminalOutputLimitBytes
+ }
+ maxInflight := cfg.SessionMaxInflight
+ if maxInflight <= 0 {
+ maxInflight = defaultTerminalSessionMaxInflight
+ }
+ janitorInterval := cfg.JanitorInterval
+ if janitorInterval <= 0 {
+ janitorInterval = terminalExecJanitorInterval
+ }
+ manager := &terminalSessionManager{
+ sessions: map[string]*terminalSession{},
+ backend: cfg.Backend,
+ template: strings.TrimSpace(cfg.Template),
+ leaseMinSec: leaseMin,
+ leaseMaxSec: leaseMax,
+ leaseDefaultSec: leaseDefault,
+ outputLimitBytes: outputLimit,
+ exportMaxBytes: cfg.ExportMaxBytes,
+ exportMode: normalizeTerminalExportMode(cfg.ExportMode),
+ sessionMaxInflight: maxInflight,
+ janitorInterval: janitorInterval,
+ stopCh: make(chan struct{}),
+ doneCh: make(chan struct{}),
+ }
+ go manager.janitorLoop()
+ return manager
+}
+
+func clampTerminalValue(value, minValue, maxValue int) int {
+ if value < minValue {
+ return minValue
+ }
+ if value > maxValue {
+ return maxValue
+ }
+ return value
+}
+
+func (m *terminalSessionManager) ActiveSessionCount() int32 {
+ if m == nil {
+ return 0
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var count int32
+ for _, session := range m.sessions {
+ if session != nil && !session.destroying {
+ count++
+ }
+ }
+ return count
+}
+
+func (m *terminalSessionManager) Execute(ctx context.Context, req terminalExecRequest) (terminalExecRunResult, error) {
+ if m == nil || m.backend == nil {
+ return terminalExecRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage)
+ }
+ command := strings.TrimSpace(req.Command)
+ if command == "" {
+ return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "command is required")
+ }
+ leaseDuration, err := m.resolveLeaseDuration(req.LeaseTTLSec)
+ if err != nil {
+ return terminalExecRunResult{}, err
+ }
+ session, created, err := m.claimSession(strings.TrimSpace(req.SessionID), time.Now().Add(leaseDuration), req.CreateIfMissing)
+ if err != nil {
+ return terminalExecRunResult{}, err
+ }
+ if err := m.awaitSessionReady(ctx, session, created); err != nil {
+ return terminalExecRunResult{}, err
+ }
+ if err := m.syncSandboxTimeout(ctx, session); err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ m.releaseAndDestroySession(session.sessionID)
+ return terminalExecRunResult{}, err
+ }
+ if errors.Is(err, e2b.ErrSandboxNotFound) {
+ m.releaseAndDestroySession(session.sessionID)
+ return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ m.releaseSession(session.sessionID)
+ return terminalExecRunResult{}, fmt.Errorf("extend E2B sandbox timeout: %w", err)
+ }
+ result, err := m.backend.Run(ctx, session.sandbox, command, m.outputLimitBytes)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ m.releaseAndDestroySession(session.sessionID)
+ return terminalExecRunResult{}, err
+ }
+ if errors.Is(err, e2b.ErrSandboxNotFound) {
+ m.releaseAndDestroySession(session.sessionID)
+ return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ m.releaseSession(session.sessionID)
+ return terminalExecRunResult{}, err
+ }
+ leaseExpires, ok := m.releaseSession(session.sessionID)
+ if !ok {
+ return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ return terminalExecRunResult{
+ SessionID: session.sessionID,
+ Created: created,
+ Stdout: result.Stdout,
+ Stderr: result.Stderr,
+ ExitCode: result.ExitCode,
+ StdoutTruncated: result.StdoutTruncated,
+ StderrTruncated: result.StderrTruncated,
+ LeaseExpiresUnixMS: leaseExpires.UnixMilli(),
+ }, nil
+}
+
+func (m *terminalSessionManager) resolveLeaseDuration(value *int) (time.Duration, error) {
+ seconds := m.leaseDefaultSec
+ if value != nil {
+ seconds = *value
+ }
+ if seconds < m.leaseMinSec || seconds > m.leaseMaxSec {
+ return 0, newTerminalExecError(
+ terminalExecCodeInvalidPayload,
+ fmt.Sprintf("lease_ttl_sec must be between %d and %d", m.leaseMinSec, m.leaseMaxSec),
+ )
+ }
+ return time.Duration(seconds) * time.Second, nil
+}
+
+func (m *terminalSessionManager) claimSession(sessionID string, leaseTarget time.Time, createIfMissing bool) (*terminalSession, bool, error) {
+ m.mu.Lock()
+ if m.closed {
+ m.mu.Unlock()
+ return nil, false, newTerminalExecError("execution_failed", terminalExecNotReadyMessage)
+ }
+ if sessionID == "" {
+ session := m.newSessionLocked(uuid.NewString(), leaseTarget)
+ m.mu.Unlock()
+ return session, true, nil
+ }
+ if existing, ok := m.sessions[sessionID]; ok && existing != nil && !existing.destroying {
+ if existing.inflight == 0 && !existing.confirmedLeaseExpiresAt.After(time.Now()) {
+ delete(m.sessions, sessionID)
+ expiredSandbox := existing.sandbox
+ if !createIfMissing {
+ m.mu.Unlock()
+ m.killSandbox(expiredSandbox)
+ return nil, false, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ session := m.newSessionLocked(sessionID, leaseTarget)
+ m.mu.Unlock()
+ m.killSandbox(expiredSandbox)
+ return session, true, nil
+ }
+ if existing.inflight >= m.sessionMaxInflight {
+ m.mu.Unlock()
+ return nil, false, newTerminalExecError(terminalExecCodeSessionBusy, terminalExecBusyMessage)
+ }
+ existing.inflight++
+ if existing.desiredLeaseExpiresAt.Before(leaseTarget) {
+ existing.desiredLeaseExpiresAt = leaseTarget
+ }
+ m.mu.Unlock()
+ return existing, false, nil
+ }
+ existing, exists := m.sessions[sessionID]
+ if !createIfMissing || (exists && existing != nil && existing.destroying) {
+ m.mu.Unlock()
+ return nil, false, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ session := m.newSessionLocked(sessionID, leaseTarget)
+ m.mu.Unlock()
+ return session, true, nil
+}
+
+func (m *terminalSessionManager) newSessionLocked(sessionID string, leaseTarget time.Time) *terminalSession {
+ m.createWG.Add(1)
+ session := &terminalSession{
+ sessionID: sessionID,
+ desiredLeaseExpiresAt: leaseTarget,
+ inflight: 1,
+ ready: make(chan struct{}),
+ }
+ m.sessions[sessionID] = session
+ return session
+}
+
+func (m *terminalSessionManager) awaitSessionReady(ctx context.Context, session *terminalSession, created bool) error {
+ if created {
+ defer m.createWG.Done()
+ m.mu.Lock()
+ leaseExpiresAt := session.desiredLeaseExpiresAt
+ m.mu.Unlock()
+ timeout := secondsUntil(leaseExpiresAt)
+ sandbox, err := m.backend.Create(ctx, m.template, timeout)
+ m.mu.Lock()
+ session.sandbox = sandbox
+ session.initErr = err
+ if err == nil {
+ session.confirmedLeaseExpiresAt = leaseExpiresAt
+ session.remoteTimeoutExpiresAt = leaseExpiresAt
+ }
+ m.mu.Unlock()
+ close(session.ready)
+ if err != nil {
+ m.releaseAndDestroySession(session.sessionID)
+ return fmt.Errorf("create E2B terminal sandbox: %w", err)
+ }
+ return nil
+ }
+ select {
+ case <-session.ready:
+ case <-ctx.Done():
+ m.releaseSession(session.sessionID)
+ return ctx.Err()
+ }
+ if session.initErr != nil {
+ m.releaseSession(session.sessionID)
+ return session.initErr
+ }
+ return nil
+}
+
+func (m *terminalSessionManager) syncSandboxTimeout(ctx context.Context, session *terminalSession) error {
+ session.leaseSyncMu.Lock()
+ defer session.leaseSyncMu.Unlock()
+
+ m.mu.Lock()
+ leaseExpires := session.desiredLeaseExpiresAt
+ targetExpires := leaseExpires
+ if deadline, ok := ctx.Deadline(); ok && deadline.After(targetExpires) {
+ targetExpires = deadline
+ }
+ remoteExpires := session.remoteTimeoutExpiresAt
+ sandbox := session.sandbox
+ if !remoteExpires.Before(targetExpires) {
+ if session.confirmedLeaseExpiresAt.Before(leaseExpires) {
+ session.confirmedLeaseExpiresAt = leaseExpires
+ }
+ m.mu.Unlock()
+ return nil
+ }
+ m.mu.Unlock()
+ if sandbox == nil {
+ return errors.New("E2B sandbox is unavailable")
+ }
+ if err := m.backend.SetTimeout(ctx, sandbox.ID, secondsUntil(targetExpires)); err != nil {
+ return err
+ }
+ m.mu.Lock()
+ if session.remoteTimeoutExpiresAt.Before(targetExpires) {
+ session.remoteTimeoutExpiresAt = targetExpires
+ }
+ if session.confirmedLeaseExpiresAt.Before(leaseExpires) {
+ session.confirmedLeaseExpiresAt = leaseExpires
+ }
+ m.mu.Unlock()
+ return nil
+}
+
+func secondsUntil(target time.Time) int {
+ seconds := int(time.Until(target).Seconds()) + 1
+ if seconds < 1 {
+ return 1
+ }
+ return seconds
+}
+
+func (m *terminalSessionManager) releaseSession(sessionID string) (time.Time, bool) {
+ m.mu.Lock()
+ session, ok := m.sessions[sessionID]
+ if !ok || session == nil {
+ m.mu.Unlock()
+ return time.Time{}, false
+ }
+ if session.inflight > 0 {
+ session.inflight--
+ }
+ expires := session.confirmedLeaseExpiresAt
+ var sandbox *e2b.Sandbox
+ if session.destroying && session.inflight == 0 {
+ delete(m.sessions, sessionID)
+ sandbox = session.sandbox
+ }
+ m.mu.Unlock()
+ if sandbox != nil {
+ m.killSandbox(sandbox)
+ }
+ return expires, true
+}
+
+func (m *terminalSessionManager) releaseAndDestroySession(sessionID string) {
+ m.mu.Lock()
+ session, ok := m.sessions[sessionID]
+ if !ok || session == nil {
+ m.mu.Unlock()
+ return
+ }
+ session.destroying = true
+ if session.inflight > 0 {
+ session.inflight--
+ }
+ var sandbox *e2b.Sandbox
+ if session.inflight == 0 {
+ delete(m.sessions, sessionID)
+ sandbox = session.sandbox
+ }
+ m.mu.Unlock()
+ if sandbox != nil {
+ m.killSandbox(sandbox)
+ }
+}
+
+func (m *terminalSessionManager) janitorLoop() {
+ ticker := time.NewTicker(m.janitorInterval)
+ defer ticker.Stop()
+ defer close(m.doneCh)
+ for {
+ select {
+ case <-m.stopCh:
+ return
+ case <-ticker.C:
+ m.cleanupExpiredSessions()
+ }
+ }
+}
+
+func (m *terminalSessionManager) cleanupExpiredSessions() {
+ now := time.Now()
+ var expired []*e2b.Sandbox
+ m.mu.Lock()
+ for id, session := range m.sessions {
+ if session == nil || session.destroying || session.inflight > 0 || session.confirmedLeaseExpiresAt.After(now) {
+ continue
+ }
+ delete(m.sessions, id)
+ if session.sandbox != nil {
+ expired = append(expired, session.sandbox)
+ }
+ }
+ m.mu.Unlock()
+ for _, sandbox := range expired {
+ m.killSandbox(sandbox)
+ }
+}
+
+func (m *terminalSessionManager) Close() {
+ if m == nil {
+ return
+ }
+ m.closeOnce.Do(func() {
+ m.mu.Lock()
+ m.closed = true
+ m.mu.Unlock()
+ close(m.stopCh)
+ <-m.doneCh
+ m.createWG.Wait()
+ m.mu.Lock()
+ var sandboxes []*e2b.Sandbox
+ for _, session := range m.sessions {
+ if session != nil && session.sandbox != nil {
+ sandboxes = append(sandboxes, session.sandbox)
+ }
+ }
+ m.sessions = map[string]*terminalSession{}
+ m.mu.Unlock()
+ for _, sandbox := range sandboxes {
+ m.killSandbox(sandbox)
+ }
+ })
+}
+
+func (m *terminalSessionManager) killSandbox(sandbox *e2b.Sandbox) {
+ if sandbox == nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), terminalExecCleanupTimeout)
+ defer cancel()
+ if err := m.backend.Kill(ctx, sandbox.ID); err != nil {
+ logging.Warnf("terminalExec cleanup failed: sandbox_id=%s err=%v", sandbox.ID, err)
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go
new file mode 100644
index 0000000..7802528
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go
@@ -0,0 +1,425 @@
+package runner
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+)
+
+const (
+ terminalResourceCapabilityName = "terminalresource"
+ terminalResourceCapabilityDeclared = "terminalResource"
+ terminalResourceActionValidate = "validate"
+ terminalResourceActionRead = "read"
+ terminalResourceActionExport = "export"
+ terminalExportModeWorker = "worker"
+ terminalExportModeSandbox = "sandbox"
+ terminalResourceCodeFileNotFound = "file_not_found"
+ terminalResourceCodePathIsDir = "path_is_directory"
+ terminalResourceCodeFileTooLarge = "file_too_large"
+ terminalExportLimitMarker = "ONLYBOXES_EXPORT_FILE_TOO_LARGE"
+)
+
+const sandboxExportScript = `import base64
+import http.client
+import json
+import os
+import sys
+import urllib.parse
+
+config = json.loads(base64.b64decode(sys.argv[1]))
+file_path = config["file_path"]
+url = urllib.parse.urlsplit(config["signed_url"])
+if url.scheme not in ("http", "https") or not url.hostname:
+ raise ValueError("signed_url must be an absolute HTTP or HTTPS URL")
+
+target = url.path or "/"
+if url.query:
+ target += "?" + url.query
+headers = dict(config.get("headers") or {})
+max_bytes = int(config.get("max_bytes") or 0)
+size = os.path.getsize(file_path)
+if max_bytes > 0 and size > max_bytes:
+ raise RuntimeError("ONLYBOXES_EXPORT_FILE_TOO_LARGE")
+headers["Content-Length"] = str(size)
+connection_type = http.client.HTTPSConnection if url.scheme == "https" else http.client.HTTPConnection
+connection = connection_type(url.hostname, url.port)
+
+class LimitedReader:
+ def __init__(self, source, limit):
+ self.source = source
+ self.remaining = limit
+
+ def read(self, size=-1):
+ if self.remaining == 0:
+ if self.source.read(1):
+ raise RuntimeError("ONLYBOXES_EXPORT_FILE_TOO_LARGE")
+ return b""
+ if self.remaining > 0 and (size < 0 or size > self.remaining):
+ size = self.remaining
+ chunk = self.source.read(size)
+ if self.remaining > 0:
+ self.remaining -= len(chunk)
+ return chunk
+
+try:
+ with open(file_path, "rb") as source:
+ body = LimitedReader(source, max_bytes) if max_bytes > 0 else source
+ connection.request("PUT", target, body=body, headers=headers)
+ response = connection.getresponse()
+ response.read(1024)
+ if response.status < 200 or response.status >= 300:
+ raise RuntimeError("upload returned HTTP status %d" % response.status)
+finally:
+ connection.close()
+`
+
+type terminalResourcePayload struct {
+ SessionID string `json:"session_id"`
+ FilePath string `json:"file_path"`
+ Action string `json:"action,omitempty"`
+ SignedURL string `json:"signed_url,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+type terminalResourceRequest struct {
+ SessionID string
+ FilePath string
+ Action string
+ SignedURL string
+ Headers map[string]string
+}
+
+type terminalResourceRunResult struct {
+ SessionID string `json:"session_id"`
+ FilePath string `json:"file_path"`
+ MIMEType string `json:"mime_type"`
+ SizeBytes int64 `json:"size_bytes"`
+ Blob []byte `json:"blob,omitempty"`
+}
+
+type terminalResourceProbeResult struct {
+ Error string `json:"error,omitempty"`
+ Message string `json:"message,omitempty"`
+ MIMEType string `json:"mime_type,omitempty"`
+ Size int64 `json:"size_bytes"`
+}
+
+func (m *terminalSessionManager) ResolveResource(ctx context.Context, req terminalResourceRequest) (terminalResourceRunResult, error) {
+ if m == nil || m.backend == nil {
+ return terminalResourceRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage)
+ }
+ sessionID := strings.TrimSpace(req.SessionID)
+ filePath := strings.TrimSpace(req.FilePath)
+ if sessionID == "" || filePath == "" {
+ return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "session_id and file_path are required")
+ }
+ action := normalizeTerminalResourceAction(req.Action)
+ if action == "" {
+ return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "action must be validate, read, or export")
+ }
+ if action == terminalResourceActionExport && strings.TrimSpace(req.SignedURL) == "" {
+ return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "signed_url is required for export")
+ }
+
+ session, _, err := m.claimSession(sessionID, timeZero, false)
+ if err != nil {
+ return terminalResourceRunResult{}, err
+ }
+ if err := m.awaitSessionReady(ctx, session, false); err != nil {
+ return terminalResourceRunResult{}, err
+ }
+ if err := m.syncSandboxTimeout(ctx, session); err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ m.releaseAndDestroySession(sessionID)
+ return terminalResourceRunResult{}, err
+ }
+ if errors.Is(err, e2b.ErrSandboxNotFound) {
+ m.releaseAndDestroySession(sessionID)
+ return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ m.releaseSession(sessionID)
+ return terminalResourceRunResult{}, fmt.Errorf("extend E2B sandbox timeout: %w", err)
+ }
+ result, err := m.resolveResource(ctx, session, filePath, action, req.SignedURL, req.Headers)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ m.releaseAndDestroySession(sessionID)
+ return terminalResourceRunResult{}, err
+ }
+ if errors.Is(err, e2b.ErrSandboxNotFound) {
+ m.releaseAndDestroySession(sessionID)
+ return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ m.releaseSession(sessionID)
+ return terminalResourceRunResult{}, err
+ }
+ if _, ok := m.releaseSession(sessionID); !ok {
+ return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage)
+ }
+ return result, nil
+}
+
+var timeZero = time.Time{}
+
+func (m *terminalSessionManager) resolveResource(
+ ctx context.Context,
+ session *terminalSession,
+ filePath, action, signedURL string,
+ headers map[string]string,
+) (terminalResourceRunResult, error) {
+ probe, err := m.probeResource(ctx, session, filePath)
+ if err != nil {
+ return terminalResourceRunResult{}, err
+ }
+ result := terminalResourceRunResult{
+ SessionID: session.sessionID,
+ FilePath: filePath,
+ MIMEType: probe.MIMEType,
+ SizeBytes: probe.Size,
+ }
+ switch action {
+ case terminalResourceActionValidate:
+ return result, nil
+ case terminalResourceActionRead:
+ file, err := m.backend.ReadFile(ctx, session.sandbox, filePath, int64(m.outputLimitBytes))
+ if errors.Is(err, e2b.ErrFileNotFound) {
+ return terminalResourceRunResult{}, newTerminalExecError(terminalResourceCodeFileNotFound, "file not found")
+ }
+ if errors.Is(err, e2b.ErrFileTooLarge) {
+ return terminalResourceRunResult{}, newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds read limit")
+ }
+ if err != nil {
+ return terminalResourceRunResult{}, err
+ }
+ result.MIMEType = file.MIMEType
+ result.SizeBytes = file.Size
+ result.Blob = file.Content
+ return result, nil
+ case terminalResourceActionExport:
+ if m.exportMaxBytes > 0 && probe.Size > int64(m.exportMaxBytes) {
+ return terminalResourceRunResult{}, newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit")
+ }
+ if err := m.exportResource(ctx, session.sandbox, filePath, signedURL, headers); err != nil {
+ return terminalResourceRunResult{}, err
+ }
+ return result, nil
+ default:
+ return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "invalid action")
+ }
+}
+
+func (m *terminalSessionManager) probeResource(ctx context.Context, session *terminalSession, filePath string) (terminalResourceProbeResult, error) {
+ encodedPath := base64.StdEncoding.EncodeToString([]byte(filePath))
+ script := `import base64,json,mimetypes,os; p=base64.b64decode("` + encodedPath + `").decode(); ` +
+ `print(json.dumps(({"error":"file_not_found","message":"file not found"} if not os.path.exists(p) else ` +
+ `({"error":"path_is_directory","message":"path is directory"} if os.path.isdir(p) else ` +
+ `{"mime_type":mimetypes.guess_type(p)[0] or "application/octet-stream","size_bytes":os.path.getsize(p)}))))`
+ command := "python3 -c '" + script + "'"
+ output, err := m.backend.Run(ctx, session.sandbox, command, 64*1024)
+ if err != nil {
+ return terminalResourceProbeResult{}, err
+ }
+ if output.ExitCode != 0 {
+ return terminalResourceProbeResult{}, fmt.Errorf("probe resource failed: exit_code=%d stderr=%s", output.ExitCode, strings.TrimSpace(output.Stderr))
+ }
+ probe := terminalResourceProbeResult{}
+ if err := json.Unmarshal([]byte(strings.TrimSpace(output.Stdout)), &probe); err != nil {
+ return terminalResourceProbeResult{}, fmt.Errorf("decode resource metadata: %w", err)
+ }
+ if probe.Error != "" {
+ return terminalResourceProbeResult{}, newTerminalExecError(probe.Error, terminalResourceErrorMessage(probe.Error, probe.Message))
+ }
+ if probe.MIMEType == "" {
+ probe.MIMEType = "application/octet-stream"
+ }
+ return probe, nil
+}
+
+func (m *terminalSessionManager) exportResource(
+ ctx context.Context,
+ sandbox *e2b.Sandbox,
+ filePath, signedURL string,
+ headers map[string]string,
+) error {
+ if m.exportMode == terminalExportModeSandbox {
+ return m.exportResourceFromSandbox(ctx, sandbox, filePath, signedURL, headers)
+ }
+ return m.exportResourceThroughWorker(ctx, sandbox, filePath, signedURL, headers)
+}
+
+func (m *terminalSessionManager) exportResourceThroughWorker(
+ ctx context.Context,
+ sandbox *e2b.Sandbox,
+ filePath, signedURL string,
+ headers map[string]string,
+) error {
+ source, err := m.backend.OpenFile(ctx, sandbox, filePath)
+ if errors.Is(err, e2b.ErrFileNotFound) {
+ return newTerminalExecError(terminalResourceCodeFileNotFound, "file not found")
+ }
+ if err != nil {
+ return err
+ }
+ defer source.Body.Close()
+ if m.exportMaxBytes > 0 && source.Size > int64(m.exportMaxBytes) {
+ return newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit")
+ }
+ var uploadBody io.Reader = source.Body
+ if m.exportMaxBytes > 0 {
+ uploadBody = &exportLimitReader{
+ source: source.Body,
+ remaining: int64(m.exportMaxBytes),
+ }
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, signedURL, uploadBody)
+ if err != nil {
+ return fmt.Errorf("build export request: %w", err)
+ }
+ if source.Size >= 0 {
+ req.ContentLength = source.Size
+ }
+ for key, value := range headers {
+ if key = strings.TrimSpace(key); key != "" {
+ req.Header.Set(key, value)
+ }
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ if errors.Is(err, errTerminalExportTooLarge) {
+ return newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit")
+ }
+ return fmt.Errorf("upload export file: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ message := strings.TrimSpace(string(body))
+ if message == "" {
+ message = resp.Status
+ }
+ return fmt.Errorf("upload export file failed: %s", message)
+ }
+ return nil
+}
+
+func (m *terminalSessionManager) exportResourceFromSandbox(
+ ctx context.Context,
+ sandbox *e2b.Sandbox,
+ filePath, signedURL string,
+ headers map[string]string,
+) error {
+ command, err := buildSandboxExportCommand(filePath, signedURL, headers, m.exportMaxBytes)
+ if err != nil {
+ return err
+ }
+ output, err := m.backend.Run(ctx, sandbox, command, 64*1024)
+ if err != nil {
+ return fmt.Errorf("run sandbox export: %w", err)
+ }
+ if output.ExitCode != 0 {
+ message := strings.TrimSpace(output.Stderr)
+ if message == "" {
+ message = strings.TrimSpace(output.Stdout)
+ }
+ if message == "" {
+ message = "upload command failed"
+ }
+ if strings.Contains(message, terminalExportLimitMarker) {
+ return newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit")
+ }
+ return fmt.Errorf("sandbox export failed: exit_code=%d: %s", output.ExitCode, message)
+ }
+ return nil
+}
+
+func buildSandboxExportCommand(filePath, signedURL string, headers map[string]string, maxBytes int) (string, error) {
+ payload, err := json.Marshal(struct {
+ FilePath string `json:"file_path"`
+ SignedURL string `json:"signed_url"`
+ Headers map[string]string `json:"headers,omitempty"`
+ MaxBytes int `json:"max_bytes,omitempty"`
+ }{
+ FilePath: filePath,
+ SignedURL: signedURL,
+ Headers: headers,
+ MaxBytes: maxBytes,
+ })
+ if err != nil {
+ return "", fmt.Errorf("encode sandbox export request: %w", err)
+ }
+ encodedScript := base64.StdEncoding.EncodeToString([]byte(sandboxExportScript))
+ encodedPayload := base64.StdEncoding.EncodeToString(payload)
+ return "python3 -c 'exec(__import__(\"base64\").b64decode(\"" + encodedScript + "\"))' " + encodedPayload, nil
+}
+
+var errTerminalExportTooLarge = errors.New("terminal export exceeds configured limit")
+
+type exportLimitReader struct {
+ source io.Reader
+ remaining int64
+}
+
+func (r *exportLimitReader) Read(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+ if r.remaining > 0 {
+ if int64(len(p)) > r.remaining {
+ p = p[:r.remaining]
+ }
+ n, err := r.source.Read(p)
+ r.remaining -= int64(n)
+ return n, err
+ }
+ var probe [1]byte
+ n, err := r.source.Read(probe[:])
+ if n > 0 {
+ return 0, errTerminalExportTooLarge
+ }
+ return 0, err
+}
+
+func normalizeTerminalExportMode(mode string) string {
+ if strings.EqualFold(strings.TrimSpace(mode), terminalExportModeWorker) {
+ return terminalExportModeWorker
+ }
+ return terminalExportModeSandbox
+}
+
+func normalizeTerminalResourceAction(action string) string {
+ switch strings.ToLower(strings.TrimSpace(action)) {
+ case "", terminalResourceActionValidate:
+ return terminalResourceActionValidate
+ case terminalResourceActionRead:
+ return terminalResourceActionRead
+ case terminalResourceActionExport:
+ return terminalResourceActionExport
+ default:
+ return ""
+ }
+}
+
+func terminalResourceErrorMessage(code, fallback string) string {
+ if fallback = strings.TrimSpace(fallback); fallback != "" {
+ return fallback
+ }
+ switch code {
+ case terminalResourceCodeFileNotFound:
+ return "file not found"
+ case terminalResourceCodePathIsDir:
+ return "path is directory"
+ case terminalResourceCodeFileTooLarge:
+ return "file exceeds limit"
+ default:
+ return "terminal resource operation failed"
+ }
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go
new file mode 100644
index 0000000..d4e2edb
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go
@@ -0,0 +1,299 @@
+package runner
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+)
+
+type recordingE2BBackend struct {
+ e2bBackend
+ mu sync.Mutex
+ killIDs []string
+}
+
+func (b *recordingE2BBackend) Kill(ctx context.Context, sandboxID string) error {
+ err := b.e2bBackend.Kill(ctx, sandboxID)
+ if err == nil {
+ b.mu.Lock()
+ b.killIDs = append(b.killIDs, sandboxID)
+ b.mu.Unlock()
+ }
+ return err
+}
+
+func (b *recordingE2BBackend) killCount() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return len(b.killIDs)
+}
+
+func TestIntegrationTerminalSessionConcurrentExecAndResources(t *testing.T) {
+ backend, template := liveTerminalBackend(t)
+ manager := newLiveTerminalManager(backend, template, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: `printf 'resource-live' > /tmp/onlyboxes-resource-live.txt`,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ startGate := make(chan struct{})
+ results := make(chan outcome, 2)
+ startedAt := time.Now()
+ for _, command := range []string{
+ `sleep 2; printf 'concurrent-a'`,
+ `sleep 2; printf 'concurrent-b'`,
+ } {
+ command := command
+ go func() {
+ <-startGate
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: command,
+ SessionID: seed.SessionID,
+ })
+ results <- outcome{result: result, err: err}
+ }()
+ }
+ close(startGate)
+ waitForSessionInflight(t, manager, seed.SessionID, 2, 3*time.Second)
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "printf overflow",
+ SessionID: seed.SessionID,
+ }); terminalErrorCode(err) != terminalExecCodeSessionBusy {
+ t.Fatalf("expected third concurrent terminalExec to return session_busy, got %v", err)
+ }
+ outputs := map[string]bool{}
+ for range 2 {
+ result := <-results
+ if result.err != nil {
+ t.Fatal(result.err)
+ }
+ if result.result.SessionID != seed.SessionID {
+ t.Fatalf("concurrent command switched sessions: %#v", result.result)
+ }
+ outputs[result.result.Stdout] = true
+ }
+ if !outputs["concurrent-a"] || !outputs["concurrent-b"] {
+ t.Fatalf("unexpected concurrent outputs: %v", outputs)
+ }
+ if elapsed := time.Since(startedAt); elapsed >= 3500*time.Millisecond {
+ t.Fatalf("commands appear serial, elapsed=%s", elapsed)
+ }
+
+ validate, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/onlyboxes-resource-live.txt",
+ Action: terminalResourceActionValidate,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if validate.SizeBytes != int64(len("resource-live")) {
+ t.Fatalf("unexpected validate result: %#v", validate)
+ }
+ read, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/onlyboxes-resource-live.txt",
+ Action: terminalResourceActionRead,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(read.Blob) != "resource-live" {
+ t.Fatalf("unexpected read result: %#v", read)
+ }
+
+ var uploaded []byte
+ uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ uploaded, _ = io.ReadAll(req.Body)
+ if req.Header.Get("X-Onlyboxes-Test") != "export" {
+ t.Errorf("export header was not forwarded")
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer uploadServer.Close()
+ if _, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/onlyboxes-resource-live.txt",
+ Action: terminalResourceActionExport,
+ SignedURL: uploadServer.URL,
+ Headers: map[string]string{"X-Onlyboxes-Test": "export"},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if string(uploaded) != "resource-live" {
+ t.Fatalf("unexpected exported content %q", uploaded)
+ }
+
+ execDone := make(chan outcome, 1)
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: `sleep 4; printf 'mixed-exec-ok'`,
+ SessionID: seed.SessionID,
+ })
+ execDone <- outcome{result: result, err: err}
+ }()
+ waitForSessionInflight(t, manager, seed.SessionID, 1, 3*time.Second)
+ mixedRead, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/onlyboxes-resource-live.txt",
+ Action: terminalResourceActionRead,
+ })
+ if err != nil || string(mixedRead.Blob) != "resource-live" {
+ t.Fatalf("mixed terminalResource failed: result=%#v err=%v", mixedRead, err)
+ }
+ select {
+ case early := <-execDone:
+ t.Fatalf("terminalExec ended before concurrent resource completed: %#v err=%v", early.result, early.err)
+ default:
+ }
+ mixedExec := <-execDone
+ if mixedExec.err != nil || mixedExec.result.Stdout != "mixed-exec-ok" {
+ t.Fatalf("mixed terminalExec failed: %#v err=%v", mixedExec.result, mixedExec.err)
+ }
+
+ manager.Close()
+ if backend.killCount() != 1 {
+ t.Fatalf("expected one E2B sandbox cleanup, got %d", backend.killCount())
+ }
+}
+
+func TestIntegrationTimedOutCommandDoesNotKillSibling(t *testing.T) {
+ backend, template := liveTerminalBackend(t)
+ manager := newLiveTerminalManager(backend, template, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "printf seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ siblingDone := make(chan outcome, 1)
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: `sleep 2; printf 'sibling-live-ok'`,
+ SessionID: seed.SessionID,
+ })
+ siblingDone <- outcome{result: result, err: err}
+ }()
+ waitForSessionInflight(t, manager, seed.SessionID, 1, 3*time.Second)
+ timeoutCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
+ defer cancel()
+ _, err = manager.Execute(timeoutCtx, terminalExecRequest{
+ Command: `sleep 5; printf should-not-complete`,
+ SessionID: seed.SessionID,
+ })
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected deadline exceeded, got %v", err)
+ }
+ manager.mu.Lock()
+ sessionAfterTimeout := manager.sessions[seed.SessionID]
+ if sessionAfterTimeout == nil || !sessionAfterTimeout.destroying || sessionAfterTimeout.inflight != 1 {
+ manager.mu.Unlock()
+ t.Fatalf("unexpected session state after timeout: %#v", sessionAfterTimeout)
+ }
+ manager.mu.Unlock()
+ if backend.killCount() != 0 {
+ t.Fatal("sandbox was killed before sibling drained")
+ }
+ sibling := <-siblingDone
+ if sibling.err != nil || sibling.result.Stdout != "sibling-live-ok" {
+ t.Fatalf("sibling was disrupted: %#v err=%v", sibling.result, sibling.err)
+ }
+ manager.mu.Lock()
+ _, sessionStillPresent := manager.sessions[seed.SessionID]
+ manager.mu.Unlock()
+ if sessionStillPresent {
+ t.Fatal("destroying session remained registered after sibling drained")
+ }
+ waitForCondition(t, 5*time.Second, func() bool { return backend.killCount() == 1 }, "sandbox was not killed after sibling drained")
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "printf after",
+ SessionID: seed.SessionID,
+ }); terminalErrorCode(err) != terminalExecCodeSessionNotFound {
+ t.Fatalf("timed-out session still accepted commands: %v", err)
+ }
+}
+
+func TestIntegrationJanitorExpiresTerminalSession(t *testing.T) {
+ backend, template := liveTerminalBackend(t)
+ manager := newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: template,
+ LeaseMinSec: 1,
+ LeaseMaxSec: 30,
+ LeaseDefaultSec: 1,
+ OutputLimitBytes: 1024,
+ SessionMaxInflight: 1,
+ JanitorInterval: 100 * time.Millisecond,
+ })
+ defer manager.Close()
+ session, err := manager.Execute(context.Background(), terminalExecRequest{Command: "printf expiring"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ waitForCondition(t, 5*time.Second, func() bool {
+ return manager.ActiveSessionCount() == 0 && backend.killCount() == 1
+ }, "janitor did not expire and clean up the live E2B sandbox")
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "printf after",
+ SessionID: session.SessionID,
+ }); terminalErrorCode(err) != terminalExecCodeSessionNotFound {
+ t.Fatalf("expired session still accepted commands: %v", err)
+ }
+}
+
+func liveTerminalBackend(t *testing.T) (*recordingE2BBackend, string) {
+ t.Helper()
+ if os.Getenv("E2B_INTEGRATION") != "1" {
+ t.Skip("set E2B_INTEGRATION=1 to run against E2B")
+ }
+ apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY"))
+ template := strings.TrimSpace(os.Getenv("E2B_TERMINAL_TEMPLATE"))
+ if apiKey == "" || template == "" {
+ t.Fatal("E2B_API_KEY and E2B_TERMINAL_TEMPLATE are required")
+ }
+ client, err := e2b.NewClient(e2b.Config{
+ APIKey: apiKey,
+ APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")),
+ Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")),
+ RequestTimeout: 60 * time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &recordingE2BBackend{e2bBackend: client}, template
+}
+
+func newLiveTerminalManager(backend e2bBackend, template string, maxInflight int) *terminalSessionManager {
+ return newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: template,
+ LeaseMinSec: 1,
+ LeaseMaxSec: 120,
+ LeaseDefaultSec: 60,
+ OutputLimitBytes: 1024 * 1024,
+ ExportMaxBytes: 1024 * 1024,
+ ExportMode: terminalExportModeWorker,
+ SessionMaxInflight: maxInflight,
+ })
+}
+
+func waitForSessionInflight(t *testing.T, manager *terminalSessionManager, sessionID string, want int, timeout time.Duration) {
+ t.Helper()
+ waitForCondition(t, timeout, func() bool {
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ session := manager.sessions[sessionID]
+ return session != nil && session.inflight == want
+ }, "session did not reach expected inflight count")
+}
diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go
new file mode 100644
index 0000000..bfa4e39
--- /dev/null
+++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go
@@ -0,0 +1,1151 @@
+package runner
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b"
+)
+
+type fakeE2BBackend struct {
+ mu sync.Mutex
+ created int
+ killed int
+ killedIDs []string
+ timeouts []int
+ runStarted chan struct{}
+ runRelease chan struct{}
+ createFn func(context.Context, string, int) (*e2b.Sandbox, error)
+ timeoutFn func(context.Context, string, int) error
+ runFn func(context.Context, *e2b.Sandbox, string, int) (e2b.CommandResult, error)
+ readFn func(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error)
+ openFn func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error)
+}
+
+func (f *fakeE2BBackend) Create(ctx context.Context, template string, timeout int) (*e2b.Sandbox, error) {
+ if f.createFn != nil {
+ return f.createFn(ctx, template, timeout)
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.created++
+ return &e2b.Sandbox{ID: "sandbox-" + strconv.Itoa(f.created), Domain: "test"}, nil
+}
+
+func (f *fakeE2BBackend) SetTimeout(ctx context.Context, sandboxID string, timeout int) error {
+ if f.timeoutFn != nil {
+ return f.timeoutFn(ctx, sandboxID, timeout)
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.timeouts = append(f.timeouts, timeout)
+ return nil
+}
+
+func (f *fakeE2BBackend) Kill(_ context.Context, sandboxID string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.killed++
+ f.killedIDs = append(f.killedIDs, sandboxID)
+ return nil
+}
+
+func (f *fakeE2BBackend) Run(ctx context.Context, sandbox *e2b.Sandbox, command string, outputLimit int) (e2b.CommandResult, error) {
+ if f.runFn != nil {
+ return f.runFn(ctx, sandbox, command, outputLimit)
+ }
+ if f.runStarted != nil {
+ select {
+ case f.runStarted <- struct{}{}:
+ default:
+ }
+ }
+ if f.runRelease != nil {
+ select {
+ case <-ctx.Done():
+ return e2b.CommandResult{}, ctx.Err()
+ case <-f.runRelease:
+ }
+ }
+ return e2b.CommandResult{Stdout: command, ExitCode: 0}, nil
+}
+
+func (f *fakeE2BBackend) ReadFile(ctx context.Context, sandbox *e2b.Sandbox, filePath string, limit int64) (e2b.File, error) {
+ if f.readFn != nil {
+ return f.readFn(ctx, sandbox, filePath, limit)
+ }
+ return e2b.File{Content: []byte("ok"), MIMEType: "text/plain", Size: 2}, nil
+}
+
+func (f *fakeE2BBackend) OpenFile(ctx context.Context, sandbox *e2b.Sandbox, filePath string) (e2b.FileReader, error) {
+ if f.openFn != nil {
+ return f.openFn(ctx, sandbox, filePath)
+ }
+ return e2b.FileReader{Body: io.NopCloser(bytes.NewReader([]byte("ok"))), MIMEType: "text/plain", Size: 2}, nil
+}
+
+func newTestTerminalManager(backend e2bBackend, maxInflight int) *terminalSessionManager {
+ return newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: "terminal-template",
+ LeaseMinSec: 1,
+ LeaseMaxSec: 60,
+ LeaseDefaultSec: 10,
+ OutputLimitBytes: 1024,
+ ExportMode: terminalExportModeWorker,
+ SessionMaxInflight: maxInflight,
+ })
+}
+
+func TestTerminalSessionCreatesReusesAndExtendsE2BLease(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ manager := newTestTerminalManager(backend, 1)
+ defer manager.Close()
+
+ first, err := manager.Execute(context.Background(), terminalExecRequest{Command: "pwd"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !first.Created || first.SessionID == "" || first.Stdout != "pwd" {
+ t.Fatalf("unexpected first result: %#v", first)
+ }
+ lease := 20
+ second, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "ls",
+ SessionID: first.SessionID,
+ LeaseTTLSec: &lease,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.Created || second.SessionID != first.SessionID || second.Stdout != "ls" {
+ t.Fatalf("unexpected reused result: %#v", second)
+ }
+ if second.LeaseExpiresUnixMS <= first.LeaseExpiresUnixMS {
+ t.Fatalf("lease was not extended: first=%d second=%d", first.LeaseExpiresUnixMS, second.LeaseExpiresUnixMS)
+ }
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ if backend.created != 1 {
+ t.Fatalf("expected one E2B sandbox, got %d", backend.created)
+ }
+ if len(backend.timeouts) != 1 || backend.timeouts[0] < 19 {
+ t.Fatalf("unexpected E2B timeout updates: %v", backend.timeouts)
+ }
+}
+
+func TestConcurrentLeaseUpdatesAreAppliedInIncreasingOrder(t *testing.T) {
+ t.Parallel()
+ firstSyncStarted := make(chan struct{})
+ firstSyncRelease := make(chan struct{})
+ var timeoutMu sync.Mutex
+ var timeouts []int
+ activeSyncs := 0
+ maxActiveSyncs := 0
+ backend := &fakeE2BBackend{}
+ backend.timeoutFn = func(ctx context.Context, _ string, timeout int) error {
+ timeoutMu.Lock()
+ timeouts = append(timeouts, timeout)
+ activeSyncs++
+ if activeSyncs > maxActiveSyncs {
+ maxActiveSyncs = activeSyncs
+ }
+ call := len(timeouts)
+ timeoutMu.Unlock()
+ if call == 1 {
+ close(firstSyncStarted)
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-firstSyncRelease:
+ }
+ }
+ timeoutMu.Lock()
+ activeSyncs--
+ timeoutMu.Unlock()
+ return nil
+ }
+ manager := newTestTerminalManager(backend, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ type leaseOutcome struct {
+ result terminalExecRunResult
+ err error
+ }
+ outcomes := make(chan leaseOutcome, 2)
+ shortLease := 20
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "short",
+ SessionID: seed.SessionID,
+ LeaseTTLSec: &shortLease,
+ })
+ outcomes <- leaseOutcome{result: result, err: err}
+ }()
+ <-firstSyncStarted
+
+ longLease := 40
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "long",
+ SessionID: seed.SessionID,
+ LeaseTTLSec: &longLease,
+ })
+ outcomes <- leaseOutcome{result: result, err: err}
+ }()
+ waitForCondition(t, time.Second, func() bool {
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ session := manager.sessions[seed.SessionID]
+ return session != nil && time.Until(session.desiredLeaseExpiresAt) > 35*time.Second
+ }, "longer lease was not recorded while the first timeout update was in flight")
+ close(firstSyncRelease)
+
+ for range 2 {
+ outcome := <-outcomes
+ if outcome.err != nil {
+ t.Fatal(outcome.err)
+ }
+ }
+ timeoutMu.Lock()
+ defer timeoutMu.Unlock()
+ if len(timeouts) != 2 || timeouts[1] <= timeouts[0] {
+ t.Fatalf("lease updates were not increasing: %v", timeouts)
+ }
+ if maxActiveSyncs != 1 {
+ t.Fatalf("SetTimeout calls overlapped: max_active=%d", maxActiveSyncs)
+ }
+}
+
+func TestFailedLeaseUpdateDoesNotAdvanceConfirmedExpiry(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ manager := newTestTerminalManager(backend, 1)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ backend.timeoutFn = func(context.Context, string, int) error {
+ return errors.New("timeout update failed")
+ }
+ longLease := 20
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "fails",
+ SessionID: seed.SessionID,
+ LeaseTTLSec: &longLease,
+ }); err == nil {
+ t.Fatal("expected lease update failure")
+ }
+ manager.mu.Lock()
+ session := manager.sessions[seed.SessionID]
+ confirmedAfterFailure := session.confirmedLeaseExpiresAt
+ desiredAfterFailure := session.desiredLeaseExpiresAt
+ manager.mu.Unlock()
+ if confirmedAfterFailure.UnixMilli() != seed.LeaseExpiresUnixMS {
+ t.Fatalf("failed update advanced confirmed lease: before=%d after=%d", seed.LeaseExpiresUnixMS, confirmedAfterFailure.UnixMilli())
+ }
+ if !desiredAfterFailure.After(confirmedAfterFailure) {
+ t.Fatalf("expected the requested lease to remain pending: desired=%s confirmed=%s", desiredAfterFailure, confirmedAfterFailure)
+ }
+
+ backend.timeoutFn = nil
+ retried, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "retry",
+ SessionID: seed.SessionID,
+ LeaseTTLSec: &longLease,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if retried.LeaseExpiresUnixMS <= seed.LeaseExpiresUnixMS {
+ t.Fatalf("retry did not confirm the pending lease: before=%d after=%d", seed.LeaseExpiresUnixMS, retried.LeaseExpiresUnixMS)
+ }
+}
+
+func TestTerminalSessionRejectsCommandsPastPerSessionLimit(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{
+ runStarted: make(chan struct{}, 1),
+ runRelease: make(chan struct{}),
+ }
+ manager := newTestTerminalManager(backend, 1)
+ defer manager.Close()
+
+ firstDone := make(chan terminalExecRunResult, 1)
+ firstErr := make(chan error, 1)
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{Command: "first"})
+ firstDone <- result
+ firstErr <- err
+ }()
+ <-backend.runStarted
+
+ manager.mu.Lock()
+ var sessionID string
+ for id := range manager.sessions {
+ sessionID = id
+ }
+ manager.mu.Unlock()
+ _, err := manager.Execute(context.Background(), terminalExecRequest{Command: "second", SessionID: sessionID})
+ var terminalErr *terminalExecError
+ if !errors.As(err, &terminalErr) || terminalErr.Code() != terminalExecCodeSessionBusy {
+ t.Fatalf("expected session_busy, got %v", err)
+ }
+ close(backend.runRelease)
+ if err := <-firstErr; err != nil {
+ t.Fatal(err)
+ }
+ if result := <-firstDone; result.SessionID != sessionID {
+ t.Fatalf("unexpected first result: %#v", result)
+ }
+}
+
+func TestTerminalSessionAllowsMultipleConcurrentExecsWithinLimit(t *testing.T) {
+ t.Parallel()
+ started := make(chan string, 2)
+ release := make(chan struct{})
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ if command == "seed" {
+ return e2b.CommandResult{}, nil
+ }
+ started <- command
+ select {
+ case <-ctx.Done():
+ return e2b.CommandResult{}, ctx.Err()
+ case <-release:
+ return e2b.CommandResult{Stdout: command}, nil
+ }
+ }
+ manager := newTestTerminalManager(backend, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ type outcome struct {
+ result terminalExecRunResult
+ err error
+ }
+ outcomes := make(chan outcome, 2)
+ for _, command := range []string{"first", "second"} {
+ command := command
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: command,
+ SessionID: seed.SessionID,
+ })
+ outcomes <- outcome{result: result, err: err}
+ }()
+ }
+ seen := map[string]bool{}
+ for len(seen) < 2 {
+ select {
+ case command := <-started:
+ seen[command] = true
+ case <-time.After(time.Second):
+ t.Fatal("two commands were not concurrently in flight")
+ }
+ }
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "overflow",
+ SessionID: seed.SessionID,
+ }); terminalErrorCode(err) != terminalExecCodeSessionBusy {
+ t.Fatalf("expected third command to return session_busy, got %v", err)
+ }
+ close(release)
+ for range 2 {
+ outcome := <-outcomes
+ if outcome.err != nil {
+ t.Fatal(outcome.err)
+ }
+ if outcome.result.SessionID != seed.SessionID || !seen[outcome.result.Stdout] {
+ t.Fatalf("unexpected concurrent result: %#v", outcome.result)
+ }
+ }
+}
+
+func TestTerminalSessionJanitorAutomaticallyKillsExpiredSandbox(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ manager := newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: "terminal-template",
+ LeaseMinSec: 1,
+ LeaseMaxSec: 60,
+ LeaseDefaultSec: 1,
+ OutputLimitBytes: 1024,
+ SessionMaxInflight: 1,
+ JanitorInterval: 10 * time.Millisecond,
+ })
+ defer manager.Close()
+ created, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ manager.mu.Lock()
+ manager.sessions[created.SessionID].confirmedLeaseExpiresAt = time.Now().Add(-time.Second)
+ manager.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ return backend.killed == 1
+ }, "janitor did not kill expired E2B sandbox")
+ if manager.ActiveSessionCount() != 0 {
+ t.Fatalf("expired session still counted as active")
+ }
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "after-expiry",
+ SessionID: created.SessionID,
+ }); terminalErrorCode(err) != terminalExecCodeSessionNotFound {
+ t.Fatalf("expected session_not_found after janitor cleanup, got %v", err)
+ }
+}
+
+func TestTerminalSessionRejectsExpiredIdleSessionBeforeJanitorCleanup(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ manager := newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: "terminal-template",
+ LeaseMinSec: 1,
+ LeaseMaxSec: 60,
+ LeaseDefaultSec: 10,
+ OutputLimitBytes: 1024,
+ SessionMaxInflight: 1,
+ JanitorInterval: time.Hour,
+ })
+ defer manager.Close()
+
+ created, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ manager.mu.Lock()
+ session := manager.sessions[created.SessionID]
+ session.desiredLeaseExpiresAt = time.Now().Add(-time.Second)
+ session.confirmedLeaseExpiresAt = session.desiredLeaseExpiresAt
+ manager.mu.Unlock()
+
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "after-expiry",
+ SessionID: created.SessionID,
+ }); terminalErrorCode(err) != terminalExecCodeSessionNotFound {
+ t.Fatalf("expected session_not_found before janitor cleanup, got %v", err)
+ }
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ if backend.killed != 1 || len(backend.killedIDs) != 1 || backend.killedIDs[0] != "sandbox-1" {
+ t.Fatalf("expired sandbox was not cleaned up: killed=%d ids=%v", backend.killed, backend.killedIDs)
+ }
+}
+
+func TestTerminalSessionReplacesExpiredIdleSessionWhenRequested(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ manager := newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: "terminal-template",
+ LeaseMinSec: 1,
+ LeaseMaxSec: 60,
+ LeaseDefaultSec: 10,
+ OutputLimitBytes: 1024,
+ SessionMaxInflight: 1,
+ JanitorInterval: time.Hour,
+ })
+ defer manager.Close()
+
+ created, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ manager.mu.Lock()
+ session := manager.sessions[created.SessionID]
+ session.desiredLeaseExpiresAt = time.Now().Add(-time.Second)
+ session.confirmedLeaseExpiresAt = session.desiredLeaseExpiresAt
+ manager.mu.Unlock()
+
+ replacement, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "after-expiry",
+ SessionID: created.SessionID,
+ CreateIfMissing: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !replacement.Created || replacement.SessionID != created.SessionID {
+ t.Fatalf("unexpected replacement result: %#v", replacement)
+ }
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ if backend.created != 2 || backend.killed != 1 || len(backend.killedIDs) != 1 || backend.killedIDs[0] != "sandbox-1" {
+ t.Fatalf(
+ "expired sandbox was not replaced: created=%d killed=%d ids=%v",
+ backend.created,
+ backend.killed,
+ backend.killedIDs,
+ )
+ }
+}
+
+func TestTerminalManagerCloseWaitsForSandboxCreation(t *testing.T) {
+ t.Parallel()
+ createStarted := make(chan struct{})
+ createRelease := make(chan struct{})
+ backend := &fakeE2BBackend{}
+ backend.createFn = func(context.Context, string, int) (*e2b.Sandbox, error) {
+ close(createStarted)
+ <-createRelease
+ return &e2b.Sandbox{ID: "created-during-close"}, nil
+ }
+ manager := newTestTerminalManager(backend, 1)
+ execDone := make(chan error, 1)
+ go func() {
+ _, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ execDone <- err
+ }()
+ <-createStarted
+
+ closeDone := make(chan struct{})
+ go func() {
+ manager.Close()
+ close(closeDone)
+ }()
+ select {
+ case <-closeDone:
+ t.Fatal("manager closed before sandbox creation completed")
+ case <-time.After(30 * time.Millisecond):
+ }
+ close(createRelease)
+ select {
+ case <-closeDone:
+ case <-time.After(time.Second):
+ t.Fatal("manager did not close after sandbox creation completed")
+ }
+ <-execDone
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ if backend.killed != 1 || len(backend.killedIDs) != 1 || backend.killedIDs[0] != "created-during-close" {
+ t.Fatalf("created sandbox was not cleaned up: killed=%d ids=%v", backend.killed, backend.killedIDs)
+ }
+}
+
+func TestCommandDeadlineExtendsRemoteTimeoutWithoutExtendingLease(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ manager := newTestTerminalManager(backend, 1)
+ defer manager.Close()
+
+ lease := 2
+ startedAt := time.Now()
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+ result, err := manager.Execute(ctx, terminalExecRequest{
+ Command: "long-command",
+ LeaseTTLSec: &lease,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ backend.mu.Lock()
+ timeouts := append([]int(nil), backend.timeouts...)
+ backend.mu.Unlock()
+ if len(timeouts) != 1 || timeouts[0] < 19 {
+ t.Fatalf("command deadline did not protect the remote sandbox: %v", timeouts)
+ }
+ leaseDuration := time.UnixMilli(result.LeaseExpiresUnixMS).Sub(startedAt)
+ if leaseDuration < time.Second || leaseDuration > 4*time.Second {
+ t.Fatalf("command deadline changed the user lease: %s", leaseDuration)
+ }
+}
+
+func TestTerminalSessionCreationGateSharesOneSandbox(t *testing.T) {
+ t.Parallel()
+ createStarted := make(chan struct{})
+ createRelease := make(chan struct{})
+ var runMu sync.Mutex
+ runCount := 0
+ backend := &fakeE2BBackend{}
+ backend.createFn = func(ctx context.Context, _ string, _ int) (*e2b.Sandbox, error) {
+ close(createStarted)
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-createRelease:
+ return &e2b.Sandbox{ID: "shared-sandbox"}, nil
+ }
+ }
+ backend.runFn = func(context.Context, *e2b.Sandbox, string, int) (e2b.CommandResult, error) {
+ runMu.Lock()
+ runCount++
+ runMu.Unlock()
+ return e2b.CommandResult{}, nil
+ }
+ manager := newTestTerminalManager(backend, 2)
+ defer manager.Close()
+
+ errs := make(chan error, 2)
+ go func() {
+ _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "creator",
+ SessionID: "shared-session",
+ CreateIfMissing: true,
+ })
+ errs <- err
+ }()
+ <-createStarted
+ go func() {
+ _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "waiter",
+ SessionID: "shared-session",
+ CreateIfMissing: true,
+ })
+ errs <- err
+ }()
+ time.Sleep(30 * time.Millisecond)
+ runMu.Lock()
+ if runCount != 0 {
+ runMu.Unlock()
+ t.Fatalf("command ran before sandbox creation completed")
+ }
+ runMu.Unlock()
+ close(createRelease)
+ for range 2 {
+ if err := <-errs; err != nil {
+ t.Fatal(err)
+ }
+ }
+ runMu.Lock()
+ defer runMu.Unlock()
+ if runCount != 2 {
+ t.Fatalf("expected both commands to use the created sandbox, run_count=%d", runCount)
+ }
+}
+
+func TestTimedOutCommandDoesNotKillConcurrentSibling(t *testing.T) {
+ t.Parallel()
+ siblingStarted := make(chan struct{})
+ siblingRelease := make(chan struct{})
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ switch command {
+ case "seed":
+ return e2b.CommandResult{}, nil
+ case "sibling":
+ close(siblingStarted)
+ select {
+ case <-ctx.Done():
+ return e2b.CommandResult{}, ctx.Err()
+ case <-siblingRelease:
+ return e2b.CommandResult{Stdout: "sibling-ok"}, nil
+ }
+ case "timeout":
+ <-ctx.Done()
+ return e2b.CommandResult{}, ctx.Err()
+ default:
+ return e2b.CommandResult{Stdout: command}, nil
+ }
+ }
+ manager := newTestTerminalManager(backend, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ siblingDone := make(chan outcome, 1)
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "sibling",
+ SessionID: seed.SessionID,
+ })
+ siblingDone <- outcome{result: result, err: err}
+ }()
+ <-siblingStarted
+ timeoutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ if _, err := manager.Execute(timeoutCtx, terminalExecRequest{
+ Command: "timeout",
+ SessionID: seed.SessionID,
+ }); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected deadline exceeded, got %v", err)
+ }
+ backend.mu.Lock()
+ killedWhileSiblingRunning := backend.killed
+ backend.mu.Unlock()
+ if killedWhileSiblingRunning != 0 {
+ t.Fatalf("sandbox was killed while sibling command was running")
+ }
+ if _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "new-command",
+ SessionID: seed.SessionID,
+ }); terminalErrorCode(err) != terminalExecCodeSessionNotFound {
+ t.Fatalf("destroying session accepted a new command: %v", err)
+ }
+ close(siblingRelease)
+ outcome := <-siblingDone
+ if outcome.err != nil || outcome.result.Stdout != "sibling-ok" {
+ t.Fatalf("sibling did not finish successfully: %#v err=%v", outcome.result, outcome.err)
+ }
+ waitForCondition(t, time.Second, func() bool {
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ return backend.killed == 1
+ }, "sandbox was not killed after sibling drained")
+}
+
+func TestTimeoutDuringLeaseSyncAlsoWaitsForSiblingBeforeCleanup(t *testing.T) {
+ t.Parallel()
+ siblingStarted := make(chan struct{})
+ siblingRelease := make(chan struct{})
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ if command == "seed" {
+ return e2b.CommandResult{}, nil
+ }
+ close(siblingStarted)
+ select {
+ case <-ctx.Done():
+ return e2b.CommandResult{}, ctx.Err()
+ case <-siblingRelease:
+ return e2b.CommandResult{Stdout: "sibling-ok"}, nil
+ }
+ }
+ manager := newTestTerminalManager(backend, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ siblingDone := make(chan outcome, 1)
+ go func() {
+ result, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "sibling",
+ SessionID: seed.SessionID,
+ })
+ siblingDone <- outcome{result: result, err: err}
+ }()
+ <-siblingStarted
+ backend.timeoutFn = func(ctx context.Context, _ string, _ int) error {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ timeoutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ if _, err := manager.Execute(timeoutCtx, terminalExecRequest{
+ Command: "never-starts",
+ SessionID: seed.SessionID,
+ }); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected deadline exceeded during lease sync, got %v", err)
+ }
+ backend.mu.Lock()
+ killedBeforeDrain := backend.killed
+ backend.mu.Unlock()
+ if killedBeforeDrain != 0 {
+ t.Fatal("lease sync timeout killed sandbox before sibling drained")
+ }
+ close(siblingRelease)
+ sibling := <-siblingDone
+ if sibling.err != nil || sibling.result.Stdout != "sibling-ok" {
+ t.Fatalf("sibling was disrupted: %#v err=%v", sibling.result, sibling.err)
+ }
+ waitForCondition(t, time.Second, func() bool {
+ backend.mu.Lock()
+ defer backend.mu.Unlock()
+ return backend.killed == 1
+ }, "sandbox was not cleaned after sibling drained")
+}
+
+func TestTerminalResourceValidateReadAndExport(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(_ context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ if command == "seed" {
+ return e2b.CommandResult{}, nil
+ }
+ return e2b.CommandResult{
+ Stdout: `{"mime_type":"text/plain","size_bytes":5}`,
+ ExitCode: 0,
+ }, nil
+ }
+ backend.readFn = func(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error) {
+ return e2b.File{Content: []byte("hello"), MIMEType: "text/plain", Size: 5}, nil
+ }
+ backend.openFn = func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) {
+ return e2b.FileReader{
+ Body: io.NopCloser(bytes.NewReader([]byte("hello"))),
+ MIMEType: "text/plain",
+ Size: 5,
+ }, nil
+ }
+ manager := newTestTerminalManager(backend, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ validate, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/hello.txt",
+ Action: terminalResourceActionValidate,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if validate.MIMEType != "text/plain" || validate.SizeBytes != 5 || validate.Blob != nil {
+ t.Fatalf("unexpected validate result: %#v", validate)
+ }
+ read, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/hello.txt",
+ Action: terminalResourceActionRead,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(read.Blob) != "hello" || read.SizeBytes != 5 {
+ t.Fatalf("unexpected read result: %#v", read)
+ }
+
+ var uploaded []byte
+ var uploadHeader string
+ uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ uploaded, _ = io.ReadAll(req.Body)
+ uploadHeader = req.Header.Get("X-Test-Export")
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer uploadServer.Close()
+ exported, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/hello.txt",
+ Action: terminalResourceActionExport,
+ SignedURL: uploadServer.URL,
+ Headers: map[string]string{"X-Test-Export": "forwarded"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(uploaded) != "hello" || uploadHeader != "forwarded" {
+ t.Fatalf("unexpected export upload body=%q header=%q", uploaded, uploadHeader)
+ }
+ if exported.Blob != nil || exported.SizeBytes != 5 {
+ t.Fatalf("unexpected export result: %#v", exported)
+ }
+}
+
+func TestTerminalResourceSandboxExportUsesSandboxCommand(t *testing.T) {
+ t.Parallel()
+ var exportCommand string
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(_ context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ switch {
+ case command == "seed":
+ return e2b.CommandResult{}, nil
+ case strings.Contains(command, "mimetypes.guess_type"):
+ return e2b.CommandResult{Stdout: `{"mime_type":"text/plain","size_bytes":5}`}, nil
+ default:
+ exportCommand = command
+ return e2b.CommandResult{}, nil
+ }
+ }
+ backend.openFn = func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) {
+ t.Fatal("sandbox export must not open the file through the worker")
+ return e2b.FileReader{}, nil
+ }
+ manager := newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: "terminal-template",
+ LeaseMinSec: 1,
+ LeaseMaxSec: 60,
+ LeaseDefaultSec: 10,
+ OutputLimitBytes: 1024,
+ ExportMode: terminalExportModeSandbox,
+ SessionMaxInflight: 1,
+ })
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/hello.txt",
+ Action: terminalResourceActionExport,
+ SignedURL: "https://uploads.example.com/object?signature=secret",
+ Headers: map[string]string{"X-Test-Export": "forwarded"},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(exportCommand, "python3 -c ") {
+ t.Fatalf("expected sandbox Python upload command, got %q", exportCommand)
+ }
+}
+
+func TestWorkerExportEnforcesLimitOnUnknownLengthStream(t *testing.T) {
+ t.Parallel()
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(_ context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ if command == "seed" {
+ return e2b.CommandResult{}, nil
+ }
+ return e2b.CommandResult{Stdout: `{"mime_type":"text/plain","size_bytes":5}`}, nil
+ }
+ backend.openFn = func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) {
+ return e2b.FileReader{
+ Body: io.NopCloser(strings.NewReader("123456")),
+ MIMEType: "text/plain",
+ Size: -1,
+ }, nil
+ }
+ manager := newTerminalSessionManager(terminalSessionManagerConfig{
+ Backend: backend,
+ Template: "terminal-template",
+ LeaseMinSec: 1,
+ LeaseMaxSec: 60,
+ LeaseDefaultSec: 10,
+ OutputLimitBytes: 1024,
+ ExportMaxBytes: 5,
+ ExportMode: terminalExportModeWorker,
+ SessionMaxInflight: 1,
+ })
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ uploadedCh := make(chan []byte, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ uploaded, _ := io.ReadAll(req.Body)
+ uploadedCh <- uploaded
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ _, err = manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/growing.txt",
+ Action: terminalResourceActionExport,
+ SignedURL: server.URL,
+ })
+ if terminalErrorCode(err) != terminalResourceCodeFileTooLarge {
+ t.Fatalf("expected file_too_large, got %v", err)
+ }
+ var uploaded []byte
+ select {
+ case uploaded = <-uploadedCh:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for export upload")
+ }
+ if len(uploaded) > 5 {
+ t.Fatalf("export uploaded %d bytes past the configured limit", len(uploaded))
+ }
+}
+
+func TestSandboxExportCommandStreamsFile(t *testing.T) {
+ t.Parallel()
+ if _, err := exec.LookPath("python3"); err != nil {
+ t.Skip("python3 is required")
+ }
+ filePath := filepath.Join(t.TempDir(), "export.txt")
+ if err := os.WriteFile(filePath, []byte("sandbox-direct"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var uploaded []byte
+ var receivedHeader string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ uploaded, _ = io.ReadAll(req.Body)
+ receivedHeader = req.Header.Get("X-Test-Export")
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ command, err := buildSandboxExportCommand(
+ filePath,
+ server.URL+"/upload?signature=secret",
+ map[string]string{"X-Test-Export": "forwarded"},
+ 0,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ output, err := exec.Command("/bin/bash", "-l", "-c", command).CombinedOutput()
+ if err != nil {
+ t.Fatalf("sandbox export command failed: %v: %s", err, output)
+ }
+ if string(uploaded) != "sandbox-direct" || receivedHeader != "forwarded" {
+ t.Fatalf("unexpected upload body=%q header=%q", uploaded, receivedHeader)
+ }
+}
+
+func TestSandboxExportCommandEnforcesLimit(t *testing.T) {
+ t.Parallel()
+ if _, err := exec.LookPath("python3"); err != nil {
+ t.Skip("python3 is required")
+ }
+ filePath := filepath.Join(t.TempDir(), "export-too-large.txt")
+ if err := os.WriteFile(filePath, []byte("123456"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var uploaded []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ uploaded, _ = io.ReadAll(req.Body)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ command, err := buildSandboxExportCommand(filePath, server.URL, nil, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ output, err := exec.Command("/bin/bash", "-l", "-c", command).CombinedOutput()
+ if err == nil || !strings.Contains(string(output), terminalExportLimitMarker) {
+ t.Fatalf("expected sandbox export limit failure, err=%v output=%s", err, output)
+ }
+ if len(uploaded) > 5 {
+ t.Fatalf("sandbox export uploaded %d bytes past the configured limit", len(uploaded))
+ }
+}
+
+func TestTerminalResourceSharesSessionConcurrencyWithExec(t *testing.T) {
+ t.Parallel()
+ execStarted := make(chan struct{})
+ execRelease := make(chan struct{})
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ switch command {
+ case "seed":
+ return e2b.CommandResult{}, nil
+ case "block":
+ close(execStarted)
+ select {
+ case <-ctx.Done():
+ return e2b.CommandResult{}, ctx.Err()
+ case <-execRelease:
+ return e2b.CommandResult{Stdout: "done"}, nil
+ }
+ default:
+ return e2b.CommandResult{Stdout: `{"mime_type":"text/plain","size_bytes":2}`}, nil
+ }
+ }
+ manager := newTestTerminalManager(backend, 2)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ execDone := make(chan error, 1)
+ go func() {
+ _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "block",
+ SessionID: seed.SessionID,
+ })
+ execDone <- err
+ }()
+ <-execStarted
+ resource, err := manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/file.txt",
+ Action: terminalResourceActionRead,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(resource.Blob) != "ok" {
+ t.Fatalf("unexpected resource result: %#v", resource)
+ }
+ close(execRelease)
+ if err := <-execDone; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestTerminalResourceReturnsBusyWhenExecUsesOnlySlot(t *testing.T) {
+ t.Parallel()
+ execStarted := make(chan struct{})
+ execRelease := make(chan struct{})
+ backend := &fakeE2BBackend{}
+ backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) {
+ if command == "seed" {
+ return e2b.CommandResult{}, nil
+ }
+ close(execStarted)
+ select {
+ case <-ctx.Done():
+ return e2b.CommandResult{}, ctx.Err()
+ case <-execRelease:
+ return e2b.CommandResult{}, nil
+ }
+ }
+ manager := newTestTerminalManager(backend, 1)
+ defer manager.Close()
+ seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ execDone := make(chan error, 1)
+ go func() {
+ _, err := manager.Execute(context.Background(), terminalExecRequest{
+ Command: "block",
+ SessionID: seed.SessionID,
+ })
+ execDone <- err
+ }()
+ <-execStarted
+ _, err = manager.ResolveResource(context.Background(), terminalResourceRequest{
+ SessionID: seed.SessionID,
+ FilePath: "/tmp/file.txt",
+ Action: terminalResourceActionValidate,
+ })
+ if terminalErrorCode(err) != terminalExecCodeSessionBusy {
+ t.Fatalf("expected resource to share session_busy limit, got %v", err)
+ }
+ close(execRelease)
+ if err := <-execDone; err != nil {
+ t.Fatal(err)
+ }
+}
+
+type outcome struct {
+ result terminalExecRunResult
+ err error
+}
+
+func terminalErrorCode(err error) string {
+ var terminalErr *terminalExecError
+ if errors.As(err, &terminalErr) {
+ return terminalErr.Code()
+ }
+ return ""
+}
+
+func waitForCondition(t *testing.T, timeout time.Duration, predicate func() bool, message string) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if predicate() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal(message)
+}