diff --git a/.gitignore b/.gitignore index 24bc2a6..c977b7f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,10 @@ go.work.sum # Python artifacts *.pyc +__pycache__/ +*.pyo +.venv/ +*.egg-info/ # Frontend artifacts (web) web/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 5c9dd4e..facf19d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Onlyboxes 是一个面向个人与小型团队的代码执行沙箱平台解决 - `worker-docker`:以 Docker 容器为执行后端 - `worker-boxlite`:以 boxlite 为执行后端 - `worker-sys`:以操作系统进程作为执行后端,用于直接控制真实设备 + - `worker-bridge-e2b`:以 e2b 为执行后端,worker 仅实现了一层 bridge。使用 uv 作为包管理器 - **前端**:于`web`目录下,Vue, TypeScript, Vite, Pinia, Tailwind CSS。 diff --git a/README.md b/README.md index 67acec9..57ffb68 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Default endpoints: ### 5) Run worker > [!WARNING] -> Workers support different runtimes and environments. The current release only provides `worker-docker`. This section uses the Docker runtime as an example. +> Workers support different runtimes and environments. Available worker implementations include `worker-docker`, `worker-boxlite`, `worker-sys`, and `worker-bridge-e2b`. This section uses the Docker runtime as an example. 1. Log in to the machine where the worker will be deployed. - Ensure Docker Engine is installed. @@ -182,7 +182,9 @@ Default endpoints: | `CONSOLE_DASHBOARD_USERNAME` | _(empty)_ | Used only for first admin initialization | | `CONSOLE_DASHBOARD_PASSWORD` | _(empty)_ | Used only for first admin initialization | -### Worker (`worker-docker`) +### Worker + +Different worker implementations have different configuration options. Below is a summary of common options for `worker-docker`: | Environment Variable | Default | Notes | | --- | --- | --- | @@ -196,6 +198,8 @@ Default endpoints: | `WORKER_TERMINAL_EXEC_DOCKER_IMAGE` | `coolfan1024/onlyboxes-runtime:default` | Runtime image for `terminalExec` | | `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | `1048576` | Per-stream output limit | +For other worker implementations (`worker-boxlite`, `worker-sys`, `worker-bridge-e2b`), see their respective README files in the `worker/` directory. + ## API Surfaces - Dashboard auth: `/api/v1/console/*` @@ -226,7 +230,11 @@ Web dev URL defaults to `http://127.0.0.1:5178` and proxies `/api/*` to `http:// - Unified API reference: `README/API.md` - Console internals: `console/README/overview.md` -- Worker internals: `worker/worker-docker/README/overview.md` +- Worker internals: + - `worker/worker-docker/README/overview.md` + - `worker/worker-boxlite/README/overview.md` + - `worker/worker-sys/README/overview.md` + - `worker/worker-bridge-e2b/README.md` - API/proto guide: `api/README/proto.md` - Web app guide: `web/README.md` diff --git a/README.zh-CN.md b/README.zh-CN.md index 39e3be9..9d0b66b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -120,7 +120,7 @@ curl -fsSL https://onlybox.es/install.sh | bash ### 5)启动 worker > [!WARNING] -> worker 支持不同运行时与运行环境,当前版本仅提供 `worker-docker`。本小节以此 docker 运行时为例。 +> worker 支持不同运行时与运行环境,可用的 worker 实现包括 `worker-docker`、`worker-boxlite`、`worker-sys` 和 `worker-bridge-e2b`。本小节以 docker 运行时为例。 1. 登陆到需要部署 worker 的机器。 - 确保 Docker Engine 已安装。 @@ -182,7 +182,9 @@ curl -fsSL https://onlybox.es/install.sh | bash | `CONSOLE_DASHBOARD_USERNAME` | _(空)_ | 仅首次初始化管理员账号时生效 | | `CONSOLE_DASHBOARD_PASSWORD` | _(空)_ | 仅首次初始化管理员账号时生效 | -### Worker(`worker-docker`) +### Worker + +不同 worker 实现有不同的配置选项。以下是 `worker-docker` 的常用选项摘要: | 环境变量 | 默认值 | 说明 | | --- | --- | --- | @@ -196,6 +198,8 @@ curl -fsSL https://onlybox.es/install.sh | bash | `WORKER_TERMINAL_EXEC_DOCKER_IMAGE` | `coolfan1024/onlyboxes-runtime:default` | `terminalExec` 运行镜像 | | `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | `1048576` | 单路输出流字节上限 | +其他 worker 实现(`worker-boxlite`、`worker-sys`、`worker-bridge-e2b`)的配置请参考 `worker/` 目录下各自的 README 文件。 + ## API 面 - 控制台认证:`/api/v1/console/*` @@ -226,7 +230,11 @@ yarn --cwd web dev - 统一 API 文档:`README/API.zh-CN.md` - Console 细节:`console/README/overview.md` -- Worker 细节:`worker/worker-docker/README/overview.md` +- Worker 细节: + - `worker/worker-docker/README/overview.md` + - `worker/worker-boxlite/README/overview.md` + - `worker/worker-sys/README/overview.md` + - `worker/worker-bridge-e2b/README.md` - API/proto 说明:`api/README/proto.md` - Web 说明:`web/README.md` diff --git a/README/API.md b/README/API.md index 9b050f3..a76fa19 100644 --- a/README/API.md +++ b/README/API.md @@ -322,7 +322,7 @@ Always returns `410 Gone`: Worker types: -- `normal` (maps to `worker-docker`) +- `normal` (shared sandbox workers; currently implemented by `worker-docker`, `worker-boxlite`, or `worker-bridge-e2b`) - `worker-sys` (maps to `worker-sys`) Permission matrix: diff --git a/README/API.zh-CN.md b/README/API.zh-CN.md index eab9867..c426aff 100644 --- a/README/API.zh-CN.md +++ b/README/API.zh-CN.md @@ -324,7 +324,7 @@ Token 按账号隔离;每个账号只能管理自己的 token。 Worker 类型: -- `normal`(对应 `worker-docker`) +- `normal`(共享沙箱 worker;当前可由 `worker-docker`、`worker-boxlite` 或 `worker-bridge-e2b` 实现) - `worker-sys`(对应 `worker-sys`) 权限矩阵: diff --git a/console/README/overview.md b/console/README/overview.md index 2fb61cc..a856ea7 100644 --- a/console/README/overview.md +++ b/console/README/overview.md @@ -16,7 +16,7 @@ The console service hosts: - `GET /api/v1/workers/:node_id/startup-command` always returns `410 Gone`. - `worker_secret` is returned once in `POST /api/v1/workers` response and is not queryable from read APIs. - worker types: - - `normal` (maps to `worker-docker`) + - `normal` (shared sandbox worker, implemented by `worker-docker`, `worker-boxlite`, or `worker-bridge-e2b`) - `worker-sys` (maps to host-shell worker) - worker create/delete visibility rules: - admin: list/stats/inflight/delete all workers; create `normal` and `worker-sys` diff --git a/website/src/docs/en/api-mcp-overview.mdx b/website/src/docs/en/api-mcp-overview.mdx index 68fcd43..d9ee87c 100644 --- a/website/src/docs/en/api-mcp-overview.mdx +++ b/website/src/docs/en/api-mcp-overview.mdx @@ -479,7 +479,7 @@ API Keys are for programmatic access to most management endpoints. Unlike execut | Delete any worker | Yes | No | | Delete own `worker-sys` | Yes | Yes | -Worker types: `normal` (backed by `worker-docker`/`worker-boxlite`), `worker-sys` (backed by `worker-sys`). +Worker types: `normal` (shared sandbox workers backed by `worker-docker`, `worker-boxlite`, or `worker-bridge-e2b`), `worker-sys` (backed by `worker-sys`). --- diff --git a/website/src/docs/en/architecture.mdx b/website/src/docs/en/architecture.mdx index e2375bf..760073c 100644 --- a/website/src/docs/en/architecture.mdx +++ b/website/src/docs/en/architecture.mdx @@ -38,6 +38,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 | E2B cloud sandboxes | Cloud-hosted 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..43f8b4c 100644 --- a/website/src/docs/en/overview.mdx +++ b/website/src/docs/en/overview.mdx @@ -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 that bridges to e2b cloud 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-boxlite.mdx b/website/src/docs/en/worker-boxlite.mdx index 3c19df1..fecf280 100644 --- a/website/src/docs/en/worker-boxlite.mdx +++ b/website/src/docs/en/worker-boxlite.mdx @@ -113,3 +113,4 @@ The default terminal image is a general execution image with Python 3, Node.js 2 - [Architecture](/en/docs/architecture) - [Docker Runtime](/en/docs/worker-docker) - [System Process Runtime](/en/docs/worker-sys) +- [E2B Bridge Runtime](/en/docs/worker-bridge-e2b) 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..d5c7a9d --- /dev/null +++ b/website/src/docs/en/worker-bridge-e2b.mdx @@ -0,0 +1,134 @@ +export const meta = { + title: 'E2B Bridge', + description: 'Run executions through e2b cloud sandboxes with worker-bridge-e2b.', + section: 'Runtimes', + order: 48, + slug: 'worker-bridge-e2b', +} + +# E2B Bridge Runtime + +`worker-bridge-e2b` bridges Onlyboxes to the [E2B](https://e2b.dev/) cloud sandbox platform. It connects to the console via gRPC and exposes E2B sandbox services as a standard `normal` worker with capabilities aligned to `worker-docker`. + +## When to use + +- You accept using public cloud infrastructure +- You need rapid scaling without local resource constraints + +## How it works + +1. The worker connects to the control node over gRPC. +2. Translates requests from the control node into E2B specifications +3. Sends requests to the E2B platform and receives responses +4. Output is streamed back through the control plane. + +## Prerequisites + +- An E2B account with API key +- Network access to the control node gRPC endpoint + +## Starting the worker + +`worker-bridge-e2b` is implemented in Python and requires `uv` for dependency management. Clone the repository and set up the worker: + +```bash +cd worker/worker-bridge-e2b +uv sync +bash scripts/gen_proto.sh # regenerate protobuf stubs if needed +``` + +Create a `normal` worker credential in the console, then start the worker with the returned `WORKER_ID` and `WORKER_SECRET`: + +```bash +WORKER_CONSOLE_GRPC_TARGET=127.0.0.1:50051 \ +WORKER_CONSOLE_INSECURE=true \ +WORKER_ID= \ +WORKER_SECRET= \ +E2B_API_KEY= \ +E2B_PYTHON_EXEC_TEMPLATE= \ +E2B_TERMINAL_EXEC_TEMPLATE= \ +uv run worker-bridge-e2b +``` + +Set `WORKER_CONSOLE_INSECURE=true` when connecting without TLS. + +## Environment variables + +### Required + +| Variable | Description | +| --- | --- | +| `WORKER_ID` | Worker node ID from the dashboard | +| `WORKER_SECRET` | One-time secret from worker creation | +| `E2B_API_KEY` | Your e2b API key | + +### Connection + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_CONSOLE_GRPC_TARGET` | No | `127.0.0.1:50051` | Control node gRPC address | +| `WORKER_CONSOLE_INSECURE` | No | `false` | Set `true` to allow insecure connections (non-TLS) | +| `WORKER_CALL_TIMEOUT_SEC` | No | `ceil(2.5 * heartbeat)` | gRPC connect/heartbeat ack timeout | + +### Identity + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_NODE_NAME` | No | | Human-readable node name | +| `WORKER_VERSION` | No | `dev` | Version reported to console | +| `WORKER_LABELS` | No | | CSV labels, e.g. `region=us,cloud=e2b` | + +### E2B sandbox + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `E2B_PYTHON_EXEC_TEMPLATE` | No | `coolfan1024/python-exec` | Sandbox template for pythonExec | +| `E2B_TERMINAL_EXEC_TEMPLATE` | No | `coolfan1024/terminal-exec` | Sandbox template for terminalExec | +| `E2B_SANDBOX_TIMEOUT_SEC` | No | `300` | Sandbox lifetime in seconds | + +### Concurrency + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_ECHO_MAX_INFLIGHT` | No | `4` | Max concurrent echo commands | +| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | No | `4` | Max concurrent pythonExec commands | +| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | No | `4` | Max concurrent terminalExec commands | +| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | No | `4` | Max concurrent terminalResource commands | + +### Terminal session leases + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_TERMINAL_LEASE_MIN_SEC` | No | `60` | Minimum lease duration (seconds) | +| `WORKER_TERMINAL_LEASE_MAX_SEC` | No | `1800` | Maximum lease duration (seconds) | +| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | No | `60` | Default lease duration (seconds) | +| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | No | `1048576` | Output limit per stream (bytes) | +| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | No | `0` (unlimited) | Maximum export file size in bytes | + +### Heartbeat + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_HEARTBEAT_INTERVAL_SEC` | No | `5` | Heartbeat interval (seconds) | +| `WORKER_HEARTBEAT_JITTER_PCT` | No | `20` | Jitter percentage (0–100) | + +### Logging + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_LOG_LEVEL` | No | `info` | `debug` / `info` / `warn` / `error` | +| `WORKER_LOG_FORMAT` | No | `json` | `json` / `text` | + +## Cost considerations + +Unlike self-hosted workers, E2B is a public cloud service that charges based on sandbox usage. + +Always monitor your [E2B dashboard](https://e2b.dev/dashboard) to track costs. + +## Related docs + +- [Architecture](/en/docs/architecture) +- [Quick Start](/en/docs/quick-start) +- [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/en/worker-docker.mdx b/website/src/docs/en/worker-docker.mdx index ea907c2..dade75a 100644 --- a/website/src/docs/en/worker-docker.mdx +++ b/website/src/docs/en/worker-docker.mdx @@ -125,3 +125,4 @@ The default terminal image is a general execution image with Python 3, Node.js 2 - [Quick Start](/en/docs/quick-start) - [Boxlite Runtime](/en/docs/worker-boxlite) - [System Process Runtime](/en/docs/worker-sys) +- [E2B Bridge Runtime](/en/docs/worker-bridge-e2b) diff --git a/website/src/docs/en/worker-sys.mdx b/website/src/docs/en/worker-sys.mdx index 0b97917..015c9c4 100644 --- a/website/src/docs/en/worker-sys.mdx +++ b/website/src/docs/en/worker-sys.mdx @@ -112,3 +112,4 @@ $env:WORKER_SECRET = "" - [Architecture](/en/docs/architecture) - [Docker Runtime](/en/docs/worker-docker) - [Boxlite Runtime](/en/docs/worker-boxlite) +- [E2B Bridge Runtime](/en/docs/worker-bridge-e2b) diff --git a/website/src/docs/zh-CN/api-mcp-overview.mdx b/website/src/docs/zh-CN/api-mcp-overview.mdx index 73e7f7a..9a1c7a0 100644 --- a/website/src/docs/zh-CN/api-mcp-overview.mdx +++ b/website/src/docs/zh-CN/api-mcp-overview.mdx @@ -479,7 +479,7 @@ API Key 用于程序化访问大多数管理端点(与执行 Token 不同,** | 删除任意 worker | 是 | 否 | | 删除本人 `worker-sys` | 是 | 是 | -Worker 类型:`normal`(对应 `worker-docker`/`worker-boxlite`)、`worker-sys`(对应 `worker-sys`)。 +Worker 类型:`normal`(共享沙箱 worker,可由 `worker-docker`、`worker-boxlite` 或 `worker-bridge-e2b` 实现)、`worker-sys`(对应 `worker-sys`)。 --- diff --git a/website/src/docs/zh-CN/architecture.mdx b/website/src/docs/zh-CN/architecture.mdx index 9d13500..af03651 100644 --- a/website/src/docs/zh-CN/architecture.mdx +++ b/website/src/docs/zh-CN/architecture.mdx @@ -38,6 +38,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..d8f6181 100644 --- a/website/src/docs/zh-CN/overview.mdx +++ b/website/src/docs/zh-CN/overview.mdx @@ -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-boxlite.mdx b/website/src/docs/zh-CN/worker-boxlite.mdx index 6b00aa1..56ae7e3 100644 --- a/website/src/docs/zh-CN/worker-boxlite.mdx +++ b/website/src/docs/zh-CN/worker-boxlite.mdx @@ -113,3 +113,4 @@ WORKER_SECRET= \ - [架构](/zh-CN/docs/architecture) - [Docker 运行时](/zh-CN/docs/worker-docker) - [系统进程运行时](/zh-CN/docs/worker-sys) +- [E2B 桥接运行时](/zh-CN/docs/worker-bridge-e2b) 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..eba7cf6 --- /dev/null +++ b/website/src/docs/zh-CN/worker-bridge-e2b.mdx @@ -0,0 +1,134 @@ +export const meta = { + title: 'E2B 桥接', + description: '通过 worker-bridge-e2b 使用 e2b 云沙箱执行任务。', + section: '运行时', + order: 48, + slug: 'worker-bridge-e2b', +} + +# E2B 桥接运行时 + +`worker-bridge-e2b` 将 Onlyboxes 桥接到 [E2B](https://e2b.dev/) 云沙箱平台。它通过 gRPC 连接控制节点,将 E2B 沙箱服务暴露为标准 `normal` worker,能力对齐 `worker-docker`。 + +## 适用场景 + +- 接受使用公有云基础设施 +- 需要快速扩展而不受本地资源限制 + +## 工作原理 + +1. Worker 通过 gRPC 连接控制节点。 +2. 将来自控制节点的请求转换为 E2B 规范 +3. 将请求发送到 E2B 平台并接收返回 +4. 输出通过控制面流式返回。 + +## 前置条件 + +- 具有 API key 的 E2B 账号 +- 能够访问控制节点 gRPC 端口 + +## 启动 worker + +`worker-bridge-e2b` 使用 Python 实现,需要 `uv` 进行依赖管理。克隆仓库并设置 worker: + +```bash +cd worker/worker-bridge-e2b +uv sync +bash scripts/gen_proto.sh # 如需要可重新生成 protobuf stubs +``` + +在控制台创建 `normal` worker 凭据,然后使用返回的 `WORKER_ID` 和 `WORKER_SECRET` 启动 worker: + +```bash +WORKER_CONSOLE_GRPC_TARGET=127.0.0.1:50051 \ +WORKER_CONSOLE_INSECURE=true \ +WORKER_ID= \ +WORKER_SECRET= \ +E2B_API_KEY= \ +E2B_PYTHON_EXEC_TEMPLATE= \ +E2B_TERMINAL_EXEC_TEMPLATE= \ +uv run worker-bridge-e2b +``` + +通过非 TLS 连接时请设置 `WORKER_CONSOLE_INSECURE=true`。 + +## 环境变量 + +### 必填 + +| 变量 | 说明 | +| --- | --- | +| `WORKER_ID` | Dashboard 中的 Worker 节点 ID | +| `WORKER_SECRET` | 创建 worker 时的一次性密钥 | +| `E2B_API_KEY` | 你的 e2b API 密钥 | + +### 连接 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_CONSOLE_GRPC_TARGET` | 否 | `127.0.0.1:50051` | 控制节点 gRPC 地址 | +| `WORKER_CONSOLE_INSECURE` | 否 | `false` | 设为 `true` 允许不安全的连接(非 TLS) | +| `WORKER_CALL_TIMEOUT_SEC` | 否 | `ceil(2.5 * heartbeat)` | gRPC 连接 / heartbeat ack 超时时间 | + +### 身份 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_NODE_NAME` | 否 | | 可读的节点名称 | +| `WORKER_VERSION` | 否 | `dev` | 上报给控制节点的版本号 | +| `WORKER_LABELS` | 否 | | CSV 格式标签,如 `region=us,cloud=e2b` | + +### E2B 沙箱 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `E2B_PYTHON_EXEC_TEMPLATE` | 否 | `coolfan1024/python-exec` | pythonExec 沙箱模板 | +| `E2B_TERMINAL_EXEC_TEMPLATE` | 否 | `coolfan1024/terminal-exec` | terminalExec 沙箱模板 | +| `E2B_SANDBOX_TIMEOUT_SEC` | 否 | `300` | E2B 自动释放沙箱的超时时间(秒) | + +### 并发上限 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_ECHO_MAX_INFLIGHT` | 否 | `4` | echo 最大并发数 | +| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | 否 | `4` | pythonExec 最大并发数 | +| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | 否 | `4` | terminalExec 最大并发数 | +| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | 否 | `4` | terminalResource 最大并发数 | + +### 终端会话租约 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_TERMINAL_LEASE_MIN_SEC` | 否 | `60` | 最短租约时长(秒) | +| `WORKER_TERMINAL_LEASE_MAX_SEC` | 否 | `1800` | 最长租约时长(秒) | +| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | 否 | `60` | 默认租约时长(秒) | +| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | 否 | `1048576` | 每个流的输出上限(字节) | +| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | 否 | `0`(无限制) | export 文件大小上限(字节) | + +### 心跳 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_HEARTBEAT_INTERVAL_SEC` | 否 | `5` | 心跳间隔(秒) | +| `WORKER_HEARTBEAT_JITTER_PCT` | 否 | `20` | 抖动百分比(0–100) | + +### 日志 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_LOG_LEVEL` | 否 | `info` | `debug` / `info` / `warn` / `error` | +| `WORKER_LOG_FORMAT` | 否 | `json` | `json` / `text` | + +## 成本考量 + +与自托管 worker 不同,E2B 作为公有云服务根据沙箱的使用情况收费。 + +时刻监控你的 [E2B dashboard](https://e2b.dev/dashboard) 以追踪成本。 + +## 相关文档 + +- [架构](/zh-CN/docs/architecture) +- [快速开始](/zh-CN/docs/quick-start) +- [Docker 运行时](/zh-CN/docs/worker-docker) +- [Boxlite 运行时](/zh-CN/docs/worker-boxlite) +- [系统进程运行时](/zh-CN/docs/worker-sys) diff --git a/website/src/docs/zh-CN/worker-docker.mdx b/website/src/docs/zh-CN/worker-docker.mdx index 814a2ce..de49bd1 100644 --- a/website/src/docs/zh-CN/worker-docker.mdx +++ b/website/src/docs/zh-CN/worker-docker.mdx @@ -125,3 +125,4 @@ WORKER_SECRET= \ - [快速开始](/zh-CN/docs/quick-start) - [Boxlite 运行时](/zh-CN/docs/worker-boxlite) - [系统进程运行时](/zh-CN/docs/worker-sys) +- [E2B 桥接运行时](/zh-CN/docs/worker-bridge-e2b) diff --git a/website/src/docs/zh-CN/worker-sys.mdx b/website/src/docs/zh-CN/worker-sys.mdx index 315da5e..78519e4 100644 --- a/website/src/docs/zh-CN/worker-sys.mdx +++ b/website/src/docs/zh-CN/worker-sys.mdx @@ -112,3 +112,4 @@ $env:WORKER_SECRET = "" - [架构](/zh-CN/docs/architecture) - [Docker 运行时](/zh-CN/docs/worker-docker) - [Boxlite 运行时](/zh-CN/docs/worker-boxlite) +- [E2B 桥接运行时](/zh-CN/docs/worker-bridge-e2b) diff --git a/website/src/features/docs/__tests__/docs-router.spec.tsx b/website/src/features/docs/__tests__/docs-router.spec.tsx index 890774f..be7fc80 100644 --- a/website/src/features/docs/__tests__/docs-router.spec.tsx +++ b/website/src/features/docs/__tests__/docs-router.spec.tsx @@ -51,6 +51,7 @@ describe('docs routing', () => { 'worker-docker', 'worker-boxlite', 'worker-sys', + 'worker-bridge-e2b', 'console-api', 'mcp-tools', 'security-faq', diff --git a/worker/worker-bridge-e2b/README.md b/worker/worker-bridge-e2b/README.md new file mode 100644 index 0000000..c5445cf --- /dev/null +++ b/worker/worker-bridge-e2b/README.md @@ -0,0 +1,113 @@ +worker-bridge-e2b +================= + +Onlyboxes worker bridge for e2b sandboxes. + +Connects to the Onlyboxes console via gRPC and exposes e2b sandboxes +as a standard worker supporting echo, pythonExec, terminalExec, and +terminalResource. + +Setup +----- + + uv sync # requires Python 3.12+ + bash scripts/gen_proto.sh # regenerate protobuf stubs + +Run +--- + + WORKER_CONSOLE_GRPC_TARGET=127.0.0.1:50051 \ + WORKER_CONSOLE_INSECURE=true \ + WORKER_ID= \ + WORKER_SECRET= \ + E2B_API_KEY= \ + E2B_PYTHON_EXEC_TEMPLATE= \ + E2B_TERMINAL_EXEC_TEMPLATE= \ + uv run worker-bridge-e2b + +Capabilities +------------ + +### echo + +Returns the input message unchanged. + +### pythonExec + +Runs Python code in a one-shot e2b sandbox. Each invocation creates a +fresh sandbox, writes the code to `/tmp/code.py`, and executes it via +`uv run /tmp/code.py` (supports PEP 723 inline dependencies). The +sandbox is destroyed after execution. + +Sandbox template is configured via `E2B_PYTHON_EXEC_TEMPLATE`. The +template should have `uv` pre-installed (e.g. built from +`ghcr.io/astral-sh/uv:python3.12-bookworm-slim`). + +### terminalExec + +Runs shell commands in persistent e2b sandbox sessions with lease-based +TTL. Sessions are created on first use and reused for subsequent commands +sharing the same `session_id`. Idle sessions are automatically cleaned up +by a janitor thread when their lease expires. + +Sandbox template is configured via `E2B_TERMINAL_EXEC_TEMPLATE`. This +can be a general-purpose template with the tools your terminal sessions +need. + +### terminalResource + +Validates, reads, or exports files from an existing terminalExec session. +`read` returns base64 content inline. `export` uploads the file to the +provided signed URL and forwards filtered upload headers from the console. + +Environment Variables +--------------------- + +### Required + + WORKER_ID worker node ID + WORKER_SECRET worker secret + E2B_API_KEY e2b API key + +### Connection + + WORKER_CONSOLE_GRPC_TARGET console gRPC address (default: 127.0.0.1:50051) + WORKER_CONSOLE_INSECURE set "true" to disable TLS (default: TLS enabled) + WORKER_CALL_TIMEOUT_SEC gRPC call/heartbeat ack timeout (default: ceil(2.5 * heartbeat)) + +### Worker identity + + WORKER_NODE_NAME display name + WORKER_VERSION version string (default: dev) + WORKER_LABELS comma-separated key=value labels + +### e2b sandbox + + E2B_PYTHON_EXEC_TEMPLATE sandbox template for pythonExec (default: coolfan1024/python-exec) + E2B_TERMINAL_EXEC_TEMPLATE sandbox template for terminalExec (default: coolfan1024/terminal-exec) + E2B_SANDBOX_TIMEOUT_SEC sandbox lifetime in seconds (default: 300) + +### Concurrency + + WORKER_ECHO_MAX_INFLIGHT (default: 4) + WORKER_PYTHON_EXEC_MAX_INFLIGHT (default: 4) + WORKER_TERMINAL_EXEC_MAX_INFLIGHT (default: 4) + WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT (default: 4) + +### Terminal session leases + + WORKER_TERMINAL_LEASE_MIN_SEC (default: 60) + WORKER_TERMINAL_LEASE_MAX_SEC (default: 1800) + WORKER_TERMINAL_LEASE_DEFAULT_SEC (default: 60) + WORKER_TERMINAL_OUTPUT_LIMIT_BYTES (default: 1048576) + WORKER_TERMINAL_EXPORT_MAX_BYTES (default: 0, unlimited) + +### Logging + + WORKER_LOG_LEVEL debug|info|warn|error (default: info) + WORKER_LOG_FORMAT json|text (default: json) + +### Heartbeat + + WORKER_HEARTBEAT_INTERVAL_SEC (default: 5) + WORKER_HEARTBEAT_JITTER_PCT (default: 20) diff --git a/worker/worker-bridge-e2b/pyproject.toml b/worker/worker-bridge-e2b/pyproject.toml new file mode 100644 index 0000000..f32f876 --- /dev/null +++ b/worker/worker-bridge-e2b/pyproject.toml @@ -0,0 +1,71 @@ +[project] +name = "worker-bridge-e2b" +version = "0.1.0" +description = "Onlyboxes worker bridge for e2b sandboxes" +requires-python = ">=3.12" +dependencies = [ + "e2b>=2.19.0", + "e2b-code-interpreter>=2.6.0", + "grpcio>=1.80.0", + "grpcio-tools>=1.80.0", + "protobuf>=6.31.1", + "python-dotenv>=1.2.2", + "structlog>=24.0.0", +] + +[project.scripts] +worker-bridge-e2b = "worker_bridge_e2b.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/worker_bridge_e2b"] + +[tool.hatch.build.targets.editable] +packages = ["src/worker_bridge_e2b"] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.8.0", + "ty>=0.0.28", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["**/proto/**/*_pb2.py", "**/proto/**/*_pb2_grpc.py"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["worker_bridge_e2b"] + +[tool.ty.src] +include = ["src"] +exclude = ["src/worker_bridge_e2b/proto/**"] + +[tool.ty.analysis] +allowed-unresolved-imports = ["grpc", "worker_bridge_e2b.proto"] + +[tool.ty.rules] +unresolved-attribute = "ignore" diff --git a/worker/worker-bridge-e2b/scripts/gen_proto.sh b/worker/worker-bridge-e2b/scripts/gen_proto.sh new file mode 100755 index 0000000..5679914 --- /dev/null +++ b/worker/worker-bridge-e2b/scripts/gen_proto.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regenerate protobuf stubs from the shared proto definition. +# Run from the worker-bridge-e2b directory: bash scripts/gen_proto.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)" +PROTO_SRC="$REPO_ROOT/api/proto" +OUT_DIR="$SCRIPT_DIR/../src/worker_bridge_e2b/proto" + +mkdir -p "$OUT_DIR" + +uv run python -m grpc_tools.protoc \ + -I "$PROTO_SRC" \ + --python_out="$OUT_DIR" \ + --grpc_python_out="$OUT_DIR" \ + "registry/v1/registry.proto" + +# fix relative import generated by grpc_tools +sed -i.bak 's/^from registry\.v1 import/from worker_bridge_e2b.proto.registry.v1 import/' \ + "$OUT_DIR/registry/v1/registry_pb2_grpc.py" +rm -f "$OUT_DIR/registry/v1/registry_pb2_grpc.py.bak" + +# ensure __init__.py files exist +touch "$OUT_DIR/__init__.py" +touch "$OUT_DIR/registry/__init__.py" +touch "$OUT_DIR/registry/v1/__init__.py" + +echo "Done: $OUT_DIR/registry/v1/" diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/__init__.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/config.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/config.py new file mode 100644 index 0000000..6a2d040 --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/config.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +DEFAULT_CONSOLE_TARGET = "127.0.0.1:50051" +DEFAULT_HEARTBEAT_INTERVAL_SEC = 5 +DEFAULT_HEARTBEAT_JITTER_PCT = 20 +DEFAULT_MAX_INFLIGHT = 4 +DEFAULT_LOG_LEVEL = "info" +DEFAULT_LOG_FORMAT = "json" +DEFAULT_EXECUTOR_KIND = "e2b" + + +@dataclass +class Config: + console_grpc_target: str + console_tls: bool + worker_id: str + worker_secret: str + heartbeat_interval_sec: int + heartbeat_jitter_pct: int + call_timeout_sec: int + node_name: str + executor_kind: str + version: str + labels: dict[str, str] + e2b_api_key: str + e2b_python_exec_template: str + e2b_terminal_exec_template: str + e2b_sandbox_timeout_sec: int + echo_max_inflight: int + python_exec_max_inflight: int + terminal_exec_max_inflight: int + terminal_resource_max_inflight: int + terminal_lease_min_sec: int + terminal_lease_max_sec: int + terminal_lease_default_sec: int + terminal_output_limit_bytes: int + terminal_export_max_bytes: int + log_level: str + log_format: str + + @classmethod + def load(cls) -> Config: + heartbeat_interval_sec = _parse_positive_int( + "WORKER_HEARTBEAT_INTERVAL_SEC", DEFAULT_HEARTBEAT_INTERVAL_SEC + ) + cfg = cls( + console_grpc_target=_get_env("WORKER_CONSOLE_GRPC_TARGET", DEFAULT_CONSOLE_TARGET), + console_tls=os.environ.get("WORKER_CONSOLE_INSECURE", "") != "true", + worker_id=os.environ.get("WORKER_ID", "").strip(), + worker_secret=os.environ.get("WORKER_SECRET", "").strip(), + heartbeat_interval_sec=heartbeat_interval_sec, + heartbeat_jitter_pct=_parse_percent( + "WORKER_HEARTBEAT_JITTER_PCT", DEFAULT_HEARTBEAT_JITTER_PCT + ), + call_timeout_sec=_parse_positive_int( + "WORKER_CALL_TIMEOUT_SEC", _default_call_timeout_sec(heartbeat_interval_sec) + ), + node_name=os.environ.get("WORKER_NODE_NAME", "").strip(), + executor_kind=DEFAULT_EXECUTOR_KIND, + version=_get_env("WORKER_VERSION", "dev"), + labels=_parse_labels(os.environ.get("WORKER_LABELS", "")), + e2b_api_key=os.environ.get("E2B_API_KEY", "").strip(), + e2b_python_exec_template=_get_env( + "E2B_PYTHON_EXEC_TEMPLATE", "coolfan1024/python-exec" + ), + e2b_terminal_exec_template=_get_env( + "E2B_TERMINAL_EXEC_TEMPLATE", "coolfan1024/terminal-exec" + ), + e2b_sandbox_timeout_sec=_parse_positive_int("E2B_SANDBOX_TIMEOUT_SEC", 300), + echo_max_inflight=_parse_positive_int("WORKER_ECHO_MAX_INFLIGHT", DEFAULT_MAX_INFLIGHT), + python_exec_max_inflight=_parse_positive_int( + "WORKER_PYTHON_EXEC_MAX_INFLIGHT", DEFAULT_MAX_INFLIGHT + ), + terminal_exec_max_inflight=_parse_positive_int( + "WORKER_TERMINAL_EXEC_MAX_INFLIGHT", DEFAULT_MAX_INFLIGHT + ), + terminal_resource_max_inflight=_parse_positive_int( + "WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT", DEFAULT_MAX_INFLIGHT + ), + terminal_lease_min_sec=_parse_positive_int("WORKER_TERMINAL_LEASE_MIN_SEC", 60), + terminal_lease_max_sec=_parse_positive_int("WORKER_TERMINAL_LEASE_MAX_SEC", 1800), + terminal_lease_default_sec=_parse_positive_int("WORKER_TERMINAL_LEASE_DEFAULT_SEC", 60), + terminal_output_limit_bytes=_parse_positive_int( + "WORKER_TERMINAL_OUTPUT_LIMIT_BYTES", 1048576 + ), + terminal_export_max_bytes=_parse_non_negative_int( + "WORKER_TERMINAL_EXPORT_MAX_BYTES", 0 + ), + log_level=_parse_log_level("WORKER_LOG_LEVEL", DEFAULT_LOG_LEVEL), + log_format=_parse_log_format("WORKER_LOG_FORMAT", DEFAULT_LOG_FORMAT), + ) + # Clamp lease values: max >= min, default in [min, max] + if cfg.terminal_lease_max_sec < cfg.terminal_lease_min_sec: + cfg.terminal_lease_max_sec = cfg.terminal_lease_min_sec + if cfg.terminal_lease_default_sec < cfg.terminal_lease_min_sec: + cfg.terminal_lease_default_sec = cfg.terminal_lease_min_sec + if cfg.terminal_lease_default_sec > cfg.terminal_lease_max_sec: + cfg.terminal_lease_default_sec = cfg.terminal_lease_max_sec + return cfg + + +def _get_env(key: str, default: str) -> str: + value = os.environ.get(key, "").strip() + return value if value else default + + +def _parse_positive_int(key: str, default: int) -> int: + try: + value = int(os.environ.get(key, "")) + if value > 0: + return value + except (ValueError, TypeError): + pass + return default + + +def _parse_non_negative_int(key: str, default: int) -> int: + try: + value = int(os.environ.get(key, "")) + if value >= 0: + return value + except (ValueError, TypeError): + pass + return default + + +def _parse_percent(key: str, default: int) -> int: + try: + value = int(os.environ.get(key, "")) + if 0 <= value <= 100: + return value + except (ValueError, TypeError): + pass + return default + + +def _parse_log_level(key: str, default: str) -> str: + value = os.environ.get(key, "").strip().lower() + if value in ("debug", "info", "warn", "error"): + return value + return default + + +def _parse_log_format(key: str, default: str) -> str: + value = os.environ.get(key, "").strip().lower() + if value in ("json", "text"): + return value + return default + + +def _parse_labels(raw: str) -> dict[str, str]: + labels = {} + for part in raw.split(","): + entry = part.strip() + if not entry or "=" not in entry: + continue + key, _, value = entry.partition("=") + key = key.strip() + if key: + labels[key] = value.strip() + return labels + + +def _default_call_timeout_sec(heartbeat_interval_sec: int) -> int: + heartbeat = ( + heartbeat_interval_sec if heartbeat_interval_sec > 0 else DEFAULT_HEARTBEAT_INTERVAL_SEC + ) + return (heartbeat * 5 + 1) // 2 diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/executor.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/executor.py new file mode 100644 index 0000000..c363c83 --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/executor.py @@ -0,0 +1,251 @@ +"""e2b capability executors. + +Each function receives the raw payload_json bytes from CommandDispatch +and returns (result_json_bytes, error_code, error_message). +error_code/error_message are empty strings on success. +""" + +from __future__ import annotations + +import contextlib +import json +import time +from collections.abc import Mapping +from typing import TYPE_CHECKING + +import structlog +from e2b import Sandbox, SandboxException + +from worker_bridge_e2b.session_manager import TerminalExecError, TerminalSessionManager + +if TYPE_CHECKING: + from worker_bridge_e2b.config import Config + +logger = structlog.get_logger() + +type ExecutorResult = tuple[bytes, str, str] + +_config: Config | None = None +_session_manager: TerminalSessionManager | None = None + + +def init(cfg: Config) -> None: + global _config, _session_manager + _config = cfg + _session_manager = TerminalSessionManager( + e2b_template=cfg.e2b_terminal_exec_template, + e2b_timeout_sec=cfg.e2b_sandbox_timeout_sec, + lease_min_sec=cfg.terminal_lease_min_sec, + lease_max_sec=cfg.terminal_lease_max_sec, + lease_default_sec=cfg.terminal_lease_default_sec, + output_limit_bytes=cfg.terminal_output_limit_bytes, + export_max_bytes=cfg.terminal_export_max_bytes, + ) + logger.info("executor initialized") + + +def shutdown() -> None: + global _session_manager + if _session_manager is not None: + _session_manager.shutdown() + _session_manager = None + logger.info("executor shut down") + + +def active_session_count() -> int: + if _session_manager is None: + return 0 + return _session_manager.active_session_count() + + +def execute_echo(payload: bytes, deadline_unix_ms: int) -> ExecutorResult: + if _deadline_exceeded(deadline_unix_ms): + return b"{}", "deadline_exceeded", "command deadline exceeded" + + data, err_code, err_message = _decode_object_payload(payload, "echo") + if err_code: + return b"{}", err_code, err_message + + message = data.get("message", "") + if not isinstance(message, str) or not message.strip(): + return b"{}", "invalid_payload", "echo payload is required" + result = json.dumps({"message": message}).encode() + return result, "", "" + + +def execute_python_exec(payload: bytes, deadline_unix_ms: int) -> ExecutorResult: + data, err_code, err_message = _decode_object_payload(payload, "pythonExec") + if err_code: + return b"{}", err_code, err_message + + code = data.get("code") + if not isinstance(code, str) or not code.strip(): + return b"{}", "invalid_payload", "pythonExec code is required" + + if _deadline_exceeded(deadline_unix_ms): + return b"{}", "deadline_exceeded", "command deadline exceeded" + + # Compute command timeout from deadline + if deadline_unix_ms > 0: + cmd_timeout = max(1.0, (deadline_unix_ms - time.time() * 1000) / 1000.0) + else: + cmd_timeout = float(_config.e2b_sandbox_timeout_sec) if _config else 300.0 + + sandbox: Sandbox | None = None + try: + sandbox = Sandbox.create( + template=_config.e2b_python_exec_template if _config else "base", + timeout=_config.e2b_sandbox_timeout_sec if _config else 300, + ) + sandbox.files.write("/tmp/code.py", code) + result = sandbox.commands.run("uv run /tmp/code.py", timeout=cmd_timeout) + + result_json = json.dumps( + { + "output": result.stdout or "", + "stderr": result.stderr or "", + "exit_code": result.exit_code, + } + ).encode() + return result_json, "", "" + + except SandboxException as exc: + return b"{}", "execution_failed", f"pythonExec execution failed: {exc}" + except Exception as exc: + if "timeout" in type(exc).__name__.lower() or "timeout" in str(exc).lower(): + return b"{}", "deadline_exceeded", "command deadline exceeded" + return b"{}", "execution_failed", f"pythonExec execution failed: {exc}" + finally: + if sandbox is not None: + with contextlib.suppress(Exception): + sandbox.kill() + + +def execute_terminal_exec(payload: bytes, deadline_unix_ms: int) -> ExecutorResult: + if _session_manager is None: + return b"{}", "execution_failed", "terminal executor is unavailable" + + if _deadline_exceeded(deadline_unix_ms): + return b"{}", "deadline_exceeded", "command deadline exceeded" + + data, err_code, err_message = _decode_object_payload(payload, "terminalExec") + if err_code: + return b"{}", err_code, err_message + + command = data.get("command", "") + if not isinstance(command, str) or not command.strip(): + return b"{}", "invalid_payload", "terminalExec command is required" + + session_id = data.get("session_id", "") + if session_id is None: + session_id = "" + if not isinstance(session_id, str): + return b"{}", "invalid_payload", "terminalExec session_id must be a string" + + create_if_missing = data.get("create_if_missing", False) + if not isinstance(create_if_missing, bool): + return b"{}", "invalid_payload", "terminalExec create_if_missing must be a boolean" + + lease_ttl_sec = data.get("lease_ttl_sec") + if lease_ttl_sec is not None and ( + not isinstance(lease_ttl_sec, int) or isinstance(lease_ttl_sec, bool) + ): + return b"{}", "invalid_payload", "terminalExec lease_ttl_sec must be an integer" + + try: + result = _session_manager.execute( + command=command, + session_id=session_id, + create_if_missing=create_if_missing, + lease_ttl_sec=lease_ttl_sec, + deadline_unix_ms=deadline_unix_ms, + ) + return json.dumps(result).encode(), "", "" + except TerminalExecError as exc: + return b"{}", exc.code, exc.message + except Exception as exc: + return b"{}", "execution_failed", f"terminalExec execution failed: {exc}" + + +def execute_terminal_resource(payload: bytes, deadline_unix_ms: int) -> ExecutorResult: + if _session_manager is None: + return b"{}", "execution_failed", "terminal executor is unavailable" + + if _deadline_exceeded(deadline_unix_ms): + return b"{}", "deadline_exceeded", "command deadline exceeded" + + data, err_code, err_message = _decode_object_payload(payload, "terminalResource") + if err_code: + return b"{}", err_code, err_message + + session_id = data.get("session_id", "") + file_path = data.get("file_path", "") + action = data.get("action", "") + signed_url = data.get("signed_url", "") + headers, err_code, err_message = _decode_headers(data.get("headers")) + if err_code: + return b"{}", err_code, err_message + + if not isinstance(session_id, str) or not isinstance(file_path, str): + return b"{}", "invalid_payload", "terminalResource session_id and file_path must be strings" + if not isinstance(action, str): + return b"{}", "invalid_payload", "terminalResource action must be a string" + if not isinstance(signed_url, str): + return b"{}", "invalid_payload", "terminalResource signed_url must be a string" + + session_id = session_id.strip() + file_path = file_path.strip() + action = action.strip() + signed_url = signed_url.strip() + + if not session_id or not file_path: + return b"{}", "invalid_payload", "terminalResource session_id and file_path are required" + if action.lower() == "export" and not signed_url: + return b"{}", "invalid_payload", "terminalResource signed_url is required for export" + + try: + result = _session_manager.resolve_resource( + session_id=session_id, + file_path=file_path, + action=action, + signed_url=signed_url, + headers=headers, + deadline_unix_ms=deadline_unix_ms, + ) + return json.dumps(result).encode(), "", "" + except TerminalExecError as exc: + return b"{}", exc.code, exc.message + except Exception as exc: + return b"{}", "execution_failed", f"terminalResource execution failed: {exc}" + + +def _deadline_exceeded(deadline_unix_ms: int) -> bool: + return deadline_unix_ms > 0 and time.time() * 1000 > deadline_unix_ms + + +def _decode_object_payload(payload: bytes, capability: str) -> tuple[dict, str, str]: + if len(payload) == 0: + return {}, "invalid_payload", f"{capability} payload is required" + try: + data = json.loads(payload) + except json.JSONDecodeError: + return {}, "invalid_payload", f"payload_json is not valid {capability} payload" + if not isinstance(data, dict): + return {}, "invalid_payload", f"payload_json is not valid {capability} payload" + return data, "", "" + + +def _decode_headers(raw: object) -> tuple[dict[str, str], str, str]: + if raw is None: + return {}, "", "" + if not isinstance(raw, Mapping): + return {}, "invalid_payload", "terminalResource headers must be an object" + + headers: dict[str, str] = {} + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, str): + return {}, "invalid_payload", "terminalResource headers must contain string values" + trimmed_key = key.strip() + if trimmed_key: + headers[trimmed_key] = value + return headers, "", "" diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/logging.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/logging.py new file mode 100644 index 0000000..3f9b8e9 --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/logging.py @@ -0,0 +1,27 @@ +import logging + +import structlog + + +def configure(level: str, fmt: str) -> None: + log_level = getattr(logging, level.upper(), logging.INFO) + + processors = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + ] + + if fmt == "json": + processors.append(structlog.processors.JSONRenderer()) # ty: ignore[invalid-argument-type] + else: + processors.append(structlog.dev.ConsoleRenderer()) # ty: ignore[invalid-argument-type] + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger(log_level), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + ) + + logging.basicConfig(level=log_level) diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/main.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/main.py new file mode 100644 index 0000000..ec2eeb7 --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/main.py @@ -0,0 +1,53 @@ +import asyncio +import signal +import sys + +import structlog + +from worker_bridge_e2b import logging as wlogging +from worker_bridge_e2b.config import Config +from worker_bridge_e2b.runner import run + +logger = structlog.get_logger() + + +def main() -> None: + cfg = Config.load() + wlogging.configure(cfg.log_level, cfg.log_format) + + if not cfg.worker_id: + logger.error("WORKER_ID is required") + sys.exit(1) + if not cfg.worker_secret: + logger.error("WORKER_SECRET is required") + sys.exit(1) + if not cfg.e2b_api_key: + logger.error("E2B_API_KEY is required") + sys.exit(1) + + logger.info( + "worker-bridge-e2b starting", + node_id=cfg.worker_id, + console=cfg.console_grpc_target, + tls=cfg.console_tls, + version=cfg.version, + python_exec_template=cfg.e2b_python_exec_template, + terminal_exec_template=cfg.e2b_terminal_exec_template, + ) + + asyncio.run(_run_async(cfg)) + + +async def _run_async(cfg: Config) -> None: + stop_event = asyncio.Event() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) + + await run(cfg, stop_event) + logger.info("worker stopped") + + +if __name__ == "__main__": + main() diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/__init__.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/__init__.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/__init__.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/registry_pb2.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/registry_pb2.py new file mode 100644 index 0000000..3f4ed79 --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/registry_pb2.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: registry/v1/registry.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'registry/v1/registry.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1aregistry/v1/registry.proto\x12\x15onlyboxes.registry.v1\";\n\x15\x43\x61pabilityDeclaration\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0cmax_inflight\x18\x02 \x01(\x05\"\xa5\x02\n\x0c\x43onnectHello\x12\x0f\n\x07node_id\x18\x01 \x01(\t\x12\x11\n\tnode_name\x18\x02 \x01(\t\x12\x15\n\rexecutor_kind\x18\x03 \x01(\t\x12?\n\x06labels\x18\x05 \x03(\x0b\x32/.onlyboxes.registry.v1.ConnectHello.LabelsEntry\x12\x0f\n\x07version\x18\x06 \x01(\t\x12\x42\n\x0c\x63\x61pabilities\x18\n \x03(\x0b\x32,.onlyboxes.registry.v1.CapabilityDeclaration\x12\x15\n\rworker_secret\x18\x0b \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"S\n\x0eHeartbeatFrame\x12\x0f\n\x07node_id\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x1c\n\x14\x61\x63tive_session_count\x18\x03 \x01(\x05\"\xcd\x01\n\x0e\x43onnectRequest\x12\x34\n\x05hello\x18\x01 \x01(\x0b\x32#.onlyboxes.registry.v1.ConnectHelloH\x00\x12:\n\theartbeat\x18\x02 \x01(\x0b\x32%.onlyboxes.registry.v1.HeartbeatFrameH\x00\x12>\n\x0e\x63ommand_result\x18\x03 \x01(\x0b\x32$.onlyboxes.registry.v1.CommandResultH\x00\x42\t\n\x07payload\"@\n\nConnectAck\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1e\n\x16heartbeat_interval_sec\x18\x03 \x01(\x05\".\n\x0cHeartbeatAck\x12\x1e\n\x16heartbeat_interval_sec\x18\x02 \x01(\x05\"i\n\x0f\x43ommandDispatch\x12\x12\n\ncommand_id\x18\x01 \x01(\t\x12\x12\n\ncapability\x18\x02 \x01(\t\x12\x14\n\x0cpayload_json\x18\x04 \x01(\x0c\x12\x18\n\x10\x64\x65\x61\x64line_unix_ms\x18\x05 \x01(\x03\"-\n\x0c\x43ommandError\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x88\x01\n\rCommandResult\x12\x12\n\ncommand_id\x18\x01 \x01(\t\x12\x32\n\x05\x65rror\x18\x03 \x01(\x0b\x32#.onlyboxes.registry.v1.CommandError\x12\x14\n\x0cpayload_json\x18\x04 \x01(\x0c\x12\x19\n\x11\x63ompleted_unix_ms\x18\x05 \x01(\x03\"\xd8\x01\n\x0f\x43onnectResponse\x12\x38\n\x0b\x63onnect_ack\x18\x01 \x01(\x0b\x32!.onlyboxes.registry.v1.ConnectAckH\x00\x12<\n\rheartbeat_ack\x18\x02 \x01(\x0b\x32#.onlyboxes.registry.v1.HeartbeatAckH\x00\x12\x42\n\x10\x63ommand_dispatch\x18\x04 \x01(\x0b\x32&.onlyboxes.registry.v1.CommandDispatchH\x00\x42\t\n\x07payload2u\n\x15WorkerRegistryService\x12\\\n\x07\x43onnect\x12%.onlyboxes.registry.v1.ConnectRequest\x1a&.onlyboxes.registry.v1.ConnectResponse(\x01\x30\x01\x42\x42Z@github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1;registryv1b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'registry.v1.registry_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'Z@github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1;registryv1' + _globals['_CONNECTHELLO_LABELSENTRY']._loaded_options = None + _globals['_CONNECTHELLO_LABELSENTRY']._serialized_options = b'8\001' + _globals['_CAPABILITYDECLARATION']._serialized_start=53 + _globals['_CAPABILITYDECLARATION']._serialized_end=112 + _globals['_CONNECTHELLO']._serialized_start=115 + _globals['_CONNECTHELLO']._serialized_end=408 + _globals['_CONNECTHELLO_LABELSENTRY']._serialized_start=363 + _globals['_CONNECTHELLO_LABELSENTRY']._serialized_end=408 + _globals['_HEARTBEATFRAME']._serialized_start=410 + _globals['_HEARTBEATFRAME']._serialized_end=493 + _globals['_CONNECTREQUEST']._serialized_start=496 + _globals['_CONNECTREQUEST']._serialized_end=701 + _globals['_CONNECTACK']._serialized_start=703 + _globals['_CONNECTACK']._serialized_end=767 + _globals['_HEARTBEATACK']._serialized_start=769 + _globals['_HEARTBEATACK']._serialized_end=815 + _globals['_COMMANDDISPATCH']._serialized_start=817 + _globals['_COMMANDDISPATCH']._serialized_end=922 + _globals['_COMMANDERROR']._serialized_start=924 + _globals['_COMMANDERROR']._serialized_end=969 + _globals['_COMMANDRESULT']._serialized_start=972 + _globals['_COMMANDRESULT']._serialized_end=1108 + _globals['_CONNECTRESPONSE']._serialized_start=1111 + _globals['_CONNECTRESPONSE']._serialized_end=1327 + _globals['_WORKERREGISTRYSERVICE']._serialized_start=1329 + _globals['_WORKERREGISTRYSERVICE']._serialized_end=1446 +# @@protoc_insertion_point(module_scope) diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/registry_pb2_grpc.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/registry_pb2_grpc.py new file mode 100644 index 0000000..b2add9d --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/proto/registry/v1/registry_pb2_grpc.py @@ -0,0 +1,97 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from worker_bridge_e2b.proto.registry.v1 import registry_pb2 as registry_dot_v1_dot_registry__pb2 + +GRPC_GENERATED_VERSION = '1.80.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in registry/v1/registry_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class WorkerRegistryServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Connect = channel.stream_stream( + '/onlyboxes.registry.v1.WorkerRegistryService/Connect', + request_serializer=registry_dot_v1_dot_registry__pb2.ConnectRequest.SerializeToString, + response_deserializer=registry_dot_v1_dot_registry__pb2.ConnectResponse.FromString, + _registered_method=True) + + +class WorkerRegistryServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Connect(self, request_iterator, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_WorkerRegistryServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Connect': grpc.stream_stream_rpc_method_handler( + servicer.Connect, + request_deserializer=registry_dot_v1_dot_registry__pb2.ConnectRequest.FromString, + response_serializer=registry_dot_v1_dot_registry__pb2.ConnectResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'onlyboxes.registry.v1.WorkerRegistryService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('onlyboxes.registry.v1.WorkerRegistryService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class WorkerRegistryService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Connect(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/onlyboxes.registry.v1.WorkerRegistryService/Connect', + registry_dot_v1_dot_registry__pb2.ConnectRequest.SerializeToString, + registry_dot_v1_dot_registry__pb2.ConnectResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/runner.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/runner.py new file mode 100644 index 0000000..22fe6a9 --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/runner.py @@ -0,0 +1,338 @@ +"""gRPC session loop: connect -> hello -> heartbeat + dispatch handling.""" + +from __future__ import annotations + +import asyncio +import contextlib +import random +import time +from typing import TYPE_CHECKING + +import grpc +import structlog + +from worker_bridge_e2b import executor +from worker_bridge_e2b.proto.registry.v1 import registry_pb2 as pb +from worker_bridge_e2b.proto.registry.v1 import registry_pb2_grpc as pb_grpc + +if TYPE_CHECKING: + from worker_bridge_e2b.config import Config + +logger = structlog.get_logger() + +INITIAL_RECONNECT_DELAY_SEC = 1.0 +MAX_RECONNECT_DELAY_SEC = 15.0 +REQUEST_QUEUE_SIZE = 64 +HEARTBEAT_ACK_QUEUE_SIZE = 16 + +ECHO_CAPABILITY = "echo" +PYTHON_EXEC_CAPABILITY = "pythonexec" +TERMINAL_EXEC_CAPABILITY = "terminalexec" +TERMINAL_RESOURCE_CAPABILITY = "terminalresource" + + +class SessionEndedError(Exception): + """Reconnectable registry session failure.""" + + +async def run(cfg: Config, stop_event: asyncio.Event) -> None: + """Main loop: reconnect on failure until stop_event is set.""" + executor.init(cfg) + try: + reconnect_delay = INITIAL_RECONNECT_DELAY_SEC + while not stop_event.is_set(): + try: + await _run_session(cfg, stop_event) + return + except asyncio.CancelledError: + return + except grpc.RpcError as exc: + if exc.code() == grpc.StatusCode.FAILED_PRECONDITION: + logger.warning("registry session replaced, reconnecting immediately") + reconnect_delay = INITIAL_RECONNECT_DELAY_SEC + else: + logger.warning("registry session interrupted", error=str(exc)) + except Exception as exc: + logger.warning("registry session error", error=str(exc)) + + try: + jitter = random.uniform(0, reconnect_delay * 0.2) + await asyncio.wait_for(stop_event.wait(), timeout=reconnect_delay + jitter) + return + except TimeoutError: + pass + reconnect_delay = min(reconnect_delay * 2, MAX_RECONNECT_DELAY_SEC) + finally: + executor.shutdown() + + +async def _run_session(cfg: Config, stop_event: asyncio.Event) -> None: + async with _open_channel(cfg) as channel: + stub = pb_grpc.WorkerRegistryServiceStub(channel) + + request_queue: asyncio.Queue[pb.ConnectRequest] = asyncio.Queue(maxsize=REQUEST_QUEUE_SIZE) + heartbeat_ack_queue: asyncio.Queue[pb.HeartbeatAck] = asyncio.Queue( + maxsize=HEARTBEAT_ACK_QUEUE_SIZE + ) + dispatch_tasks: set[asyncio.Task] = set() + + async def request_iter(): + yield _build_hello(cfg) + while not stop_event.is_set(): + req = await request_queue.get() + yield req + + stream = stub.Connect(request_iter()) + response_iter = stream.__aiter__() + + first = await _read_response(response_iter, cfg.call_timeout_sec, "connect_ack") + if first.WhichOneof("payload") != "connect_ack": + raise SessionEndedError("unexpected first response frame") + session_id = first.connect_ack.session_id.strip() + if not session_id: + raise SessionEndedError("connect_ack.session_id is required") + + heartbeat_interval_sec = _duration_from_server( + first.connect_ack.heartbeat_interval_sec, cfg.heartbeat_interval_sec + ) + logger.info("connected", session_id=session_id) + + heartbeat_task = asyncio.create_task( + _heartbeat_loop( + cfg, + request_queue, + heartbeat_ack_queue, + stop_event, + session_id, + heartbeat_interval_sec, + ) + ) + response_task: asyncio.Task | None = None + try: + while not stop_event.is_set(): + response_task = asyncio.create_task(anext(response_iter)) + done, _ = await asyncio.wait( + {response_task, heartbeat_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if heartbeat_task in done: + if stop_event.is_set(): + return + await heartbeat_task + raise SessionEndedError("heartbeat loop stopped") + + try: + response = response_task.result() + except StopAsyncIteration as exc: + raise SessionEndedError("registry stream closed") from exc + + which = response.WhichOneof("payload") + if which == "heartbeat_ack": + _enqueue_heartbeat_ack(heartbeat_ack_queue, response.heartbeat_ack) + elif which == "command_dispatch": + task = asyncio.create_task( + _handle_dispatch(response.command_dispatch, request_queue, cfg) + ) + dispatch_tasks.add(task) + task.add_done_callback(dispatch_tasks.discard) + task.add_done_callback(_log_task_exception) + else: + raise SessionEndedError("unexpected response frame") + finally: + with contextlib.suppress(Exception): + stream.cancel() + heartbeat_task.cancel() + if response_task is not None and not response_task.done(): + response_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await response_task + for task in dispatch_tasks: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat_task + if dispatch_tasks: + await asyncio.gather(*dispatch_tasks, return_exceptions=True) + + +def _open_channel(cfg: Config): + if cfg.console_tls: + return grpc.aio.secure_channel(cfg.console_grpc_target, grpc.ssl_channel_credentials()) + return grpc.aio.insecure_channel(cfg.console_grpc_target) + + +async def _read_response(response_iter, timeout_sec: int, expected: str) -> pb.ConnectResponse: + try: + return await asyncio.wait_for(anext(response_iter), timeout=timeout_sec) + except StopAsyncIteration as exc: + raise SessionEndedError(f"stream closed before {expected}") from exc + except TimeoutError as exc: + raise SessionEndedError(f"receive {expected} timed out") from exc + + +async def _heartbeat_loop( + cfg: Config, + queue: asyncio.Queue, + heartbeat_ack_queue: asyncio.Queue, + stop_event: asyncio.Event, + session_id: str, + heartbeat_interval_sec: int, +) -> None: + interval_sec = heartbeat_interval_sec + consecutive_ack_timeouts = 0 + + while not stop_event.is_set(): + try: + await asyncio.wait_for( + stop_event.wait(), + timeout=_jitter_duration_sec(interval_sec, cfg.heartbeat_jitter_pct), + ) + return + except TimeoutError: + pass + + hb = pb.ConnectRequest( + heartbeat=pb.HeartbeatFrame( + node_id=cfg.worker_id, + session_id=session_id, + active_session_count=executor.active_session_count(), + ) + ) + await _enqueue_request(queue, hb, cfg.call_timeout_sec) + + try: + ack = await asyncio.wait_for(heartbeat_ack_queue.get(), timeout=cfg.call_timeout_sec) + except TimeoutError as exc: + consecutive_ack_timeouts += 1 + if consecutive_ack_timeouts >= 2: + raise SessionEndedError("heartbeat ack deadline exceeded") from exc + continue + + consecutive_ack_timeouts = 0 + interval_sec = _duration_from_server(ack.heartbeat_interval_sec, interval_sec) + + +async def _handle_dispatch( + dispatch: pb.CommandDispatch, + queue: asyncio.Queue, + cfg: Config, +) -> None: + capability = dispatch.capability.lower() + log = logger.bind(command_id=dispatch.command_id, capability=capability) + + try: + loop = asyncio.get_event_loop() + if capability == ECHO_CAPABILITY: + result_payload, err_code, err_msg = await loop.run_in_executor( + None, executor.execute_echo, dispatch.payload_json, dispatch.deadline_unix_ms + ) + elif capability == PYTHON_EXEC_CAPABILITY: + result_payload, err_code, err_msg = await loop.run_in_executor( + None, executor.execute_python_exec, dispatch.payload_json, dispatch.deadline_unix_ms + ) + elif capability == TERMINAL_EXEC_CAPABILITY: + result_payload, err_code, err_msg = await loop.run_in_executor( + None, + executor.execute_terminal_exec, + dispatch.payload_json, + dispatch.deadline_unix_ms, + ) + elif capability == TERMINAL_RESOURCE_CAPABILITY: + result_payload, err_code, err_msg = await loop.run_in_executor( + None, + executor.execute_terminal_resource, + dispatch.payload_json, + dispatch.deadline_unix_ms, + ) + else: + err_code = "unsupported_capability" + err_msg = f"capability not supported: {capability}" + result_payload = b"{}" + log.warning("unsupported capability") + except NotImplementedError: + err_code = "not_implemented" + err_msg = f"capability {capability} is not yet implemented" + result_payload = b"{}" + log.warning("capability not implemented") + except Exception as exc: + err_code = "execution_failed" + err_msg = str(exc) + result_payload = b"{}" + log.error("dispatch execution failed", error=err_msg) + + cmd_error = pb.CommandError(code=err_code, message=err_msg) if err_code else None + result = pb.ConnectRequest( + command_result=pb.CommandResult( + command_id=dispatch.command_id, + error=cmd_error, + payload_json=result_payload, + completed_unix_ms=int(time.time() * 1000), + ) + ) + await _enqueue_request(queue, result, cfg.call_timeout_sec) + log.info("command result sent") + + +def _build_hello(cfg: Config) -> pb.ConnectRequest: + capabilities = [ + pb.CapabilityDeclaration(name="echo", max_inflight=cfg.echo_max_inflight), + pb.CapabilityDeclaration(name="pythonExec", max_inflight=cfg.python_exec_max_inflight), + pb.CapabilityDeclaration(name="terminalExec", max_inflight=cfg.terminal_exec_max_inflight), + pb.CapabilityDeclaration( + name="terminalResource", max_inflight=cfg.terminal_resource_max_inflight + ), + ] + hello = pb.ConnectHello( + node_id=cfg.worker_id, + node_name=cfg.node_name, + executor_kind=cfg.executor_kind, + version=cfg.version, + capabilities=capabilities, + worker_secret=cfg.worker_secret, + ) + hello.labels.update(cfg.labels) + return pb.ConnectRequest(hello=hello) + + +async def _enqueue_request( + queue: asyncio.Queue, + request: pb.ConnectRequest, + timeout_sec: int, +) -> None: + await asyncio.wait_for(queue.put(request), timeout=timeout_sec) + + +def _enqueue_heartbeat_ack(queue: asyncio.Queue, ack: pb.HeartbeatAck) -> None: + try: + queue.put_nowait(ack) + return + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() + queue.put_nowait(ack) + + +def _duration_from_server(seconds: int, fallback: int) -> int: + if seconds > 0: + return seconds + if fallback > 0: + return fallback + return 5 + + +def _jitter_duration_sec(base_sec: int, jitter_pct: int) -> float: + base = float(base_sec if base_sec > 0 else 5) + pct = max(0, min(jitter_pct, 100)) + if pct == 0: + return base + min_value = max(1.0, base * (100 - pct) / 100) + max_value = max(min_value, base * (100 + pct) / 100) + return random.uniform(min_value, max_value) + + +def _log_task_exception(task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.error("dispatch task failed", error=str(exc)) diff --git a/worker/worker-bridge-e2b/src/worker_bridge_e2b/session_manager.py b/worker/worker-bridge-e2b/src/worker_bridge_e2b/session_manager.py new file mode 100644 index 0000000..61704bb --- /dev/null +++ b/worker/worker-bridge-e2b/src/worker_bridge_e2b/session_manager.py @@ -0,0 +1,709 @@ +"""Terminal session manager for e2b sandboxes. + +Manages persistent e2b Sandbox instances for the terminalExec capability, +with lease-based TTL and a janitor thread for cleanup. +""" + +import base64 +import json +import shlex +import threading +import time +import urllib.error +import urllib.request +import uuid +from collections.abc import Mapping + +import structlog +from e2b import ( + FileNotFoundException, + Sandbox, + SandboxException, + SandboxNotFoundException, + TimeoutException, +) + +logger = structlog.get_logger() + +JANITOR_INTERVAL_SEC = 5.0 +TERMINAL_RESOURCE_ACTION_VALIDATE = "validate" +TERMINAL_RESOURCE_ACTION_READ = "read" +TERMINAL_RESOURCE_ACTION_EXPORT = "export" +TERMINAL_RESOURCE_CODE_FILE_NOT_FOUND = "file_not_found" +TERMINAL_RESOURCE_CODE_PATH_IS_DIR = "path_is_directory" +TERMINAL_RESOURCE_CODE_FILE_TOO_LARGE = "file_too_large" +TERMINAL_RESOURCE_PROBE_SCRIPT = """ +import argparse +import json +import mimetypes +import os +import sys + +parser = argparse.ArgumentParser() +parser.add_argument("--file-path", required=True) +args = parser.parse_args() + +target = args.file_path +if not os.path.exists(target): + print(json.dumps({"error": "file_not_found", "message": "file not found"})) + sys.exit(10) +if os.path.isdir(target): + print(json.dumps({"error": "path_is_directory", "message": "path is directory"})) + sys.exit(11) + +size_bytes = os.path.getsize(target) +mime_type, _ = mimetypes.guess_type(target) +if not mime_type: + mime_type = "application/octet-stream" + +print(json.dumps({"mime_type": mime_type, "size_bytes": size_bytes})) +""" + + +class TerminalExecError(Exception): + """Domain error with an error code for the gRPC response.""" + + def __init__(self, code: str, message: str): + self.code = code + self.message = message + super().__init__(message) + + +class _TerminalSession: + __slots__ = ("busy", "lease_expires_at", "sandbox", "session_id") + + def __init__(self, session_id: str, sandbox: Sandbox, lease_expires_at: float): + self.session_id = session_id + self.sandbox = sandbox + self.lease_expires_at = lease_expires_at + self.busy = True + + +class TerminalSessionManager: + def __init__( + self, + e2b_template: str, + e2b_timeout_sec: int, + lease_min_sec: int, + lease_max_sec: int, + lease_default_sec: int, + output_limit_bytes: int, + export_max_bytes: int, + ): + self._e2b_template = e2b_template + self._e2b_timeout_sec = e2b_timeout_sec + self._lease_min_sec = lease_min_sec + self._lease_max_sec = lease_max_sec + self._lease_default_sec = lease_default_sec + self._output_limit_bytes = output_limit_bytes + self._export_max_bytes = export_max_bytes + + self._sessions: dict[str, _TerminalSession] = {} + self._lock = threading.Lock() + self._stop_event = threading.Event() + self._janitor_thread = threading.Thread( + target=self._janitor_loop, daemon=True, name="terminal-janitor" + ) + self._janitor_thread.start() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def execute( + self, + command: str, + session_id: str, + create_if_missing: bool, + lease_ttl_sec: int | None, + deadline_unix_ms: int, + ) -> dict: + command = (command or "").strip() + if not command: + raise TerminalExecError("invalid_payload", "command is required") + _resolve_request_timeout(deadline_unix_ms) + + lease_duration = self._resolve_lease_duration(lease_ttl_sec) + now = time.monotonic() + lease_target = now + lease_duration + + session_id = (session_id or "").strip() + created = False + session: _TerminalSession | None = None + + if not session_id: + # New session with auto-generated ID + session_id = str(uuid.uuid4()) + sandbox = self._create_sandbox() + try: + session = _TerminalSession(session_id, sandbox, lease_target) + created = True + with self._lock: + self._sessions[session_id] = session + except BaseException: + _kill_sandbox_safe(sandbox, session_id) + raise + else: + # Check if session exists first (without holding lock during creation) + with self._lock: + existing = self._sessions.get(session_id) + if existing is not None: + if existing.busy: + raise TerminalExecError("session_busy", "session is busy") + existing.busy = True + if lease_target > existing.lease_expires_at: + existing.lease_expires_at = lease_target + session = existing + + if session is None: + if not create_if_missing: + raise TerminalExecError("session_not_found", "session not found") + # Create sandbox outside lock to avoid blocking other operations + sandbox = self._create_sandbox() + try: + session = _TerminalSession(session_id, sandbox, lease_target) + with self._lock: + # Check again in case another thread created it + if session_id in self._sessions: + raise TerminalExecError( + "session_conflict", + "session was created by another request", + ) + self._sessions[session_id] = session + created = True + except BaseException: + _kill_sandbox_safe(sandbox, session_id) + raise + + # Compute command timeout from deadline + cmd_timeout: float | None = None + if deadline_unix_ms > 0: + remaining_ms = deadline_unix_ms - int(time.time() * 1000) + if remaining_ms <= 0: + self._destroy_session(session_id) + raise TerminalExecError("deadline_exceeded", "command deadline exceeded") + cmd_timeout = remaining_ms / 1000.0 + + try: + result = session.sandbox.commands.run(command, timeout=cmd_timeout) + except SandboxException as exc: + if "not found" in str(exc).lower() or "404" in str(exc): + self._destroy_session(session_id) + raise TerminalExecError("session_not_found", "session not found") from exc + self._mark_session_idle(session_id) + raise TerminalExecError( + "execution_failed", f"terminal execution failed: {exc}" + ) from exc + except Exception as exc: + if "timeout" in type(exc).__name__.lower() or "timeout" in str(exc).lower(): + self._destroy_session(session_id) + raise TerminalExecError("deadline_exceeded", "command deadline exceeded") from exc + self._mark_session_idle(session_id) + raise TerminalExecError( + "execution_failed", f"terminal execution failed: {exc}" + ) from exc + + stdout, stdout_truncated = _truncate_by_bytes(result.stdout or "", self._output_limit_bytes) + stderr, stderr_truncated = _truncate_by_bytes(result.stderr or "", self._output_limit_bytes) + + lease_expires_at, ok = self._mark_session_idle(session_id) + if not ok: + raise TerminalExecError("session_not_found", "session not found") + + # Convert monotonic lease to wall-clock unix ms + lease_wall_ms = int((time.time() + (lease_expires_at - time.monotonic())) * 1000) + + return { + "session_id": session_id, + "created": created, + "stdout": stdout, + "stderr": stderr, + "exit_code": result.exit_code, + "stdout_truncated": stdout_truncated, + "stderr_truncated": stderr_truncated, + "lease_expires_unix_ms": lease_wall_ms, + } + + def resolve_resource( + self, + session_id: str, + file_path: str, + action: str, + signed_url: str, + headers: dict[str, str] | None = None, + deadline_unix_ms: int = 0, + ) -> dict: + session_id = (session_id or "").strip() + file_path = (file_path or "").strip() + if not session_id or not file_path: + raise TerminalExecError("invalid_payload", "session_id and file_path are required") + + normalized_action = _normalize_terminal_resource_action(action) + if not normalized_action: + raise TerminalExecError("invalid_payload", "action must be validate, read, or export") + signed_url = (signed_url or "").strip() + if normalized_action == TERMINAL_RESOURCE_ACTION_EXPORT and not signed_url: + raise TerminalExecError("invalid_payload", "signed_url is required for export") + + session = self._acquire_session(session_id) + + try: + result = self._resolve_resource_in_session( + session_id=session_id, + sandbox=session.sandbox, + file_path=file_path, + action=normalized_action, + signed_url=signed_url, + headers=headers or {}, + deadline_unix_ms=deadline_unix_ms, + ) + except TerminalExecError as exc: + if exc.code in ("deadline_exceeded", "session_not_found"): + self._destroy_session(session_id) + else: + self._mark_session_idle(session_id) + raise + except Exception as exc: + if _is_timeout_error(exc): + self._destroy_session(session_id) + raise TerminalExecError("deadline_exceeded", "command deadline exceeded") from exc + if _is_sandbox_missing_error(exc): + self._destroy_session(session_id) + raise TerminalExecError("session_not_found", "session not found") from exc + self._mark_session_idle(session_id) + raise TerminalExecError( + "execution_failed", f"terminalResource execution failed: {exc}" + ) from exc + + if not self._mark_session_idle(session_id)[1]: + raise TerminalExecError("session_not_found", "session not found") + return result + + def shutdown(self) -> None: + self._stop_event.set() + self._janitor_thread.join(timeout=10.0) + + with self._lock: + sessions = list(self._sessions.values()) + self._sessions.clear() + + for session in sessions: + _kill_sandbox_safe(session.sandbox, session.session_id) + + def active_session_count(self) -> int: + with self._lock: + return len(self._sessions) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _resolve_lease_duration(self, lease_ttl_sec: int | None) -> float: + sec = self._lease_default_sec if lease_ttl_sec is None else lease_ttl_sec + if sec < self._lease_min_sec or sec > self._lease_max_sec: + raise TerminalExecError( + "invalid_payload", + f"lease_ttl_sec must be between {self._lease_min_sec} and {self._lease_max_sec}", + ) + return float(sec) + + def _create_sandbox(self) -> Sandbox: + try: + return Sandbox.create( + template=self._e2b_template, + timeout=self._e2b_timeout_sec, + ) + except Exception as exc: + raise TerminalExecError("execution_failed", f"failed to create sandbox: {exc}") from exc + + def _acquire_session(self, session_id: str) -> _TerminalSession: + with self._lock: + session = self._sessions.get(session_id) + if session is None: + raise TerminalExecError("session_not_found", "session not found") + if session.busy: + raise TerminalExecError("session_busy", "session is busy") + session.busy = True + return session + + def _resolve_resource_in_session( + self, + session_id: str, + sandbox: Sandbox, + file_path: str, + action: str, + signed_url: str, + headers: dict[str, str], + deadline_unix_ms: int, + ) -> dict: + metadata = self._probe_terminal_resource( + sandbox=sandbox, + file_path=file_path, + deadline_unix_ms=deadline_unix_ms, + ) + result = { + "session_id": session_id, + "file_path": file_path, + "mime_type": metadata["mime_type"], + "size_bytes": metadata["size_bytes"], + } + + if action == TERMINAL_RESOURCE_ACTION_VALIDATE: + return result + + if action == TERMINAL_RESOURCE_ACTION_READ: + if self._output_limit_bytes > 0 and metadata["size_bytes"] > self._output_limit_bytes: + raise TerminalExecError( + TERMINAL_RESOURCE_CODE_FILE_TOO_LARGE, + "file exceeds read limit", + ) + content = self._read_terminal_resource_bytes( + sandbox=sandbox, + file_path=file_path, + deadline_unix_ms=deadline_unix_ms, + ) + result["blob"] = base64.b64encode(bytes(content)).decode("ascii") + return result + + if self._export_max_bytes > 0 and metadata["size_bytes"] > self._export_max_bytes: + raise TerminalExecError( + TERMINAL_RESOURCE_CODE_FILE_TOO_LARGE, + "file exceeds export limit", + ) + content = self._read_terminal_resource_stream( + sandbox=sandbox, + file_path=file_path, + deadline_unix_ms=deadline_unix_ms, + ) + _upload_to_signed_url( + signed_url=signed_url, + content=_DeadlineBoundChunks(content, deadline_unix_ms), + content_length=metadata["size_bytes"], + headers=headers, + deadline_unix_ms=deadline_unix_ms, + ) + return result + + def _probe_terminal_resource( + self, + sandbox: Sandbox, + file_path: str, + deadline_unix_ms: int, + ) -> dict: + command = ( + "python3 -c " + + shlex.quote(TERMINAL_RESOURCE_PROBE_SCRIPT) + + " --file-path " + + shlex.quote(file_path) + ) + try: + result = sandbox.commands.run( + command, timeout=_resolve_request_timeout(deadline_unix_ms) + ) + except SandboxNotFoundException as exc: + raise TerminalExecError("session_not_found", "session not found") from exc + except TimeoutException as exc: + raise TerminalExecError("deadline_exceeded", "command deadline exceeded") from exc + except SandboxException as exc: + raise TerminalExecError( + "execution_failed", f"terminal resource probe failed: {exc}" + ) from exc + + if result.exit_code != 0: + decoded = _try_decode_json_object(result.stdout or "") + if decoded and decoded.get("error"): + raise TerminalExecError( + str(decoded["error"]), + _terminal_resource_error_message( + str(decoded["error"]), + str(decoded.get("message") or ""), + ), + ) + raise TerminalExecError( + "execution_failed", + _terminal_resource_probe_failure_message( + result.exit_code, + result.stdout or "", + result.stderr or "", + ), + ) + + decoded = _try_decode_json_object(result.stdout or "") + if not decoded: + raise TerminalExecError( + "execution_failed", "invalid terminalResource result: empty output" + ) + if decoded.get("error"): + raise TerminalExecError( + str(decoded["error"]), + _terminal_resource_error_message( + str(decoded["error"]), + str(decoded.get("message") or ""), + ), + ) + + mime_type = str(decoded.get("mime_type") or "").strip() or "application/octet-stream" + size_bytes = int(decoded.get("size_bytes") or 0) + return { + "mime_type": mime_type, + "size_bytes": size_bytes, + } + + def _read_terminal_resource_bytes( + self, + sandbox: Sandbox, + file_path: str, + deadline_unix_ms: int, + ) -> bytes: + try: + return bytes( + sandbox.files.read( + file_path, + format="bytes", + request_timeout=_resolve_request_timeout(deadline_unix_ms), + ) + ) + except Exception as exc: + raise _translate_terminal_resource_access_error(exc, "read") from exc + + def _read_terminal_resource_stream( + self, + sandbox: Sandbox, + file_path: str, + deadline_unix_ms: int, + ): + try: + return sandbox.files.read( + file_path, + format="stream", + request_timeout=_resolve_request_timeout(deadline_unix_ms), + ) + except Exception as exc: + raise _translate_terminal_resource_access_error(exc, "export stream") from exc + + def _mark_session_idle(self, session_id: str) -> tuple[float, bool]: + with self._lock: + session = self._sessions.get(session_id) + if session is None: + return 0.0, False + session.busy = False + return session.lease_expires_at, True + + def _destroy_session(self, session_id: str) -> None: + with self._lock: + session = self._sessions.pop(session_id, None) + if session is not None: + _kill_sandbox_safe(session.sandbox, session_id) + + def _janitor_loop(self) -> None: + while not self._stop_event.wait(JANITOR_INTERVAL_SEC): + self._cleanup_expired() + + def _cleanup_expired(self) -> None: + now = time.monotonic() + expired: list[_TerminalSession] = [] + + with self._lock: + for sid in list(self._sessions): + session = self._sessions[sid] + if session.busy: + continue + if session.lease_expires_at <= now: + expired.append(session) + del self._sessions[sid] + + for session in expired: + logger.info("janitor: cleaning expired session", session_id=session.session_id) + _kill_sandbox_safe(session.sandbox, session.session_id) + + +def _kill_sandbox_safe(sandbox: Sandbox, session_id: str) -> None: + try: + sandbox.kill() + except Exception as exc: + logger.warning("failed to kill sandbox", session_id=session_id, error=str(exc)) + + +def _truncate_by_bytes(value: str, max_bytes: int) -> tuple[str, bool]: + if max_bytes <= 0: + return value, False + encoded = value.encode("utf-8", errors="replace") + if len(encoded) <= max_bytes: + return value, False + truncated = encoded[:max_bytes].decode("utf-8", errors="ignore") + return truncated, True + + +def _normalize_terminal_resource_action(action: str) -> str: + normalized = (action or "").strip().lower() + if not normalized: + return TERMINAL_RESOURCE_ACTION_VALIDATE + if normalized in { + TERMINAL_RESOURCE_ACTION_VALIDATE, + TERMINAL_RESOURCE_ACTION_READ, + TERMINAL_RESOURCE_ACTION_EXPORT, + }: + return normalized + return "" + + +def _resolve_request_timeout(deadline_unix_ms: int) -> float | None: + if deadline_unix_ms <= 0: + return None + remaining_ms = deadline_unix_ms - int(time.time() * 1000) + if remaining_ms <= 0: + raise TerminalExecError("deadline_exceeded", "command deadline exceeded") + return remaining_ms / 1000.0 + + +def _try_decode_json_object(value: str) -> dict | None: + trimmed = value.strip() + if not trimmed: + return None + try: + decoded = json.loads(trimmed) + except json.JSONDecodeError: + return None + return decoded if isinstance(decoded, dict) else None + + +def _terminal_resource_error_message(code: str, fallback: str) -> str: + if fallback.strip(): + return fallback.strip() + if code == TERMINAL_RESOURCE_CODE_FILE_NOT_FOUND: + return "file not found" + if code == TERMINAL_RESOURCE_CODE_PATH_IS_DIR: + return "path is directory" + if code == TERMINAL_RESOURCE_CODE_FILE_TOO_LARGE: + return "file exceeds read limit" + return "terminal resource operation failed" + + +def _terminal_resource_probe_failure_message(exit_code: int, stdout: str, stderr: str) -> str: + stderr_value = stderr.strip() + stdout_value = stdout.strip() + if stderr_value: + return f"terminal resource probe failed: exit_code={exit_code}, stderr={stderr_value}" + if len(stdout_value) > 256: + stdout_value = stdout_value[:256] + "..." + if stdout_value: + return f"terminal resource probe failed: exit_code={exit_code}, stdout={stdout_value}" + return f"terminal resource probe failed: exit_code={exit_code}" + + +def _is_timeout_error(exc: Exception) -> bool: + for candidate in _iter_exception_chain(exc): + if isinstance(candidate, (TimeoutException, TimeoutError)): + return True + text = f"{type(candidate).__name__} {candidate}".lower() + if "timeout" in text or "deadline exceeded" in text: + return True + return False + + +def _is_sandbox_missing_error(exc: Exception) -> bool: + for candidate in _iter_exception_chain(exc): + if isinstance(candidate, SandboxNotFoundException): + return True + if isinstance(candidate, SandboxException): + text = f"{type(candidate).__name__} {candidate}".lower() + if "sandbox" in text and "not found" in text: + return True + return False + + +def _is_file_not_found_error(exc: Exception) -> bool: + return any( + isinstance(candidate, (FileNotFoundException, FileNotFoundError)) + for candidate in _iter_exception_chain(exc) + ) + + +def _is_path_is_directory_error(exc: Exception) -> bool: + return any(isinstance(candidate, IsADirectoryError) for candidate in _iter_exception_chain(exc)) + + +def _translate_terminal_resource_access_error(exc: Exception, operation: str) -> TerminalExecError: + if _is_timeout_error(exc): + return TerminalExecError("deadline_exceeded", "command deadline exceeded") + if _is_sandbox_missing_error(exc): + return TerminalExecError("session_not_found", "session not found") + if _is_file_not_found_error(exc): + return TerminalExecError(TERMINAL_RESOURCE_CODE_FILE_NOT_FOUND, "file not found") + if _is_path_is_directory_error(exc): + return TerminalExecError(TERMINAL_RESOURCE_CODE_PATH_IS_DIR, "path is directory") + return TerminalExecError("execution_failed", f"terminal resource {operation} failed: {exc}") + + +def _iter_exception_chain(exc: Exception): + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + yield current + seen.add(id(current)) + next_exc = current.__cause__ + if next_exc is None: + next_exc = current.__context__ + current = next_exc + + +def _upload_to_signed_url( + signed_url: str, + content, + content_length: int, + headers: Mapping[str, str] | None, + deadline_unix_ms: int, +) -> None: + data = _StreamingBody(content) + request = urllib.request.Request(signed_url, data=data, method="PUT") + request.add_header("Content-Length", str(content_length)) + for key, value in (headers or {}).items(): + trimmed_key = str(key).strip() + if trimmed_key: + request.add_header(trimmed_key, str(value)) + try: + with urllib.request.urlopen( + request, timeout=_resolve_request_timeout(deadline_unix_ms) + ) as response: + status = getattr(response, "status", response.getcode()) + if status < 200 or status >= 300: + body = response.read(1024).decode("utf-8", errors="replace").strip() + raise RuntimeError(body or f"HTTP {status}") + except TerminalExecError: + raise + except urllib.error.HTTPError as exc: + body = exc.read(1024).decode("utf-8", errors="replace").strip() + raise RuntimeError(f"upload export file failed: {body or exc.reason}") from exc + except urllib.error.URLError as exc: + if _is_timeout_error(exc): + raise TerminalExecError("deadline_exceeded", "command deadline exceeded") from exc + raise RuntimeError(f"upload export file: {exc.reason}") from exc + except TimeoutError as exc: + raise TerminalExecError("deadline_exceeded", "command deadline exceeded") from exc + + +class _StreamingBody: + def __init__(self, chunks) -> None: + self._chunks = iter(chunks) + + def read(self, size: int = -1) -> bytes: + if size == 0: + return b"" + try: + chunk = next(self._chunks) + except StopIteration: + return b"" + return bytes(chunk) + + +class _DeadlineBoundChunks: + def __init__(self, chunks, deadline_unix_ms: int) -> None: + self._chunks = iter(chunks) + self._deadline_unix_ms = deadline_unix_ms + + def __iter__(self): + return self + + def __next__(self): + _resolve_request_timeout(self._deadline_unix_ms) + return next(self._chunks) diff --git a/worker/worker-bridge-e2b/tests/test_terminal_resource.py b/worker/worker-bridge-e2b/tests/test_terminal_resource.py new file mode 100644 index 0000000..2f0d315 --- /dev/null +++ b/worker/worker-bridge-e2b/tests/test_terminal_resource.py @@ -0,0 +1,682 @@ +import asyncio +import base64 +import json +import time +from types import SimpleNamespace + +import pytest +from e2b import FileNotFoundException, SandboxNotFoundException + +import worker_bridge_e2b.session_manager as session_manager_module +from worker_bridge_e2b import executor, runner +from worker_bridge_e2b.config import Config +from worker_bridge_e2b.session_manager import ( + TERMINAL_RESOURCE_ACTION_EXPORT, + TERMINAL_RESOURCE_ACTION_READ, + TERMINAL_RESOURCE_ACTION_VALIDATE, + TERMINAL_RESOURCE_CODE_FILE_NOT_FOUND, + TERMINAL_RESOURCE_CODE_FILE_TOO_LARGE, + TERMINAL_RESOURCE_CODE_PATH_IS_DIR, + TerminalExecError, + TerminalSessionManager, + _TerminalSession, +) + + +class FakeCommandResult: + def __init__(self, stdout: str = "", stderr: str = "", exit_code: int = 0): + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + + +class FakeCommands: + def __init__(self, run_fn): + self._run_fn = run_fn + + def run(self, command: str, timeout: float | None = None): + return self._run_fn(command, timeout) + + +class FakeFiles: + def __init__(self, read_fn): + self._read_fn = read_fn + + def read(self, path: str, format: str = "text", request_timeout: float | None = None): + return self._read_fn(path, format, request_timeout) + + +class FakeSandbox: + def __init__(self, run_fn, read_fn=None): + self.commands = FakeCommands(run_fn) + self.files = FakeFiles(read_fn or (lambda path, format, request_timeout: b"")) + self.killed = False + + def kill(self): + self.killed = True + + +@pytest.fixture +def manager(): + value = TerminalSessionManager( + e2b_template="base", + e2b_timeout_sec=300, + lease_min_sec=60, + lease_max_sec=1800, + lease_default_sec=60, + output_limit_bytes=1024, + export_max_bytes=0, + ) + yield value + value.shutdown() + + +@pytest.fixture +def restore_executor_state(): + original_session_manager = executor._session_manager + original_config = executor._config + yield + executor._session_manager = original_session_manager + executor._config = original_config + + +def make_config() -> Config: + return Config( + console_grpc_target="127.0.0.1:50051", + console_tls=False, + worker_id="worker-1", + worker_secret="secret", + heartbeat_interval_sec=5, + heartbeat_jitter_pct=20, + call_timeout_sec=13, + node_name="node-1", + executor_kind="e2b", + version="dev", + labels={}, + e2b_api_key="key", + e2b_python_exec_template="base", + e2b_terminal_exec_template="base", + e2b_sandbox_timeout_sec=300, + echo_max_inflight=4, + python_exec_max_inflight=4, + terminal_exec_max_inflight=4, + terminal_resource_max_inflight=7, + terminal_lease_min_sec=60, + terminal_lease_max_sec=1800, + terminal_lease_default_sec=60, + terminal_output_limit_bytes=1024, + terminal_export_max_bytes=0, + log_level="info", + log_format="json", + ) + + +def register_session( + manager: TerminalSessionManager, session_id: str, sandbox: FakeSandbox, busy: bool = False +): + session = _TerminalSession(session_id, sandbox, time.monotonic() + 60) + session.busy = busy + with manager._lock: + manager._sessions[session_id] = session + return session + + +def test_build_hello_declares_terminal_resource(): + hello = runner._build_hello(make_config()).hello + capabilities = {item.name: item.max_inflight for item in hello.capabilities} + assert capabilities["terminalResource"] == 7 + + +def test_config_default_call_timeout(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("WORKER_HEARTBEAT_INTERVAL_SEC", "5") + monkeypatch.delenv("WORKER_CALL_TIMEOUT_SEC", raising=False) + + cfg = Config.load() + + assert cfg.call_timeout_sec == 13 + + +def test_execute_terminal_exec_invalid_payloads(restore_executor_state): + executor._session_manager = SimpleNamespace(execute=lambda **kwargs: None) + + result_payload, err_code, err_message = executor.execute_terminal_exec(b"{", 0) + + assert result_payload == b"{}" + assert err_code == "invalid_payload" + assert err_message == "payload_json is not valid terminalExec payload" + + +@pytest.mark.asyncio +async def test_heartbeat_loop_reports_active_session_count(monkeypatch: pytest.MonkeyPatch): + cfg = make_config() + queue = asyncio.Queue() + heartbeat_ack_queue = asyncio.Queue() + stop_event = asyncio.Event() + + monkeypatch.setattr(runner, "_jitter_duration_sec", lambda base_sec, jitter_pct: 0.01) + monkeypatch.setattr(executor, "active_session_count", lambda: 3) + + task = asyncio.create_task( + runner._heartbeat_loop(cfg, queue, heartbeat_ack_queue, stop_event, "registry-session-1", 5) + ) + + request = await asyncio.wait_for(queue.get(), timeout=1) + heartbeat = request.heartbeat + assert heartbeat.node_id == "worker-1" + assert heartbeat.session_id == "registry-session-1" + assert heartbeat.active_session_count == 3 + + await heartbeat_ack_queue.put(runner.pb.HeartbeatAck(heartbeat_interval_sec=2)) + stop_event.set() + await asyncio.wait_for(task, timeout=1) + + +@pytest.mark.asyncio +async def test_heartbeat_loop_fails_after_two_ack_timeouts(monkeypatch: pytest.MonkeyPatch): + cfg = make_config() + cfg.call_timeout_sec = 0.01 + queue = asyncio.Queue() + heartbeat_ack_queue = asyncio.Queue() + stop_event = asyncio.Event() + + monkeypatch.setattr(runner, "_jitter_duration_sec", lambda base_sec, jitter_pct: 0.01) + + with pytest.raises(runner.SessionEndedError, match="heartbeat ack deadline exceeded"): + await runner._heartbeat_loop( + cfg, + queue, + heartbeat_ack_queue, + stop_event, + "registry-session-1", + 5, + ) + + +def test_execute_terminal_resource_success(restore_executor_state): + resolved = {} + + def fake_resolve_resource(**kwargs): + resolved.update(kwargs) + return { + "session_id": kwargs["session_id"], + "file_path": kwargs["file_path"], + "mime_type": "text/plain", + "size_bytes": 5, + "blob": base64.b64encode(b"hello").decode("ascii"), + } + + executor._session_manager = SimpleNamespace(resolve_resource=fake_resolve_resource) + + payload, err_code, err_message = executor.execute_terminal_resource( + b'{"session_id":"sess-1","file_path":"/tmp/hello.txt","action":"read","headers":{"x-amz-acl":"public-read"}}', + 0, + ) + + assert err_code == "" + assert err_message == "" + assert json.loads(payload) == { + "session_id": "sess-1", + "file_path": "/tmp/hello.txt", + "mime_type": "text/plain", + "size_bytes": 5, + "blob": base64.b64encode(b"hello").decode("ascii"), + } + assert resolved["headers"] == {"x-amz-acl": "public-read"} + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + (b"", "terminalResource payload is required"), + (b"[]", "payload_json is not valid terminalResource payload"), + (b"{", "payload_json is not valid terminalResource payload"), + (b'{"session_id":"sess-1"}', "terminalResource session_id and file_path are required"), + ( + b'{"session_id":1,"file_path":"/tmp/hello.txt"}', + "terminalResource session_id and file_path must be strings", + ), + ( + b'{"session_id":"sess-1","file_path":"/tmp/hello.txt","action":1}', + "terminalResource action must be a string", + ), + ( + b'{"session_id":"sess-1","file_path":"/tmp/hello.txt","action":"export","signed_url":1}', + "terminalResource signed_url must be a string", + ), + ( + b'{"session_id":"sess-1","file_path":"/tmp/hello.txt","action":"export"}', + "terminalResource signed_url is required for export", + ), + ], +) +def test_execute_terminal_resource_invalid_payloads( + restore_executor_state, payload: bytes, message: str +): + executor._session_manager = SimpleNamespace(resolve_resource=lambda **kwargs: None) + + result_payload, err_code, err_message = executor.execute_terminal_resource(payload, 0) + + assert result_payload == b"{}" + assert err_code == "invalid_payload" + assert err_message == message + + +def test_terminal_session_manager_validate_and_read(manager: TerminalSessionManager): + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult( + stdout='{"mime_type":"text/plain","size_bytes":5}' + ), + read_fn=lambda path, format, request_timeout: b"hello", + ) + register_session(manager, "sess-1", sandbox) + + validate = manager.resolve_resource( + session_id="sess-1", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_VALIDATE, + signed_url="", + deadline_unix_ms=0, + ) + read = manager.resolve_resource( + session_id="sess-1", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_READ, + signed_url="", + deadline_unix_ms=0, + ) + + assert validate == { + "session_id": "sess-1", + "file_path": "/tmp/hello.txt", + "mime_type": "text/plain", + "size_bytes": 5, + } + assert read == { + "session_id": "sess-1", + "file_path": "/tmp/hello.txt", + "mime_type": "text/plain", + "size_bytes": 5, + "blob": base64.b64encode(b"hello").decode("ascii"), + } + + +def test_terminal_session_manager_export( + manager: TerminalSessionManager, monkeypatch: pytest.MonkeyPatch +): + uploaded = {} + + def fake_upload( + signed_url: str, + content, + content_length: int, + headers: dict[str, str], + deadline_unix_ms: int, + ): + uploaded["signed_url"] = signed_url + uploaded["content"] = b"".join(bytes(chunk) for chunk in content) + uploaded["content_length"] = content_length + uploaded["headers"] = headers + uploaded["deadline_unix_ms"] = deadline_unix_ms + + monkeypatch.setattr(session_manager_module, "_upload_to_signed_url", fake_upload) + + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult( + stdout='{"mime_type":"text/plain","size_bytes":5}' + ), + read_fn=lambda path, format, request_timeout: iter([b"he", b"llo"]), + ) + register_session(manager, "sess-export", sandbox) + + result = manager.resolve_resource( + session_id="sess-export", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_EXPORT, + signed_url="https://uploads.example.com/put", + headers={"x-amz-acl": "public-read"}, + deadline_unix_ms=0, + ) + + assert result == { + "session_id": "sess-export", + "file_path": "/tmp/hello.txt", + "mime_type": "text/plain", + "size_bytes": 5, + } + assert uploaded == { + "signed_url": "https://uploads.example.com/put", + "content": b"hello", + "content_length": 5, + "headers": {"x-amz-acl": "public-read"}, + "deadline_unix_ms": 0, + } + + +@pytest.mark.parametrize( + ("stdout", "exit_code", "code", "message"), + [ + ( + '{"error":"file_not_found","message":"file not found"}', + 10, + TERMINAL_RESOURCE_CODE_FILE_NOT_FOUND, + "file not found", + ), + ( + '{"error":"path_is_directory","message":"path is directory"}', + 11, + TERMINAL_RESOURCE_CODE_PATH_IS_DIR, + "path is directory", + ), + ], +) +def test_terminal_session_manager_domain_errors( + manager: TerminalSessionManager, + stdout: str, + exit_code: int, + code: str, + message: str, +): + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult(stdout=stdout, exit_code=exit_code) + ) + register_session(manager, "sess-err", sandbox) + + with pytest.raises(TerminalExecError) as exc_info: + manager.resolve_resource( + session_id="sess-err", + file_path="/tmp/missing.txt", + action=TERMINAL_RESOURCE_ACTION_READ, + signed_url="", + deadline_unix_ms=0, + ) + + assert exc_info.value.code == code + assert exc_info.value.message == message + + +@pytest.mark.parametrize( + ("action", "output_limit_bytes", "export_max_bytes", "message"), + [ + (TERMINAL_RESOURCE_ACTION_READ, 3, 0, "file exceeds read limit"), + (TERMINAL_RESOURCE_ACTION_EXPORT, 1024, 3, "file exceeds export limit"), + ], +) +def test_terminal_session_manager_oversized_file( + action: str, + output_limit_bytes: int, + export_max_bytes: int, + message: str, +): + manager = TerminalSessionManager( + e2b_template="base", + e2b_timeout_sec=300, + lease_min_sec=60, + lease_max_sec=1800, + lease_default_sec=60, + output_limit_bytes=output_limit_bytes, + export_max_bytes=export_max_bytes, + ) + try: + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult( + stdout='{"mime_type":"application/octet-stream","size_bytes":10}' + ), + read_fn=lambda path, format, request_timeout: pytest.fail( + "files.read should not be called" + ), + ) + register_session(manager, "sess-large", sandbox) + + with pytest.raises(TerminalExecError) as exc_info: + manager.resolve_resource( + session_id="sess-large", + file_path="/tmp/large.bin", + action=action, + signed_url="https://uploads.example.com/put", + deadline_unix_ms=0, + ) + + assert exc_info.value.code == TERMINAL_RESOURCE_CODE_FILE_TOO_LARGE + assert exc_info.value.message == message + finally: + manager.shutdown() + + +def test_terminal_session_manager_missing_and_busy_sessions(manager: TerminalSessionManager): + with pytest.raises(TerminalExecError) as missing_exc: + manager.resolve_resource( + session_id="missing", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_VALIDATE, + signed_url="", + deadline_unix_ms=0, + ) + assert missing_exc.value.code == "session_not_found" + + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult( + stdout='{"mime_type":"text/plain","size_bytes":5}' + ) + ) + register_session(manager, "busy", sandbox, busy=True) + + with pytest.raises(TerminalExecError) as busy_exc: + manager.resolve_resource( + session_id="busy", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_VALIDATE, + signed_url="", + deadline_unix_ms=0, + ) + assert busy_exc.value.code == "session_busy" + + +def test_terminal_session_manager_timeout_destroys_session(manager: TerminalSessionManager): + sandbox = FakeSandbox( + run_fn=lambda command, timeout: (_ for _ in ()).throw(TimeoutError("timeout")) + ) + session = register_session(manager, "sess-timeout", sandbox) + + with pytest.raises(TerminalExecError) as exc_info: + manager.resolve_resource( + session_id="sess-timeout", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_VALIDATE, + signed_url="", + deadline_unix_ms=0, + ) + + assert exc_info.value.code == "deadline_exceeded" + assert session.sandbox.killed is True + with manager._lock: + assert "sess-timeout" not in manager._sessions + + +def test_terminal_session_manager_recomputes_deadline_before_read( + manager: TerminalSessionManager, + monkeypatch: pytest.MonkeyPatch, +): + now_sec = {"value": 0.0} + + def fake_time(): + return now_sec["value"] + + monkeypatch.setattr(session_manager_module.time, "time", fake_time) + + sandbox = FakeSandbox( + run_fn=lambda command, timeout: ( + now_sec.__setitem__("value", 5.1), + FakeCommandResult(stdout='{"mime_type":"text/plain","size_bytes":5}'), + )[1], + read_fn=lambda path, format, request_timeout: b"hello", + ) + session = register_session(manager, "sess-read-deadline", sandbox) + + with pytest.raises(TerminalExecError) as exc_info: + manager.resolve_resource( + session_id="sess-read-deadline", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_READ, + signed_url="", + deadline_unix_ms=5000, + ) + + assert exc_info.value.code == "deadline_exceeded" + assert session.sandbox.killed is True + with manager._lock: + assert "sess-read-deadline" not in manager._sessions + + +def test_terminal_session_manager_recomputes_deadline_before_export_upload( + manager: TerminalSessionManager, + monkeypatch: pytest.MonkeyPatch, +): + now_sec = {"value": 0.0} + uploaded = {} + + def fake_time(): + return now_sec["value"] + + def fake_upload( + signed_url: str, + content, + content_length: int, + headers: dict[str, str], + deadline_unix_ms: int, + ): + uploaded["signed_url"] = signed_url + uploaded["content"] = b"".join(bytes(chunk) for chunk in content) + uploaded["content_length"] = content_length + uploaded["headers"] = headers + uploaded["remaining_sec"] = (deadline_unix_ms / 1000.0) - now_sec["value"] + + monkeypatch.setattr(session_manager_module.time, "time", fake_time) + monkeypatch.setattr(session_manager_module, "_upload_to_signed_url", fake_upload) + + sandbox = FakeSandbox( + run_fn=lambda command, timeout: ( + now_sec.__setitem__("value", 4.8), + FakeCommandResult(stdout='{"mime_type":"text/plain","size_bytes":5}'), + )[1], + read_fn=lambda path, format, request_timeout: iter([b"he", b"llo"]), + ) + register_session(manager, "sess-export-deadline", sandbox) + + result = manager.resolve_resource( + session_id="sess-export-deadline", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_EXPORT, + signed_url="https://uploads.example.com/put", + deadline_unix_ms=5000, + ) + + assert result == { + "session_id": "sess-export-deadline", + "file_path": "/tmp/hello.txt", + "mime_type": "text/plain", + "size_bytes": 5, + } + assert uploaded == { + "signed_url": "https://uploads.example.com/put", + "content": b"hello", + "content_length": 5, + "headers": {}, + "remaining_sec": pytest.approx(0.2, abs=0.01), + } + + +def test_terminal_session_manager_upload_not_found_does_not_destroy_session( + manager: TerminalSessionManager, + monkeypatch: pytest.MonkeyPatch, +): + def fake_upload( + signed_url: str, + content, + content_length: int, + headers: dict[str, str], + deadline_unix_ms: int, + ): + raise RuntimeError("upload export file failed: Not Found") + + monkeypatch.setattr(session_manager_module, "_upload_to_signed_url", fake_upload) + + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult( + stdout='{"mime_type":"text/plain","size_bytes":5}' + ), + read_fn=lambda path, format, request_timeout: iter([b"hello"]), + ) + session = register_session(manager, "sess-upload-404", sandbox) + + with pytest.raises(TerminalExecError) as exc_info: + manager.resolve_resource( + session_id="sess-upload-404", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_EXPORT, + signed_url="https://uploads.example.com/put", + deadline_unix_ms=0, + ) + + assert exc_info.value.code == "execution_failed" + assert "upload export file failed: Not Found" in exc_info.value.message + assert session.sandbox.killed is False + with manager._lock: + assert manager._sessions["sess-upload-404"].busy is False + + +def test_terminal_session_manager_file_not_found_during_read_keeps_session( + manager: TerminalSessionManager, +): + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult( + stdout='{"mime_type":"text/plain","size_bytes":5}' + ), + read_fn=lambda path, format, request_timeout: (_ for _ in ()).throw( + FileNotFoundException("file not found") + ), + ) + session = register_session(manager, "sess-read-missing", sandbox) + + with pytest.raises(TerminalExecError) as exc_info: + manager.resolve_resource( + session_id="sess-read-missing", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_READ, + signed_url="", + deadline_unix_ms=0, + ) + + assert exc_info.value.code == TERMINAL_RESOURCE_CODE_FILE_NOT_FOUND + assert exc_info.value.message == "file not found" + assert session.sandbox.killed is False + with manager._lock: + assert manager._sessions["sess-read-missing"].busy is False + + +def test_terminal_session_manager_sandbox_missing_during_read_destroys_session( + manager: TerminalSessionManager, +): + sandbox = FakeSandbox( + run_fn=lambda command, timeout: FakeCommandResult( + stdout='{"mime_type":"text/plain","size_bytes":5}' + ), + read_fn=lambda path, format, request_timeout: (_ for _ in ()).throw( + SandboxNotFoundException("sandbox not found") + ), + ) + session = register_session(manager, "sess-read-sandbox-missing", sandbox) + + with pytest.raises(TerminalExecError) as exc_info: + manager.resolve_resource( + session_id="sess-read-sandbox-missing", + file_path="/tmp/hello.txt", + action=TERMINAL_RESOURCE_ACTION_READ, + signed_url="", + deadline_unix_ms=0, + ) + + assert exc_info.value.code == "session_not_found" + assert exc_info.value.message == "session not found" + assert session.sandbox.killed is True + with manager._lock: + assert "sess-read-sandbox-missing" not in manager._sessions diff --git a/worker/worker-bridge-e2b/uv.lock b/worker/worker-bridge-e2b/uv.lock new file mode 100644 index 0000000..e821fb3 --- /dev/null +++ b/worker/worker-bridge-e2b/uv.lock @@ -0,0 +1,499 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508 }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845 }, +] + +[[package]] +name = "e2b" +version = "2.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/d5/ae04c966323b53de7397d7a6eb86da3214ce9e5fd2db89b352f4704eb99d/e2b-2.19.0.tar.gz", hash = "sha256:b78fe6ea07e0505d88f23e37ccda1a596492417f3700c63034c7f559ae4ca9a1", size = 155945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/f4/6c59addd3971e1d8444ed2546ffe72ec5c21cea5b9af740e4c243ad5bacb/e2b-2.19.0-py3-none-any.whl", hash = "sha256:00fce05815820b538243d164e813d5a9f3e4ea667036402bea55f53d608813eb", size = 295827 }, +] + +[[package]] +name = "e2b-code-interpreter" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "e2b" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/dd/f90b56d1597abfcdabdc018ac184fa714066be93d24b97edc2bf0671d483/e2b_code_interpreter-2.6.0.tar.gz", hash = "sha256:67e66531e5cf65c9df6e82aa0bdb1e73223a1ab205f10d47c027eb2ea09b73f9", size = 10683 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/79/f70d50604584df66064892f3fca7ab57b10ad40c826fd003be53a4cd5fa5/e2b_code_interpreter-2.6.0-py3-none-any.whl", hash = "sha256:a15f1d155566aef98cf2ccc0f8d9b07d15e07582d6cc8a128bc97de371bd617c", size = 13715 }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616 }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204 }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866 }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060 }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121 }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811 }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860 }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132 }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904 }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243 }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840 }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644 }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830 }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216 }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866 }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602 }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752 }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310 }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833 }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376 }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133 }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748 }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711 }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372 }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268 }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000 }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477 }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563 }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457 }, +] + +[[package]] +name = "grpcio-tools" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/b9/65929df8c9614792db900a8e45d4997fadbd1734c827da3f0eb1f2fe4866/grpcio_tools-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:d19d5a8244311947b96f749c417b32d144641c6953f1164824579e1f0a51d040", size = 2550856 }, + { url = "https://files.pythonhosted.org/packages/28/17/af1557544d68d1aeca9d9ea53ed16524022d521fec6ba334ab3530e9c1a6/grpcio_tools-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fb599a3dc89ed1bb24489a2724b2f6dd4cddbbf0f7bdd69c073477bab0dc7554", size = 5710883 }, + { url = "https://files.pythonhosted.org/packages/cc/48/aa9b4f7519ca972bc40d315d5c28f05ca28fa08de13d4e8b69f551b798ab/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:623ee31fc2ff7df9a987b4f3d139c30af17ce46a861ae0e25fb8c112daa32dd8", size = 2598004 }, + { url = "https://files.pythonhosted.org/packages/b4/b8/b01371c119924b3beca1fe3f047b1bc2cdc66b3d37f0f3acc9d10c567a43/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b46570a68378539ee2b75a5a43202561f8d753c832798b1047099e3c551cf5d6", size = 2909568 }, + { url = "https://files.pythonhosted.org/packages/4f/7c/1108f7bdb58475a7e701ec89b55eb494538b6e76acd211ba0d4cc5fd28e8/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51caf99c28999e7e0f97e9cea190c1405b7681a57bb2e0631205accd92b43fa4", size = 2660938 }, + { url = "https://files.pythonhosted.org/packages/67/59/d1c0063d4cd3b85363c7044ff3e5159d6d5df96e2692a9a5312d9c8cb290/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cdaa1c9aa8d3a87891a96700cadd29beec214711d6522818d207277f6452567c", size = 3113814 }, + { url = "https://files.pythonhosted.org/packages/76/21/18d34a4efe524c903cf66b0cfa5260d81f277b6ae668b647edf795df9ce5/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3399b5fd7b59bcffd59c6b9975a969d9f37a3c87f3e3d63c3a09c147907acb0d", size = 3662793 }, + { url = "https://files.pythonhosted.org/packages/f3/40/cf2d9295a6bd593244ea703858f8fc2efd315046ca3ef7c6f9ebc5b810fa/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c6abc08d3485b2aac99bb58afcd31dc6cd4316ce36cf263ff09cb6df15f287f", size = 3329149 }, + { url = "https://files.pythonhosted.org/packages/0d/1d/fc34b32167966df20d69429b71dfca83c48434b047a5ac4fd6cd91ca4eed/grpcio_tools-1.80.0-cp312-cp312-win32.whl", hash = "sha256:18c51e07652ac7386fcdbd11866f8d55a795de073337c12447b5805575339f74", size = 997519 }, + { url = "https://files.pythonhosted.org/packages/91/98/6d6563cdf51085b75f8ec24605c6f2ce84197571878ca8ab4af949c6be2d/grpcio_tools-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6fdd42d5bb18f0d903a067e2825be172deff70cf197164b6f65676cb506c9b", size = 1162407 }, + { url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319 }, + { url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681 }, + { url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820 }, + { url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193 }, + { url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197 }, + { url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144 }, + { url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897 }, + { url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786 }, + { url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087 }, + { url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167 }, + { url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303 }, + { url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778 }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928 }, + { url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333 }, + { url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217 }, + { url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896 }, + { url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950 }, + { url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755 }, + { url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620 }, + { url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739 }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089 }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737 }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381 }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436 }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206 }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307 }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722 }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674 }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516 }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202 }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891 }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525 }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072 }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998 }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769 }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236 }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343 }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382 }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969 }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870 }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510 }, +] + +[[package]] +name = "ty" +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/c2/a60543fb172ac7adaa3ae43b8db1d0dcd70aa67df254b70bf42f852a24f6/ty-0.0.28.tar.gz", hash = "sha256:1fbde7bc5d154d6f047b570d95665954fa83b75a0dce50d88cf081b40a27ea32", size = 5447781 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/15/c2aa3d4633e6153a2e300d7dd0ebdedf904a60241d1922566f31c5f7f211/ty-0.0.28-py3-none-linux_armv6l.whl", hash = "sha256:6dbfb27524195ab1715163d7be065cc45037509fe529d9763aff6732c919f0d8", size = 10556282 }, + { url = "https://files.pythonhosted.org/packages/60/9c/f6183838df89e9692235a71a69a9d4e0f12481bbdf1883f47010075793b0/ty-0.0.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c72a899ba94f7438bd07e897a84b36526b385aaf01d6f3eb6504e869232b3a6", size = 10425770 }, + { url = "https://files.pythonhosted.org/packages/68/82/e9208383412f8a320537ef4c44a768d2cb6c1330d9ab33087f0b932ccd1b/ty-0.0.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eef67f9cdfd31677bde801b611741dde779271ec6f471f818c7c6eccf515237f", size = 9899999 }, + { url = "https://files.pythonhosted.org/packages/4d/26/0442f49589ba393fbd3b50751f8bb82137b036bc509762884f7b21c511d1/ty-0.0.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70e7b98a91d8245641be1e4b55af8bc9b1ae82ec189794d35e14e546f1e15e66", size = 10400725 }, + { url = "https://files.pythonhosted.org/packages/57/d9/64128f1a7ceba72e49f35dd562533f44d4c56d0cf62efb21692377819dbc/ty-0.0.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd83d4ad9f99078b830aabb47792fac6dc39368bb0f72f3cc14607173ed6e25", size = 10387410 }, + { url = "https://files.pythonhosted.org/packages/cc/52/498b6bdd1d0a985fd14ce83c31186f3b838ad79efdf68ce928f441a6962b/ty-0.0.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0172984fc2fcd3e47ccd5da69f36f632cddc410f9a093144a05ad07d67cf06ed", size = 10880982 }, + { url = "https://files.pythonhosted.org/packages/f4/c8/fefd616f38a250b28f62ba73728cb6061715f03df0a610dce558a0fdfc0a/ty-0.0.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0bbf47d2bea82a09cab2ca4f48922d6c16a36608447acdc64163cd19beb28d3", size = 11459056 }, + { url = "https://files.pythonhosted.org/packages/16/15/9e18d763a5ef9c6a69396876586589fd5e0fd0acba35fae8a9a169680f48/ty-0.0.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1774c9a0fb071607e3bdfa0ce8365488ac46809fc04ad1706562a8709a023247", size = 11156341 }, + { url = "https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2849d6d212af78175430e8cc51a962a53851458182eb44a981b0e3981163177", size = 11006089 }, + { url = "https://files.pythonhosted.org/packages/dd/de/5b5fdbe3bdb5c6f4918b33f1c55cd975b3d606057089a822439d5151bf93/ty-0.0.28-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3c576c15b867b3913c4a1d9be30ade4682303e24a576d2cc99bfd8f25ae838e9", size = 10367739 }, + { url = "https://files.pythonhosted.org/packages/80/82/abdfb27ab988e6bd09502a4573f64a7e72db3e83acd7886af54448703c97/ty-0.0.28-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e5f13d10b3436bee3ea35851e5af400123f6693bfae48294ddfbbf553fa51ef", size = 10399528 }, + { url = "https://files.pythonhosted.org/packages/ba/74/3ccbe468e8480ba53f83a1e52481d3e11756415f0ca1297fb2da65e29612/ty-0.0.28-py3-none-musllinux_1_2_i686.whl", hash = "sha256:759db467e399faedc7d5f1ca4b383dd8ecc71d7d79b2ca6ea6db4ac8e643378a", size = 10586771 }, + { url = "https://files.pythonhosted.org/packages/ee/79/545c76dcef0c3f89fb733ec46118aed2a700e79d4e22cb142e3b5a80286c/ty-0.0.28-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0cd44e3c857951cbf3f8647722ca87475614fac8ac0371eb1f200a942315a2c2", size = 11110550 }, + { url = "https://files.pythonhosted.org/packages/2c/e4/e3c6f71c95a2cbabd7d88fd698b00b8af48e39aa10e0b10b839410fc3c6d/ty-0.0.28-py3-none-win32.whl", hash = "sha256:88e2c784ec5e0e2fb01b137d92fd595cdc27b98a553f4bb34b8bf138bac1be1e", size = 9985411 }, + { url = "https://files.pythonhosted.org/packages/8c/e5/79dbab4856d3d15e5173314ff1846be65d58b31de6efe62ef1c25c663b32/ty-0.0.28-py3-none-win_amd64.whl", hash = "sha256:faaffbef127cb67560ad6dbc6a8f8845a4033b818bcc78ad7af923e02df199db", size = 10986548 }, + { url = "https://files.pythonhosted.org/packages/01/b2/cc987aaf5babacc55caf0aeb751c83401e86e05e22ce82dace5a7e7e5354/ty-0.0.28-py3-none-win_arm64.whl", hash = "sha256:34a18ea09ee09612fb6555deccf1eed810e6f770b61a41243b494bcb7f624a1c", size = 10388573 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854 }, +] + +[[package]] +name = "worker-bridge-e2b" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "e2b" }, + { name = "e2b-code-interpreter" }, + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "protobuf" }, + { name = "python-dotenv" }, + { name = "structlog" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "e2b", specifier = ">=2.19.0" }, + { name = "e2b-code-interpreter", specifier = ">=2.6.0" }, + { name = "grpcio", specifier = ">=1.80.0" }, + { name = "grpcio-tools", specifier = ">=1.80.0" }, + { name = "protobuf", specifier = ">=6.31.1" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "structlog", specifier = ">=24.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "ruff", specifier = ">=0.8.0" }, + { name = "ty", specifier = ">=0.0.28" }, +]