From 3d9529a7f05aad0380f88db69626feb4a6a96772 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 13:33:35 +0800 Subject: [PATCH 01/12] feat(worker): add E2B bridge --- .../worker-bridge-e2b/README/config-file.md | 102 + worker/worker-bridge-e2b/README/overview.md | 171 ++ .../cmd/worker-bridge-e2b/main.go | 30 + worker/worker-bridge-e2b/config.example.toml | 49 + worker/worker-bridge-e2b/go.mod | 21 + worker/worker-bridge-e2b/go.sum | 42 + .../internal/buildinfo/buildinfo.go | 7 + .../internal/config/config.go | 241 ++ .../internal/config/source.go | 181 ++ .../internal/config/source_test.go | 158 ++ .../worker-bridge-e2b/internal/e2b/client.go | 426 ++++ .../internal/e2b/client_integration_test.go | 64 + .../internal/e2b/client_test.go | 216 ++ .../internal/e2b/generate.go | 3 + .../internal/e2b/process/v1/process.pb.go | 2057 +++++++++++++++++ .../internal/e2b/process/v1/process.proto | 160 ++ .../v1/processv1connect/process.connect.go | 306 +++ .../internal/logging/logging.go | 70 + .../runner/capability_dispatch_test.go | 136 ++ .../internal/runner/capability_executor.go | 309 +++ .../internal/runner/console_session_test.go | 295 +++ .../internal/runner/e2b_backend.go | 16 + .../runner/full_worker_integration_test.go | 251 ++ .../internal/runner/hello_builder.go | 49 + .../internal/runner/python_runtime.go | 69 + .../runner/python_runtime_integration_test.go | 43 + .../internal/runner/python_runtime_test.go | 120 + .../internal/runner/runner.go | 131 ++ .../internal/runner/session_client.go | 418 ++++ .../internal/runner/session_client_test.go | 106 + .../internal/runner/terminal_exec.go | 447 ++++ .../internal/runner/terminal_resource.go | 250 ++ .../terminal_session_integration_test.go | 298 +++ .../internal/runner/terminal_session_test.go | 682 ++++++ 34 files changed, 7924 insertions(+) create mode 100644 worker/worker-bridge-e2b/README/config-file.md create mode 100644 worker/worker-bridge-e2b/README/overview.md create mode 100644 worker/worker-bridge-e2b/cmd/worker-bridge-e2b/main.go create mode 100644 worker/worker-bridge-e2b/config.example.toml create mode 100644 worker/worker-bridge-e2b/go.mod create mode 100644 worker/worker-bridge-e2b/go.sum create mode 100644 worker/worker-bridge-e2b/internal/buildinfo/buildinfo.go create mode 100644 worker/worker-bridge-e2b/internal/config/config.go create mode 100644 worker/worker-bridge-e2b/internal/config/source.go create mode 100644 worker/worker-bridge-e2b/internal/config/source_test.go create mode 100644 worker/worker-bridge-e2b/internal/e2b/client.go create mode 100644 worker/worker-bridge-e2b/internal/e2b/client_integration_test.go create mode 100644 worker/worker-bridge-e2b/internal/e2b/client_test.go create mode 100644 worker/worker-bridge-e2b/internal/e2b/generate.go create mode 100644 worker/worker-bridge-e2b/internal/e2b/process/v1/process.pb.go create mode 100644 worker/worker-bridge-e2b/internal/e2b/process/v1/process.proto create mode 100644 worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect/process.connect.go create mode 100644 worker/worker-bridge-e2b/internal/logging/logging.go create mode 100644 worker/worker-bridge-e2b/internal/runner/capability_dispatch_test.go create mode 100644 worker/worker-bridge-e2b/internal/runner/capability_executor.go create mode 100644 worker/worker-bridge-e2b/internal/runner/console_session_test.go create mode 100644 worker/worker-bridge-e2b/internal/runner/e2b_backend.go create mode 100644 worker/worker-bridge-e2b/internal/runner/full_worker_integration_test.go create mode 100644 worker/worker-bridge-e2b/internal/runner/hello_builder.go create mode 100644 worker/worker-bridge-e2b/internal/runner/python_runtime.go create mode 100644 worker/worker-bridge-e2b/internal/runner/python_runtime_integration_test.go create mode 100644 worker/worker-bridge-e2b/internal/runner/python_runtime_test.go create mode 100644 worker/worker-bridge-e2b/internal/runner/runner.go create mode 100644 worker/worker-bridge-e2b/internal/runner/session_client.go create mode 100644 worker/worker-bridge-e2b/internal/runner/session_client_test.go create mode 100644 worker/worker-bridge-e2b/internal/runner/terminal_exec.go create mode 100644 worker/worker-bridge-e2b/internal/runner/terminal_resource.go create mode 100644 worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go create mode 100644 worker/worker-bridge-e2b/internal/runner/terminal_session_test.go diff --git a/worker/worker-bridge-e2b/README/config-file.md b/worker/worker-bridge-e2b/README/config-file.md new file mode 100644 index 0000000..64b1f4b --- /dev/null +++ b/worker/worker-bridge-e2b/README/config-file.md @@ -0,0 +1,102 @@ +# E2B Bridge 配置参数 + +worker 按以下顺序查找 `config.toml`: + +1. `WORKER_CONFIG_FILE` 指定的路径;文件缺失或格式错误时启动失败。 +2. worker 可执行文件旁的 `config.toml`。 +3. 当前工作目录的 `config.toml`。 + +单项配置的取值优先级为: + +1. `WORKER_*` 环境变量。 +2. 对应的 E2B 标准环境变量别名。 +3. `config.toml`。 +4. 内置默认值。 + +环境变量只要存在就会覆盖下一层,包括显式设置为空字符串。配置键通常等于环境变量去掉 `WORKER_` 前缀并转为小写,例如 `WORKER_E2B_API_KEY` 对应 `e2b_api_key`。 + +## 身份与 console + +标记为“必填”的参数没有默认值,缺失时 worker 启动失败。 + +| 环境变量 | `config.toml` 键 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_CONFIG_FILE` | — | 自动查找 | 显式指定配置文件路径;文件不存在或格式错误时启动失败 | +| `WORKER_ID` | `id` | 必填 | console 创建 worker 后分配的 ID | +| `WORKER_SECRET` | `secret` | 必填 | hello 认证使用的一次性 worker secret | +| `WORKER_CONSOLE_GRPC_TARGET` | `console_grpc_target` | `127.0.0.1:50051` | console gRPC 地址 | +| `WORKER_CONSOLE_INSECURE` | `console_insecure` | `false` | `true` 时关闭 TLS,仅用于可信开发环境 | +| `WORKER_NODE_NAME` | `node_name` | `worker-bridge-e2b-` | hello 中报告的节点名;空值时自动生成 | +| `WORKER_LABELS` | `[labels]` 或 `labels` | 空 | worker labels;环境变量接受 JSON 对象或逗号分隔的 `key=value` | + +`WORKER_LABELS` 示例: + +```bash +WORKER_LABELS='{"region":"cn","owner":"team-a"}' +WORKER_LABELS='region=cn,owner=team-a' +``` + +对应的 TOML 推荐写法: + +```toml +[labels] +region = "cn" +owner = "team-a" +``` + +## 心跳与调用 + +| 环境变量 | `config.toml` 键 | 默认值 | 约束与说明 | +| --- | --- | --- | --- | +| `WORKER_HEARTBEAT_INTERVAL_SEC` | `heartbeat_interval_sec` | `5` | 正整数;发送 heartbeat 的基础间隔 | +| `WORKER_HEARTBEAT_JITTER_PCT` | `heartbeat_jitter_pct` | `20` | `0`–`100`;heartbeat 间隔的抖动比例 | +| `WORKER_CALL_TIMEOUT_SEC` | `call_timeout_sec` | `ceil(2.5 × heartbeat_interval_sec)` | 正整数;等待 console hello 和 heartbeat ack 的超时,默认配置下为 `13` 秒 | + +## E2B + +| 环境变量 | E2B 环境变量别名 | `config.toml` 键 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `WORKER_E2B_API_KEY` | `E2B_API_KEY` | `e2b_api_key` | 必填 | E2B Control API Key | +| `WORKER_E2B_API_URL` | `E2B_API_URL` | `e2b_api_url` | `https://api.e2b.app` | Control API 基础 URL | +| `WORKER_E2B_DOMAIN` | `E2B_DOMAIN` | `e2b_domain` | `e2b.app` | envd 沙箱域名后缀 | +| `WORKER_E2B_SANDBOX_URL` | `E2B_SANDBOX_URL` | `e2b_sandbox_url` | 空 | 覆盖 envd 沙箱基础 URL,用于自托管、调试或集成测试 | +| `WORKER_E2B_PYTHON_TEMPLATE` | `E2B_PYTHON_EXEC_TEMPLATE` | `e2b_python_template` | 必填 | `pythonExec` 使用的模板 ID 或别名 | +| `WORKER_E2B_TERMINAL_TEMPLATE` | `E2B_TERMINAL_EXEC_TEMPLATE` | `e2b_terminal_template` | 必填 | `terminalExec` 使用的模板 ID 或别名 | +| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | — | `e2b_request_timeout_sec` | `60` | 正整数;E2B Control API 和 envd 请求超时秒数 | +| `WORKER_E2B_PYTHON_TIMEOUT_SEC` | `E2B_SANDBOX_TIMEOUT_SEC` | `e2b_python_timeout_sec` | `300` | 正整数;一次性 Python 沙箱的 E2B timeout 秒数 | + +`WORKER_*` 变量始终优先于同一行的 E2B 别名。API URL 和 sandbox URL 末尾的 `/` 会被移除。 + +## 终端 session 与文件 + +| 环境变量 | `config.toml` 键 | 默认值 | 约束与说明 | +| --- | --- | --- | --- | +| `WORKER_TERMINAL_LEASE_MIN_SEC` | `terminal_lease_min_sec` | `60` | 正整数;请求可指定的最短 lease | +| `WORKER_TERMINAL_LEASE_MAX_SEC` | `terminal_lease_max_sec` | `1800` | 正整数;小于最小值时自动提高到最小值 | +| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | `terminal_lease_default_sec` | `60` | 正整数;自动限制在最小值与最大值之间 | +| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | `terminal_output_limit_bytes` | `1048576` | 正整数;分别限制 stdout、stderr 和 `terminalResource.read` | +| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | `terminal_export_max_bytes` | `0` | 非负整数;`0` 表示不限制导出大小 | +| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | `terminal_session_max_inflight` | `1` | 正整数;同一 session 内 `terminalExec` 与 `terminalResource` 共享的并发上限 | + +## 能力并发 + +以下参数限制整个 worker 同时处理某项能力的数量,并通过 hello 的 `max_inflight` 报告给 console。它们与单 session 并发限制同时生效。 + +| 环境变量 | `config.toml` 键 | 默认值 | 约束 | +| --- | --- | --- | --- | +| `WORKER_ECHO_MAX_INFLIGHT` | `echo_max_inflight` | `4` | 正整数 | +| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | `python_exec_max_inflight` | `4` | 正整数 | +| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | `terminal_exec_max_inflight` | `4` | 正整数 | +| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | `terminal_resource_max_inflight` | `4` | 正整数 | + +## 日志 + +| 环境变量 | `config.toml` 键 | 默认值 | 可选值与说明 | +| --- | --- | --- | --- | +| `WORKER_LOG_LEVEL` | `log_level` | `info` | `debug`、`info`、`warn`、`error` | +| `WORKER_LOG_FORMAT` | `log_format` | `json` | `json`、`text` | +| `WORKER_LOG_ADD_SOURCE` | `log_add_source` | `false` | 是否记录源码位置;布尔值接受 `1/0`、`true/false`、`yes/no`、`on/off` | + +正整数、百分比、枚举或布尔值无效时使用该项默认值。`WORKER_TERMINAL_EXPORT_MAX_BYTES` 的负数也会回退为 `0`。 + +配置文件中包含 worker secret 或 E2B API Key 时,应设置为仅运行用户可读,并且不要提交到版本库。可复制根目录的 [`config.example.toml`](../config.example.toml) 作为起点。 diff --git a/worker/worker-bridge-e2b/README/overview.md b/worker/worker-bridge-e2b/README/overview.md new file mode 100644 index 0000000..6da6b92 --- /dev/null +++ b/worker/worker-bridge-e2b/README/overview.md @@ -0,0 +1,171 @@ +# E2B Bridge Worker + +`worker-bridge-e2b` 是 Onlyboxes 的远程 E2B 执行节点。worker 连接 console 的 `WorkerRegistryService.Connect` 双向流,发送身份、能力和心跳,并在收到任务后调用 E2B 控制 API 与沙箱内的 envd。 + +## 配置入口 + +启动至少需要 `WORKER_ID`、`WORKER_SECRET`、`WORKER_E2B_API_KEY`、`WORKER_E2B_PYTHON_TEMPLATE` 和 `WORKER_E2B_TERMINAL_TEMPLATE`。E2B 标准环境变量别名、全部可选参数、默认值和 `config.toml` 键见[完整配置参数参考](config-file.md),可直接复制根目录的 [`config.example.toml`](../config.example.toml)。 + +console gRPC 默认启用 TLS。只有在可信内网开发环境中才应设置 `WORKER_CONSOLE_INSECURE=true`;明文连接会暴露 hello 中的一次性 worker secret。 + +## E2B 通信 + +- 沙箱创建、TTL 更新和销毁使用 E2B Control API。 +- 命令执行使用 envd 的 Connect RPC `process.Process/Start`,端口为 E2B 官方协议固定的 `49983`。 +- 文件读取使用 envd HTTP `GET /files`。 +- E2B API Key 只发送给 Control API;envd 使用创建响应里的独立 access token。 +- 日志不记录 API Key、envd access token、原始代码、命令或文件内容。 + +Go 客户端根据 E2B 的公开 OpenAPI 与 envd protobuf 实现。E2B 当前没有官方 Go SDK。 + +## 能力 + +worker 在 hello 中声明以下四项能力: + +| 工具 | 用途 | 沙箱生命周期 | +| --- | --- | --- | +| `echo` | 检查 console 与 worker 的调用链路 | 不创建沙箱 | +| `pythonExec` | 在 Python 模板中执行一次代码 | 每次调用创建并销毁 | +| `terminalExec` | 创建或复用终端 session 执行命令 | 按 lease 复用 | +| `terminalResource` | 校验、读取或导出终端 session 中的文件 | 复用已有 session | + +请求和返回值都是 JSON 对象。未知字段会被忽略;缺少必填字段时返回 `invalid_payload`。 + +### echo + +请求与返回字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `message` | string | 是 | 要原样返回的非空文本 | + +示例:`{"message":"ping"}` 返回 `{"message":"ping"}`。 + +### pythonExec + +每次请求创建独立的 Python 模板沙箱,将代码写入 `/tmp/onlyboxes-pythonexec.py`,通过 `uv run` 执行,随后销毁沙箱。模板必须提供 `uv` 与 Python,并可使用 PEP 723 内联依赖。 + +请求字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `code` | string | 是 | 要执行的非空 Python 源码 | + +返回字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `output` | string | 标准输出 | +| `stderr` | string | 标准错误 | +| `exit_code` | number | 进程退出码 | + +```json +{"output":"...","stderr":"...","exit_code":0} +``` + +非零退出码属于执行结果,不会被改写为 worker 传输错误。命令 deadline 取消后,worker 仍会使用独立的短超时请求销毁沙箱。 + +### terminalExec + +请求字段: + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `command` | string | 是 | — | 由 `/bin/bash -l -c` 执行的非空命令 | +| `session_id` | string | 否 | 空 | 空值时创建新 session;非空时复用指定 session | +| `create_if_missing` | boolean | 否 | `false` | 指定的 session 不存在时是否创建 | +| `lease_ttl_sec` | number | 否 | `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | session lease,必须位于配置的最小值和最大值之间 | + +返回字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `session_id` | string | Onlyboxes session ID | +| `created` | boolean | 本次调用是否创建了 session | +| `stdout` | string | 标准输出 | +| `stderr` | string | 标准错误 | +| `exit_code` | number | 进程退出码 | +| `stdout_truncated` | boolean | 标准输出是否因大小限制被截断 | +| `stderr_truncated` | boolean | 标准错误是否因大小限制被截断 | +| `lease_expires_unix_ms` | number | 当前 lease 到期时间,Unix 毫秒 | + +请求示例: + +```json +{"command":"pwd","session_id":"optional","create_if_missing":false,"lease_ttl_sec":60} +``` + +- 未提供 `session_id` 时创建新 E2B 沙箱和 Onlyboxes session。 +- 相同 `session_id` 复用沙箱及其文件系统。 +- 未知 session 返回 `session_not_found`,除非 `create_if_missing=true`。 +- lease 只会延长,不会被较短的 TTL 缩短;worker 同步调用 E2B timeout API,避免本地 lease 长于 E2B 沙箱寿命。 +- 单 session 并发由 `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` 控制,并与 `terminalResource` 共用。 +- 达到并发上限返回 `session_busy`。 +- 创建中的 session 会阻塞后续调用,所有等待者共享创建结果。 +- 某个命令超时会把 session 标记为待销毁;已有并发命令继续完成,最后一个调用退出后才销毁沙箱。 +- 空闲 session 到期后由 janitor 销毁;worker 正常退出时销毁所有仍管理的沙箱。 + +每个命令由独立的 `/bin/bash -l -c` 进程执行,因此共享文件系统,但不共享 cwd、shell 变量或当前进程环境。 + +### terminalResource + +请求字段: + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `session_id` | string | 是 | — | 已存在的终端 session ID | +| `file_path` | string | 是 | — | session 沙箱内的文件路径 | +| `action` | string | 否 | `validate` | `validate`、`read` 或 `export`,不区分大小写 | +| `signed_url` | string | 仅 `export` | 空 | 接收文件的 HTTP 预签名 URL | +| `headers` | object | 否 | 空 | `export` 上传时附加的 HTTP 请求头 | + +返回字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `session_id` | string | 终端 session ID | +| `file_path` | string | 被处理的文件路径 | +| `mime_type` | string | 推断出的 MIME 类型;无法识别时为 `application/octet-stream` | +| `size_bytes` | number | 文件大小 | +| `blob` | string | 仅 `read` 返回;文件字节经过 JSON base64 编码 | + +请求示例: + +```json +{"session_id":"required","file_path":"required","action":"validate|read|export","signed_url":"export required"} +``` + +- `validate` 返回 MIME 类型与大小。 +- `read` 返回 `blob`;JSON 编码后表现为 base64。 +- `read` 大小上限为 `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES`。 +- `export` 从 E2B 流式下载文件,再通过 HTTP `PUT` 上传到 console 提供的预签名 URL。 +- `WORKER_TERMINAL_EXPORT_MAX_BYTES=0` 表示不限制导出大小。 +- 终端模板必须提供 `python3`,用于安全地探测文件类型、大小和目录状态。 + +领域错误为 `file_not_found`、`path_is_directory` 和 `file_too_large`。 + +## 会话与心跳 + +- hello 声明 `echo`、`pythonExec`、`terminalExec`、`terminalResource`,每项都有独立的 `max_inflight`。 +- heartbeat 报告 `active_session_count`。 +- worker 容忍一次 heartbeat ack 超时,连续两次超时后重连。 +- session 被 console 替换并返回 `FailedPrecondition` 时立即进入重连流程。 +- 其余断线使用最长 `15` 秒的指数退避。 +- 版本只来自构建期注入,开发构建报告 `dev`,运行时不能覆盖。 + +## 本地验证 + +普通测试不访问 E2B: + +```bash +go test -race ./... +``` + +真实 E2B 冒烟会创建并自动销毁一个终端模板沙箱: + +```bash +E2B_INTEGRATION=1 \ +E2B_API_KEY=... \ +E2B_TERMINAL_TEMPLATE=... \ +go test ./internal/e2b -run TestIntegrationSandboxCommandAndFile -count=1 -v +``` diff --git a/worker/worker-bridge-e2b/cmd/worker-bridge-e2b/main.go b/worker/worker-bridge-e2b/cmd/worker-bridge-e2b/main.go new file mode 100644 index 0000000..b1c6394 --- /dev/null +++ b/worker/worker-bridge-e2b/cmd/worker-bridge-e2b/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "errors" + "os/signal" + "syscall" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/runner" +) + +func main() { + cfg := config.Load() + logging.Configure(cfg.LogLevel, cfg.LogFormat, cfg.LogAddSource) + if cfg.ConfigFile != "" { + logging.Infof("config file loaded: %s", cfg.ConfigFile) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := runner.Run(ctx, cfg); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + logging.Infof("worker stopped: %v", err) + return + } + logging.Fatalf("worker stopped with error: %v", err) + } +} diff --git a/worker/worker-bridge-e2b/config.example.toml b/worker/worker-bridge-e2b/config.example.toml new file mode 100644 index 0000000..e04aa5c --- /dev/null +++ b/worker/worker-bridge-e2b/config.example.toml @@ -0,0 +1,49 @@ +# worker-bridge-e2b configuration. +# Copy to config.toml next to the binary, or set WORKER_CONFIG_FILE. +# Environment variables override values in this file. + +# Identity returned by console when creating a worker. +id = "" +secret = "" + +# Console connection. Keep TLS enabled outside trusted local development. +console_grpc_target = "127.0.0.1:50051" +console_insecure = false +node_name = "" + +heartbeat_interval_sec = 5 +heartbeat_jitter_pct = 20 +# Defaults to ceil(2.5 * heartbeat_interval_sec). +# call_timeout_sec = 13 + +# E2B credentials and templates. Both templates are required. +e2b_api_key = "" +e2b_api_url = "https://api.e2b.app" +e2b_domain = "e2b.app" +e2b_python_template = "" +e2b_terminal_template = "" +e2b_request_timeout_sec = 60 +e2b_python_timeout_sec = 300 +# Optional override for self-hosted E2B or integration tests. +# e2b_sandbox_url = "https://sandbox.e2b.app" + +terminal_lease_min_sec = 60 +terminal_lease_max_sec = 1800 +terminal_lease_default_sec = 60 +terminal_output_limit_bytes = 1048576 +# 0 means no export size limit. +terminal_export_max_bytes = 0 +terminal_session_max_inflight = 1 + +echo_max_inflight = 4 +python_exec_max_inflight = 4 +terminal_exec_max_inflight = 4 +terminal_resource_max_inflight = 4 + +log_level = "info" +log_format = "json" +log_add_source = false + +[labels] +# region = "cn" +# owner = "team-a" diff --git a/worker/worker-bridge-e2b/go.mod b/worker/worker-bridge-e2b/go.mod new file mode 100644 index 0000000..95db34c --- /dev/null +++ b/worker/worker-bridge-e2b/go.mod @@ -0,0 +1,21 @@ +module github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b + +go 1.24.0 + +require ( + connectrpc.com/connect v1.19.1 + github.com/BurntSushi/toml v1.5.0 + github.com/google/uuid v1.6.0 + github.com/onlyboxes/onlyboxes/api v0.0.0 + google.golang.org/grpc v1.79.1 + google.golang.org/protobuf v1.36.10 +) + +require ( + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect +) + +replace github.com/onlyboxes/onlyboxes/api => ../../api diff --git a/worker/worker-bridge-e2b/go.sum b/worker/worker-bridge-e2b/go.sum new file mode 100644 index 0000000..bfcb073 --- /dev/null +++ b/worker/worker-bridge-e2b/go.sum @@ -0,0 +1,42 @@ +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/worker/worker-bridge-e2b/internal/buildinfo/buildinfo.go b/worker/worker-bridge-e2b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..1d140c1 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/buildinfo/buildinfo.go @@ -0,0 +1,7 @@ +package buildinfo + +const defaultVersion = "dev" + +// Version is injected at build time via: +// go build -ldflags "-X github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/buildinfo.Version=" +var Version = defaultVersion diff --git a/worker/worker-bridge-e2b/internal/config/config.go b/worker/worker-bridge-e2b/internal/config/config.go new file mode 100644 index 0000000..b29a92d --- /dev/null +++ b/worker/worker-bridge-e2b/internal/config/config.go @@ -0,0 +1,241 @@ +package config + +import ( + "encoding/json" + "strconv" + "strings" + "time" +) + +const ( + defaultConsoleTarget = "127.0.0.1:50051" + defaultHeartbeatInterval = 5 + defaultHeartbeatJitter = 20 + defaultExecutorKind = "e2b" + defaultE2BAPIURL = "https://api.e2b.app" + defaultE2BDomain = "e2b.app" + defaultE2BRequestTimeout = 60 + defaultE2BPythonTimeout = 300 + defaultTerminalLeaseMin = 60 + defaultTerminalLeaseMax = 1800 + defaultTerminalLeaseTTL = 60 + defaultTerminalOutputMax = 1024 * 1024 + defaultTerminalSessionInflight = 1 + defaultLogLevel = "info" + defaultLogFormat = "json" + defaultMaxInflight = 4 +) + +type Config struct { + ConfigFile string + ConsoleGRPCTarget string + ConsoleTLS bool + WorkerID string + WorkerSecret string + HeartbeatInterval time.Duration + HeartbeatJitter int + CallTimeout time.Duration + NodeName string + ExecutorKind string + Labels map[string]string + E2BAPIKey string + E2BAPIURL string + E2BDomain string + E2BSandboxURL string + E2BPythonTemplate string + E2BTerminalTemplate string + E2BRequestTimeout time.Duration + E2BPythonTimeoutSec int + TerminalLeaseMinSec int + TerminalLeaseMaxSec int + TerminalLeaseDefaultSec int + TerminalOutputLimitBytes int + TerminalExportMaxBytes int + TerminalSessionMaxInflight int + EchoMaxInflight int + PythonExecMaxInflight int + TerminalExecMaxInflight int + TerminalResourceMaxInflight int + LogLevel string + LogFormat string + LogAddSource bool +} + +func Load() Config { + src := newSource() + heartbeatSec := src.positiveInt("WORKER_HEARTBEAT_INTERVAL_SEC", defaultHeartbeatInterval) + heartbeatJitter := src.percent("WORKER_HEARTBEAT_JITTER_PCT", defaultHeartbeatJitter) + callTimeoutSec := src.positiveInt("WORKER_CALL_TIMEOUT_SEC", defaultCallTimeoutSec(heartbeatSec)) + terminalLeaseMinSec := src.positiveInt("WORKER_TERMINAL_LEASE_MIN_SEC", defaultTerminalLeaseMin) + terminalLeaseMaxSec := src.positiveInt("WORKER_TERMINAL_LEASE_MAX_SEC", defaultTerminalLeaseMax) + if terminalLeaseMaxSec < terminalLeaseMinSec { + terminalLeaseMaxSec = terminalLeaseMinSec + } + terminalLeaseDefaultSec := clampInt( + src.positiveInt("WORKER_TERMINAL_LEASE_DEFAULT_SEC", defaultTerminalLeaseTTL), + terminalLeaseMinSec, + terminalLeaseMaxSec, + ) + + e2bAPIKey := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_API_KEY", "E2B_API_KEY")) + e2bAPIURL := strings.TrimRight(strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_API_URL", "E2B_API_URL")), "/") + if e2bAPIURL == "" { + e2bAPIURL = defaultE2BAPIURL + } + e2bDomain := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_DOMAIN", "E2B_DOMAIN")) + if e2bDomain == "" { + e2bDomain = defaultE2BDomain + } + pythonTemplate := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_PYTHON_TEMPLATE", "E2B_PYTHON_EXEC_TEMPLATE")) + terminalTemplate := strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_TERMINAL_TEMPLATE", "E2B_TERMINAL_EXEC_TEMPLATE")) + + return Config{ + ConfigFile: src.Path(), + ConsoleGRPCTarget: src.stringValue("WORKER_CONSOLE_GRPC_TARGET", defaultConsoleTarget), + ConsoleTLS: !src.boolValue("WORKER_CONSOLE_INSECURE", false), + WorkerID: strings.TrimSpace(src.get("WORKER_ID")), + WorkerSecret: strings.TrimSpace(src.get("WORKER_SECRET")), + HeartbeatInterval: time.Duration(heartbeatSec) * time.Second, + HeartbeatJitter: heartbeatJitter, + CallTimeout: time.Duration(callTimeoutSec) * time.Second, + NodeName: strings.TrimSpace(src.get("WORKER_NODE_NAME")), + ExecutorKind: defaultExecutorKind, + Labels: parseLabels(src.get("WORKER_LABELS")), + E2BAPIKey: e2bAPIKey, + E2BAPIURL: e2bAPIURL, + E2BDomain: e2bDomain, + E2BSandboxURL: strings.TrimRight(strings.TrimSpace(src.getWithEnvAliases("WORKER_E2B_SANDBOX_URL", "E2B_SANDBOX_URL")), "/"), + E2BPythonTemplate: pythonTemplate, + E2BTerminalTemplate: terminalTemplate, + E2BRequestTimeout: time.Duration(src.positiveInt("WORKER_E2B_REQUEST_TIMEOUT_SEC", defaultE2BRequestTimeout)) * time.Second, + E2BPythonTimeoutSec: positiveIntValue(src.getWithEnvAliases("WORKER_E2B_PYTHON_TIMEOUT_SEC", "E2B_SANDBOX_TIMEOUT_SEC"), defaultE2BPythonTimeout), + TerminalLeaseMinSec: terminalLeaseMinSec, + TerminalLeaseMaxSec: terminalLeaseMaxSec, + TerminalLeaseDefaultSec: terminalLeaseDefaultSec, + TerminalOutputLimitBytes: src.positiveInt("WORKER_TERMINAL_OUTPUT_LIMIT_BYTES", defaultTerminalOutputMax), + TerminalExportMaxBytes: src.nonNegativeInt("WORKER_TERMINAL_EXPORT_MAX_BYTES", 0), + TerminalSessionMaxInflight: src.positiveInt("WORKER_TERMINAL_SESSION_MAX_INFLIGHT", defaultTerminalSessionInflight), + EchoMaxInflight: src.positiveInt("WORKER_ECHO_MAX_INFLIGHT", defaultMaxInflight), + PythonExecMaxInflight: src.positiveInt("WORKER_PYTHON_EXEC_MAX_INFLIGHT", defaultMaxInflight), + TerminalExecMaxInflight: src.positiveInt("WORKER_TERMINAL_EXEC_MAX_INFLIGHT", defaultMaxInflight), + TerminalResourceMaxInflight: src.positiveInt("WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT", defaultMaxInflight), + LogLevel: src.logLevel("WORKER_LOG_LEVEL", defaultLogLevel), + LogFormat: src.logFormat("WORKER_LOG_FORMAT", defaultLogFormat), + LogAddSource: src.boolValue("WORKER_LOG_ADD_SOURCE", false), + } +} + +func positiveIntValue(raw string, fallback int) int { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func (s source) stringValue(key, fallback string) string { + if value := strings.TrimSpace(s.get(key)); value != "" { + return value + } + return fallback +} + +func (s source) positiveInt(key string, fallback int) int { + value, err := strconv.Atoi(strings.TrimSpace(s.get(key))) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func (s source) nonNegativeInt(key string, fallback int) int { + value, err := strconv.Atoi(strings.TrimSpace(s.get(key))) + if err != nil || value < 0 { + return fallback + } + return value +} + +func (s source) percent(key string, fallback int) int { + value, err := strconv.Atoi(strings.TrimSpace(s.get(key))) + if err != nil || value < 0 || value > 100 { + return fallback + } + return value +} + +func (s source) boolValue(key string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(s.get(key))) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +func (s source) logLevel(key, fallback string) string { + switch value := strings.ToLower(strings.TrimSpace(s.get(key))); value { + case "debug", "info", "warn", "error": + return value + default: + return fallback + } +} + +func (s source) logFormat(key, fallback string) string { + switch value := strings.ToLower(strings.TrimSpace(s.get(key))); value { + case "json", "text": + return value + default: + return fallback + } +} + +func defaultCallTimeoutSec(heartbeatSec int) int { + if heartbeatSec <= 0 { + heartbeatSec = defaultHeartbeatInterval + } + return (heartbeatSec*5 + 1) / 2 +} + +func parseLabels(raw string) map[string]string { + if strings.TrimSpace(raw) == "" { + return map[string]string{} + } + if strings.HasPrefix(strings.TrimSpace(raw), "{") { + labels := map[string]string{} + if json.Unmarshal([]byte(raw), &labels) == nil { + return normalizeLabels(labels) + } + } + labels := map[string]string{} + for _, item := range strings.Split(raw, ",") { + parts := strings.SplitN(strings.TrimSpace(item), "=", 2) + if len(parts) == 2 && strings.TrimSpace(parts[0]) != "" { + labels[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + return labels +} + +func normalizeLabels(raw map[string]string) map[string]string { + labels := make(map[string]string, len(raw)) + for key, value := range raw { + if key = strings.TrimSpace(key); key != "" { + labels[key] = strings.TrimSpace(value) + } + } + return labels +} + +func clampInt(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} diff --git a/worker/worker-bridge-e2b/internal/config/source.go b/worker/worker-bridge-e2b/internal/config/source.go new file mode 100644 index 0000000..303cc2d --- /dev/null +++ b/worker/worker-bridge-e2b/internal/config/source.go @@ -0,0 +1,181 @@ +package config + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/BurntSushi/toml" +) + +const ( + configFileName = "config.toml" + configFileEnvKey = "WORKER_CONFIG_FILE" + envPrefix = "WORKER_" +) + +// source resolves configuration values from environment variables first and +// falls back to the values declared in config.toml. +type source struct { + path string + values map[string]string +} + +func newSource() source { + path, explicit := configFilePath() + if path == "" { + return source{values: map[string]string{}} + } + + raw := map[string]any{} + if _, err := toml.DecodeFile(path, &raw); err != nil { + if os.IsNotExist(err) && !explicit { + return source{values: map[string]string{}} + } + log.Fatalf("failed to load config file %s: %v", path, err) + } + + return source{path: path, values: flatten(raw)} +} + +// configFilePath returns the config file to load and whether it was requested +// explicitly through WORKER_CONFIG_FILE. +func configFilePath() (string, bool) { + if explicit := strings.TrimSpace(os.Getenv(configFileEnvKey)); explicit != "" { + return explicit, true + } + + if executable, err := os.Executable(); err == nil { + if resolved, err := filepath.EvalSymlinks(executable); err == nil { + executable = resolved + } + candidate := filepath.Join(filepath.Dir(executable), configFileName) + if fileExists(candidate) { + return candidate, false + } + } + + if fileExists(configFileName) { + return configFileName, false + } + + return "", false +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +// Path returns the loaded config file path, empty when no file was used. +func (s source) Path() string { + return s.path +} + +// get returns the value for an environment variable key, falling back to the +// matching config file key (env key without the WORKER_ prefix, lowercased). +func (s source) get(envKey string) string { + if value, ok := os.LookupEnv(envKey); ok { + return value + } + return s.values[fileKey(envKey)] +} + +// getWithEnvAliases keeps config.toml mapped to the canonical WORKER_ key, +// while accepting provider-standard environment variable names. +func (s source) getWithEnvAliases(envKey string, aliases ...string) string { + if value, ok := os.LookupEnv(envKey); ok { + return value + } + for _, alias := range aliases { + if value, ok := os.LookupEnv(alias); ok { + return value + } + } + return s.values[fileKey(envKey)] +} + +func fileKey(envKey string) string { + return strings.ToLower(strings.TrimPrefix(envKey, envPrefix)) +} + +// flatten converts TOML values into the same string form accepted by the +// environment variable parsers. Nested tables are additionally exposed as +// `parent_child` keys so that grouped sections map onto flat env names. +func flatten(raw map[string]any) map[string]string { + values := make(map[string]string, len(raw)) + flattenInto(values, "", raw) + return values +} + +func flattenInto(values map[string]string, prefix string, table map[string]any) { + for key, value := range table { + fullKey := strings.ToLower(key) + if prefix != "" { + fullKey = prefix + "_" + fullKey + } + if nested, ok := value.(map[string]any); ok { + flattenInto(values, fullKey, nested) + } + if encoded, ok := encodeValue(value); ok { + values[fullKey] = encoded + } + } +} + +func encodeValue(value any) (string, bool) { + switch typed := value.(type) { + case string: + return typed, true + case bool: + return strconv.FormatBool(typed), true + case int64: + return strconv.FormatInt(typed, 10), true + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64), true + case []any: + return encodeArray(typed) + case map[string]any: + return encodeTable(typed), true + default: + return "", false + } +} + +// encodeArray renders TOML arrays as JSON arrays of strings. +func encodeArray(items []any) (string, bool) { + encoded := make([]string, 0, len(items)) + for _, item := range items { + value, ok := encodeValue(item) + if !ok { + return "", false + } + encoded = append(encoded, value) + } + payload, err := json.Marshal(encoded) + if err != nil { + return "", false + } + return string(payload), true +} + +// encodeTable renders TOML tables as JSON objects so delimiters in keys and +// values remain unambiguous. +func encodeTable(table map[string]any) string { + encoded := make(map[string]string, len(table)) + for key, rawValue := range table { + value, ok := encodeValue(rawValue) + if !ok { + continue + } + encoded[key] = value + } + payload, err := json.Marshal(encoded) + if err != nil { + return "{}" + } + return string(payload) +} diff --git a/worker/worker-bridge-e2b/internal/config/source_test.go b/worker/worker-bridge-e2b/internal/config/source_test.go new file mode 100644 index 0000000..ef05990 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/config/source_test.go @@ -0,0 +1,158 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeConfigFile(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), configFileName) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + t.Setenv(configFileEnvKey, path) + return path +} + +func TestLoadReadsConfigFile(t *testing.T) { + writeConfigFile(t, ` +console_grpc_target = "10.0.0.1:50051" +console_insecure = true +id = "worker-1" +secret = "s3cret" +heartbeat_interval_sec = 7 +e2b_api_key = "test-api-key" +e2b_python_template = "python-template" +e2b_terminal_template = "terminal-template" +e2b_request_timeout_sec = 12 +log_level = "debug" +log_add_source = true + +[labels] +region = "cn" +owner = "team-a" +description = "gpu,shared" +`) + + cfg := Load() + if cfg.ConsoleGRPCTarget != "10.0.0.1:50051" { + t.Fatalf("unexpected console target %q", cfg.ConsoleGRPCTarget) + } + if cfg.ConsoleTLS { + t.Fatalf("expected console TLS disabled by console_insecure=true") + } + if cfg.WorkerID != "worker-1" || cfg.WorkerSecret != "s3cret" { + t.Fatalf("unexpected identity %q/%q", cfg.WorkerID, cfg.WorkerSecret) + } + if cfg.HeartbeatInterval != 7*time.Second { + t.Fatalf("unexpected heartbeat interval %s", cfg.HeartbeatInterval) + } + if cfg.CallTimeout != 18*time.Second { + t.Fatalf("expected dynamic call timeout 18s, got %s", cfg.CallTimeout) + } + if cfg.E2BAPIKey != "test-api-key" { + t.Fatalf("unexpected E2B API key") + } + if cfg.E2BPythonTemplate != "python-template" || cfg.E2BTerminalTemplate != "terminal-template" { + t.Fatalf("unexpected E2B templates %q/%q", cfg.E2BPythonTemplate, cfg.E2BTerminalTemplate) + } + if cfg.E2BRequestTimeout != 12*time.Second { + t.Fatalf("unexpected E2B request timeout %s", cfg.E2BRequestTimeout) + } + if cfg.LogLevel != "debug" || !cfg.LogAddSource { + t.Fatalf("unexpected log config %q/%t", cfg.LogLevel, cfg.LogAddSource) + } + if cfg.Labels["region"] != "cn" || cfg.Labels["owner"] != "team-a" || cfg.Labels["description"] != "gpu,shared" { + t.Fatalf("unexpected labels %v", cfg.Labels) + } +} + +func TestEnvOverridesConfigFile(t *testing.T) { + writeConfigFile(t, ` +console_grpc_target = "10.0.0.1:50051" +log_level = "debug" +`) + t.Setenv("WORKER_CONSOLE_GRPC_TARGET", "127.0.0.1:60051") + t.Setenv("WORKER_LOG_LEVEL", "warn") + + cfg := Load() + if cfg.ConsoleGRPCTarget != "127.0.0.1:60051" { + t.Fatalf("expected env override, got %q", cfg.ConsoleGRPCTarget) + } + if cfg.LogLevel != "warn" { + t.Fatalf("expected env override, got %q", cfg.LogLevel) + } +} + +func TestEmptyEnvOverridesConfigFile(t *testing.T) { + writeConfigFile(t, ` +console_grpc_target = "10.0.0.1:50051" +secret = "from-file" + +[labels] +region = "cn" +`) + t.Setenv("WORKER_CONSOLE_GRPC_TARGET", "") + t.Setenv("WORKER_SECRET", "") + t.Setenv("WORKER_LABELS", "") + + cfg := Load() + if cfg.ConsoleGRPCTarget != defaultConsoleTarget { + t.Fatalf("expected empty env to select the default target, got %q", cfg.ConsoleGRPCTarget) + } + if cfg.WorkerSecret != "" { + t.Fatalf("expected empty env to clear worker secret, got %q", cfg.WorkerSecret) + } + if len(cfg.Labels) != 0 { + t.Fatalf("expected empty env to clear labels, got %v", cfg.Labels) + } +} + +func TestLoadReportsConfigFilePath(t *testing.T) { + path := writeConfigFile(t, "log_format = \"text\"\n") + + cfg := Load() + if cfg.ConfigFile != path { + t.Fatalf("expected config file path %q, got %q", path, cfg.ConfigFile) + } + if cfg.LogFormat != "text" { + t.Fatalf("unexpected log format %q", cfg.LogFormat) + } +} + +func TestE2BStandardEnvironmentAliasesOverrideConfigFile(t *testing.T) { + writeConfigFile(t, ` +e2b_api_key = "from-file" +e2b_python_template = "python-from-file" +e2b_terminal_template = "terminal-from-file" +`) + t.Setenv("E2B_API_KEY", "from-env") + t.Setenv("E2B_PYTHON_EXEC_TEMPLATE", "python-from-env") + t.Setenv("E2B_TERMINAL_EXEC_TEMPLATE", "terminal-from-env") + t.Setenv("E2B_SANDBOX_TIMEOUT_SEC", "420") + + cfg := Load() + if cfg.E2BAPIKey != "from-env" { + t.Fatalf("expected standard E2B API key alias") + } + if cfg.E2BPythonTemplate != "python-from-env" || cfg.E2BTerminalTemplate != "terminal-from-env" { + t.Fatalf("unexpected aliased templates %q/%q", cfg.E2BPythonTemplate, cfg.E2BTerminalTemplate) + } + if cfg.E2BPythonTimeoutSec != 420 { + t.Fatalf("unexpected aliased sandbox timeout %d", cfg.E2BPythonTimeoutSec) + } +} + +func TestCanonicalWorkerE2BEnvironmentWinsOverAlias(t *testing.T) { + writeConfigFile(t, `e2b_api_key = "from-file"`) + t.Setenv("E2B_API_KEY", "from-alias") + t.Setenv("WORKER_E2B_API_KEY", "from-worker") + + if got := Load().E2BAPIKey; got != "from-worker" { + t.Fatalf("expected canonical worker variable, got %q", got) + } +} diff --git a/worker/worker-bridge-e2b/internal/e2b/client.go b/worker/worker-bridge-e2b/internal/e2b/client.go new file mode 100644 index 0000000..5a09a86 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/e2b/client.go @@ -0,0 +1,426 @@ +package e2b + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "time" + + "connectrpc.com/connect" + processv1 "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect" +) + +const envdPort = 49983 + +type Config struct { + APIKey string + APIURL string + Domain string + SandboxURL string + RequestTimeout time.Duration +} + +type Client struct { + apiKey string + apiURL string + domain string + sandboxURL string + controlHTTP *http.Client + sandboxHTTP *http.Client +} + +type Sandbox struct { + ID string + Domain string + EnvdVersion string + AccessToken string +} + +type CommandResult struct { + Stdout string + Stderr string + ExitCode int + StdoutTruncated bool + StderrTruncated bool +} + +type File struct { + Content []byte + MIMEType string + Size int64 +} + +type FileReader struct { + Body io.ReadCloser + MIMEType string + Size int64 +} + +type apiError struct { + Message string `json:"message"` + Code int `json:"code"` +} + +type HTTPError struct { + StatusCode int + Status string + Message string +} + +func (e *HTTPError) Error() string { + if e == nil { + return "E2B API request failed" + } + return fmt.Sprintf("E2B API returned %s: %s", e.Status, e.Message) +} + +func NewClient(cfg Config) (*Client, error) { + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, errors.New("E2B API key is required") + } + apiURL := strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/") + if apiURL == "" { + apiURL = "https://api.e2b.app" + } + domain := strings.TrimSpace(cfg.Domain) + if domain == "" { + domain = "e2b.app" + } + requestTimeout := cfg.RequestTimeout + if requestTimeout <= 0 { + requestTimeout = 60 * time.Second + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ForceAttemptHTTP2 = true + return &Client{ + apiKey: strings.TrimSpace(cfg.APIKey), + apiURL: apiURL, + domain: domain, + sandboxURL: strings.TrimRight(strings.TrimSpace(cfg.SandboxURL), "/"), + controlHTTP: &http.Client{Transport: transport.Clone(), Timeout: requestTimeout}, + sandboxHTTP: &http.Client{Transport: transport}, + }, nil +} + +func (c *Client) Create(ctx context.Context, template string, timeoutSec int) (*Sandbox, error) { + template = strings.TrimSpace(template) + if template == "" { + return nil, errors.New("E2B template is required") + } + if timeoutSec <= 0 { + return nil, errors.New("E2B sandbox timeout must be positive") + } + body := struct { + TemplateID string `json:"templateID"` + Timeout int `json:"timeout"` + AutoPause bool `json:"autoPause"` + Secure bool `json:"secure"` + AllowInternetAccess bool `json:"allow_internet_access"` + Metadata map[string]string `json:"metadata"` + EnvVars map[string]string `json:"envVars"` + }{ + TemplateID: template, + Timeout: timeoutSec, + AutoPause: false, + Secure: true, + AllowInternetAccess: true, + Metadata: map[string]string{"onlyboxes.worker": "worker-bridge-e2b"}, + EnvVars: map[string]string{}, + } + var response struct { + SandboxID string `json:"sandboxID"` + EnvdVersion string `json:"envdVersion"` + EnvdAccessToken string `json:"envdAccessToken"` + Domain *string `json:"domain"` + } + if err := c.controlJSON(ctx, http.MethodPost, "/sandboxes", body, &response); err != nil { + return nil, err + } + domain := c.domain + if response.Domain != nil && strings.TrimSpace(*response.Domain) != "" { + domain = strings.TrimSpace(*response.Domain) + } + if strings.TrimSpace(response.SandboxID) == "" { + return nil, errors.New("E2B create response did not include sandboxID") + } + return &Sandbox{ + ID: response.SandboxID, + Domain: domain, + EnvdVersion: response.EnvdVersion, + AccessToken: response.EnvdAccessToken, + }, nil +} + +func (c *Client) SetTimeout(ctx context.Context, sandboxID string, timeoutSec int) error { + if timeoutSec <= 0 { + return errors.New("E2B sandbox timeout must be positive") + } + path := "/sandboxes/" + url.PathEscape(strings.TrimSpace(sandboxID)) + "/timeout" + err := c.controlJSON(ctx, http.MethodPost, path, struct { + Timeout int `json:"timeout"` + }{Timeout: timeoutSec}, nil) + if isHTTPStatus(err, http.StatusNotFound) { + return fmt.Errorf("%w: %v", ErrSandboxNotFound, err) + } + return err +} + +func (c *Client) Kill(ctx context.Context, sandboxID string) error { + err := c.controlJSON(ctx, http.MethodDelete, "/sandboxes/"+url.PathEscape(strings.TrimSpace(sandboxID)), nil, nil) + if isHTTPStatus(err, http.StatusNotFound) { + return nil + } + return err +} + +func (c *Client) Run(ctx context.Context, sandbox *Sandbox, command string, maxOutputBytes int) (CommandResult, error) { + if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" { + return CommandResult{}, errors.New("sandbox is required") + } + baseURL := c.sandboxBaseURL(sandbox) + rpc := processv1connect.NewProcessClient(c.sandboxHTTP, baseURL, connect.WithProtoJSON()) + stdin := false + req := connect.NewRequest(&processv1.StartRequest{ + Process: &processv1.ProcessConfig{ + Cmd: "/bin/bash", + Args: []string{"-l", "-c", command}, + Envs: map[string]string{}, + }, + Stdin: &stdin, + }) + c.applySandboxHeaders(req.Header(), sandbox) + req.Header().Set("Keepalive-Ping-Interval", "50") + stream, err := rpc.Start(ctx, req) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + return CommandResult{}, fmt.Errorf("%w: %v", ErrSandboxNotFound, err) + } + return CommandResult{}, fmt.Errorf("start E2B command: %w", err) + } + var stdout, stderr limitedBuffer + stdout.limit = maxOutputBytes + stderr.limit = maxOutputBytes + result := CommandResult{} + ended := false + for stream.Receive() { + event := stream.Msg().GetEvent() + if event == nil { + continue + } + if data := event.GetData(); data != nil { + stdout.Write(data.GetStdout()) + stderr.Write(data.GetStderr()) + } + if end := event.GetEnd(); end != nil { + result.ExitCode = int(end.GetExitCode()) + if end.GetError() != "" && result.ExitCode == 0 { + return CommandResult{}, fmt.Errorf("E2B command failed: %s", end.GetError()) + } + ended = true + } + } + if err := stream.Err(); err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + return CommandResult{}, fmt.Errorf("%w: %v", ErrSandboxNotFound, err) + } + return CommandResult{}, fmt.Errorf("receive E2B command output: %w", err) + } + if !ended { + return CommandResult{}, errors.New("E2B command ended without an end event") + } + result.Stdout = stdout.String() + result.Stderr = stderr.String() + result.StdoutTruncated = stdout.truncated + result.StderrTruncated = stderr.truncated + return result, nil +} + +func (c *Client) ReadFile(ctx context.Context, sandbox *Sandbox, filePath string, maxBytes int64) (File, error) { + opened, err := c.OpenFile(ctx, sandbox, filePath) + if err != nil { + return File{}, err + } + defer opened.Body.Close() + if maxBytes >= 0 && opened.Size > maxBytes { + return File{Size: opened.Size}, ErrFileTooLarge + } + reader := io.Reader(opened.Body) + if maxBytes >= 0 { + reader = io.LimitReader(opened.Body, maxBytes+1) + } + content, err := io.ReadAll(reader) + if err != nil { + return File{}, fmt.Errorf("read E2B file: %w", err) + } + if maxBytes >= 0 && int64(len(content)) > maxBytes { + return File{Size: int64(len(content))}, ErrFileTooLarge + } + mimeType := opened.MIMEType + if mimeType == "" { + mimeType = http.DetectContentType(content) + } + return File{Content: content, MIMEType: mimeType, Size: int64(len(content))}, nil +} + +func (c *Client) OpenFile(ctx context.Context, sandbox *Sandbox, filePath string) (FileReader, error) { + if sandbox == nil || strings.TrimSpace(sandbox.ID) == "" { + return FileReader{}, errors.New("sandbox is required") + } + endpoint := c.sandboxBaseURL(sandbox) + "/files?path=" + url.QueryEscape(filePath) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return FileReader{}, err + } + c.applySandboxHeaders(req.Header, sandbox) + resp, err := c.sandboxHTTP.Do(req) + if err != nil { + return FileReader{}, fmt.Errorf("download E2B file: %w", err) + } + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + return FileReader{}, ErrFileNotFound + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + err := responseError(resp) + resp.Body.Close() + return FileReader{}, err + } + mimeType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]) + if mimeType == "" || mimeType == "application/octet-stream" { + mimeType = mime.TypeByExtension(filepath.Ext(filePath)) + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + return FileReader{Body: resp.Body, MIMEType: mimeType, Size: resp.ContentLength}, nil +} + +var ( + ErrFileNotFound = errors.New("file not found") + ErrFileTooLarge = errors.New("file exceeds limit") + ErrSandboxNotFound = errors.New("sandbox not found") +) + +func (c *Client) controlJSON(ctx context.Context, method, path string, input, output any) error { + var body io.Reader + if input != nil { + payload, err := json.Marshal(input) + if err != nil { + return err + } + body = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, c.apiURL+path, body) + if err != nil { + return err + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("User-Agent", "onlyboxes-worker-bridge-e2b") + if input != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.controlHTTP.Do(req) + if err != nil { + return fmt.Errorf("E2B API request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return responseError(resp) + } + if output == nil || resp.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + if err := json.NewDecoder(resp.Body).Decode(output); err != nil { + return fmt.Errorf("decode E2B API response: %w", err) + } + return nil +} + +func responseError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + var decoded apiError + _ = json.Unmarshal(body, &decoded) + message := strings.TrimSpace(decoded.Message) + if message == "" { + message = strings.TrimSpace(string(body)) + } + if message == "" { + message = resp.Status + } + return &HTTPError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + Message: message, + } +} + +func isHTTPStatus(err error, statusCode int) bool { + var httpErr *HTTPError + return errors.As(err, &httpErr) && httpErr.StatusCode == statusCode +} + +func (c *Client) sandboxBaseURL(sandbox *Sandbox) string { + if c.sandboxURL != "" { + return c.sandboxURL + } + domain := strings.TrimSpace(sandbox.Domain) + if domain == "" { + domain = c.domain + } + if domain == "e2b.app" { + return "https://sandbox.e2b.app" + } + return "https://" + strconv.Itoa(envdPort) + "-" + sandbox.ID + "." + domain +} + +func (c *Client) applySandboxHeaders(header http.Header, sandbox *Sandbox) { + header.Set("E2b-Sandbox-Id", sandbox.ID) + header.Set("E2b-Sandbox-Port", strconv.Itoa(envdPort)) + header.Set("User-Agent", "onlyboxes-worker-bridge-e2b") + if sandbox.AccessToken != "" { + header.Set("X-Access-Token", sandbox.AccessToken) + } +} + +type limitedBuffer struct { + buf bytes.Buffer + limit int + truncated bool +} + +func (b *limitedBuffer) Write(data []byte) { + if len(data) == 0 { + return + } + if b.limit < 0 { + _, _ = b.buf.Write(data) + return + } + if b.limit == 0 { + b.truncated = true + return + } + remaining := b.limit - b.buf.Len() + if remaining <= 0 { + b.truncated = true + return + } + if len(data) > remaining { + data = data[:remaining] + b.truncated = true + } + _, _ = b.buf.Write(data) +} + +func (b *limitedBuffer) String() string { return b.buf.String() } diff --git a/worker/worker-bridge-e2b/internal/e2b/client_integration_test.go b/worker/worker-bridge-e2b/internal/e2b/client_integration_test.go new file mode 100644 index 0000000..cffbbda --- /dev/null +++ b/worker/worker-bridge-e2b/internal/e2b/client_integration_test.go @@ -0,0 +1,64 @@ +package e2b + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestIntegrationSandboxCommandAndFile(t *testing.T) { + if os.Getenv("E2B_INTEGRATION") != "1" { + t.Skip("set E2B_INTEGRATION=1 to run against E2B") + } + apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY")) + template := strings.TrimSpace(os.Getenv("E2B_TERMINAL_TEMPLATE")) + if apiKey == "" || template == "" { + t.Fatal("E2B_API_KEY and E2B_TERMINAL_TEMPLATE are required") + } + client, err := NewClient(Config{ + APIKey: apiKey, + APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")), + Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")), + RequestTimeout: 60 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + sandbox, err := client.Create(ctx, template, 120) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + if err := client.Kill(cleanupCtx, sandbox.ID); err != nil { + t.Errorf("cleanup sandbox: %v", err) + } + }) + result, err := client.Run( + ctx, + sandbox, + `printf 'hello-e2b' > /tmp/onlyboxes-e2b-smoke.txt && printf 'command-ok'`, + 1024, + ) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 || result.Stdout != "command-ok" { + t.Fatalf("unexpected command result: %#v", result) + } + file, err := client.ReadFile(ctx, sandbox, "/tmp/onlyboxes-e2b-smoke.txt", 1024) + if err != nil { + t.Fatal(err) + } + if string(file.Content) != "hello-e2b" { + t.Fatalf("unexpected file content %q", string(file.Content)) + } + if err := client.SetTimeout(ctx, sandbox.ID, 120); err != nil { + t.Fatal(err) + } +} diff --git a/worker/worker-bridge-e2b/internal/e2b/client_test.go b/worker/worker-bridge-e2b/internal/e2b/client_test.go new file mode 100644 index 0000000..2d30955 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/e2b/client_test.go @@ -0,0 +1,216 @@ +package e2b + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "connectrpc.com/connect" + processv1 "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect" +) + +type processTestHandler struct { + processv1connect.UnimplementedProcessHandler + t *testing.T +} + +func (h processTestHandler) Start( + _ context.Context, + req *connect.Request[processv1.StartRequest], + stream *connect.ServerStream[processv1.StartResponse], +) error { + if req.Header().Get("E2b-Sandbox-Id") != "sb-command" || + req.Header().Get("X-Access-Token") != "command-token" { + h.t.Errorf("missing E2B routing headers: %#v", req.Header()) + } + process := req.Msg.GetProcess() + if process.GetCmd() != "/bin/bash" || strings.Join(process.GetArgs(), " ") != "-l -c printf ok" { + h.t.Errorf("unexpected process request: %#v", process) + } + if err := stream.Send(&processv1.StartResponse{Event: &processv1.ProcessEvent{ + Event: &processv1.ProcessEvent_Start{Start: &processv1.ProcessEvent_StartEvent{Pid: 42}}, + }}); err != nil { + return err + } + if err := stream.Send(&processv1.StartResponse{Event: &processv1.ProcessEvent{ + Event: &processv1.ProcessEvent_Data{Data: &processv1.ProcessEvent_DataEvent{ + Output: &processv1.ProcessEvent_DataEvent_Stdout{Stdout: []byte("ok")}, + }}, + }}); err != nil { + return err + } + return stream.Send(&processv1.StartResponse{Event: &processv1.ProcessEvent{ + Event: &processv1.ProcessEvent_End{End: &processv1.ProcessEvent_EndEvent{ExitCode: 0, Exited: true}}, + }}) +} + +func TestControlPlaneLifecycle(t *testing.T) { + t.Parallel() + var mu sync.Mutex + var requests []struct { + Method string + Path string + Body map[string]any + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-API-Key") != "test-key" { + t.Errorf("missing API key") + } + body := map[string]any{} + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) + } + mu.Lock() + requests = append(requests, struct { + Method string + Path string + Body map[string]any + }{r.Method, r.URL.Path, body}) + mu.Unlock() + switch { + case r.Method == http.MethodPost && r.URL.Path == "/sandboxes": + _ = json.NewEncoder(w).Encode(map[string]any{ + "sandboxID": "sb-1", + "envdVersion": "0.6.2", + "envdAccessToken": "envd-token", + "domain": "example.test", + }) + default: + w.WriteHeader(http.StatusNoContent) + } + })) + defer server.Close() + + client, err := NewClient(Config{ + APIKey: "test-key", + APIURL: server.URL, + RequestTimeout: time.Second, + }) + if err != nil { + t.Fatal(err) + } + sandbox, err := client.Create(context.Background(), "template-1", 90) + if err != nil { + t.Fatal(err) + } + if sandbox.ID != "sb-1" || sandbox.Domain != "example.test" || sandbox.AccessToken != "envd-token" { + t.Fatalf("unexpected sandbox: %#v", sandbox) + } + if err := client.SetTimeout(context.Background(), sandbox.ID, 120); err != nil { + t.Fatal(err) + } + if err := client.Kill(context.Background(), sandbox.ID); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + if len(requests) != 3 { + t.Fatalf("expected 3 requests, got %d", len(requests)) + } + if requests[0].Method != http.MethodPost || requests[0].Path != "/sandboxes" { + t.Fatalf("unexpected create request: %#v", requests[0]) + } + if requests[0].Body["templateID"] != "template-1" || requests[0].Body["timeout"] != float64(90) { + t.Fatalf("unexpected create body: %#v", requests[0].Body) + } + if requests[0].Body["allow_internet_access"] != true || requests[0].Body["secure"] != true { + t.Fatalf("missing create security/network fields: %#v", requests[0].Body) + } + if requests[1].Path != "/sandboxes/sb-1/timeout" || requests[1].Body["timeout"] != float64(120) { + t.Fatalf("unexpected timeout request: %#v", requests[1]) + } + if requests[2].Method != http.MethodDelete || requests[2].Path != "/sandboxes/sb-1" { + t.Fatalf("unexpected delete request: %#v", requests[2]) + } +} + +func TestReadFileUsesSandboxRoutingHeadersAndLimit(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/files" || r.URL.Query().Get("path") != "/tmp/a b.txt" { + t.Errorf("unexpected file URL: %s", r.URL.String()) + } + if r.Header.Get("E2b-Sandbox-Id") != "sb-2" || + r.Header.Get("E2b-Sandbox-Port") != "49983" || + r.Header.Get("X-Access-Token") != "token-2" { + t.Errorf("missing routing headers: %#v", r.Header) + } + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Length", "5") + _, _ = io.WriteString(w, "hello") + })) + defer server.Close() + client, err := NewClient(Config{ + APIKey: "test-key", + SandboxURL: server.URL, + }) + if err != nil { + t.Fatal(err) + } + sandbox := &Sandbox{ID: "sb-2", AccessToken: "token-2", Domain: "e2b.app"} + file, err := client.ReadFile(context.Background(), sandbox, "/tmp/a b.txt", 5) + if err != nil { + t.Fatal(err) + } + if string(file.Content) != "hello" || file.MIMEType != "text/plain" || file.Size != 5 { + t.Fatalf("unexpected file: %#v", file) + } + _, err = client.ReadFile(context.Background(), sandbox, "/tmp/a b.txt", 4) + if err == nil || !strings.Contains(err.Error(), ErrFileTooLarge.Error()) { + t.Fatalf("expected file-too-large error, got %v", err) + } +} + +func TestCreateSurfacesAPIErrorWithoutLeakingKey(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"message":"invalid credentials"}`) + })) + defer server.Close() + client, err := NewClient(Config{APIKey: "secret-key", APIURL: server.URL}) + if err != nil { + t.Fatal(err) + } + _, err = client.Create(context.Background(), "template", 60) + if err == nil || !strings.Contains(err.Error(), "invalid credentials") { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(err.Error(), "secret-key") { + t.Fatalf("error leaked API key: %v", err) + } +} + +func TestRunUsesEnvdConnectJSONStream(t *testing.T) { + t.Parallel() + path, handler := processv1connect.NewProcessHandler(processTestHandler{t: t}) + mux := http.NewServeMux() + mux.Handle(path, handler) + server := httptest.NewServer(mux) + defer server.Close() + + client, err := NewClient(Config{APIKey: "test-key", SandboxURL: server.URL}) + if err != nil { + t.Fatal(err) + } + result, err := client.Run( + context.Background(), + &Sandbox{ID: "sb-command", AccessToken: "command-token"}, + "printf ok", + 1024, + ) + if err != nil { + t.Fatal(err) + } + if result.Stdout != "ok" || result.Stderr != "" || result.ExitCode != 0 { + t.Fatalf("unexpected command result: %#v", result) + } +} diff --git a/worker/worker-bridge-e2b/internal/e2b/generate.go b/worker/worker-bridge-e2b/internal/e2b/generate.go new file mode 100644 index 0000000..76548a2 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/e2b/generate.go @@ -0,0 +1,3 @@ +package e2b + +//go:generate protoc --go_out=. --go_opt=paths=source_relative --connect-go_out=. --connect-go_opt=paths=source_relative process/v1/process.proto diff --git a/worker/worker-bridge-e2b/internal/e2b/process/v1/process.pb.go b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.pb.go new file mode 100644 index 0000000..810ec12 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.pb.go @@ -0,0 +1,2057 @@ +// Source: https://github.com/e2b-dev/E2B/blob/main/spec/envd/process/process.proto + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v7.34.0 +// source: process/v1/process.proto + +package processv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Signal int32 + +const ( + Signal_SIGNAL_UNSPECIFIED Signal = 0 + Signal_SIGNAL_SIGTERM Signal = 15 + Signal_SIGNAL_SIGKILL Signal = 9 +) + +// Enum value maps for Signal. +var ( + Signal_name = map[int32]string{ + 0: "SIGNAL_UNSPECIFIED", + 15: "SIGNAL_SIGTERM", + 9: "SIGNAL_SIGKILL", + } + Signal_value = map[string]int32{ + "SIGNAL_UNSPECIFIED": 0, + "SIGNAL_SIGTERM": 15, + "SIGNAL_SIGKILL": 9, + } +) + +func (x Signal) Enum() *Signal { + p := new(Signal) + *p = x + return p +} + +func (x Signal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Signal) Descriptor() protoreflect.EnumDescriptor { + return file_process_v1_process_proto_enumTypes[0].Descriptor() +} + +func (Signal) Type() protoreflect.EnumType { + return &file_process_v1_process_proto_enumTypes[0] +} + +func (x Signal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Signal.Descriptor instead. +func (Signal) EnumDescriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{0} +} + +type PTY struct { + state protoimpl.MessageState `protogen:"open.v1"` + Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY) Reset() { + *x = PTY{} + mi := &file_process_v1_process_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY) ProtoMessage() {} + +func (x *PTY) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY.ProtoReflect.Descriptor instead. +func (*PTY) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{0} +} + +func (x *PTY) GetSize() *PTY_Size { + if x != nil { + return x.Size + } + return nil +} + +type ProcessConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessConfig) Reset() { + *x = ProcessConfig{} + mi := &file_process_v1_process_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessConfig) ProtoMessage() {} + +func (x *ProcessConfig) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead. +func (*ProcessConfig) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{1} +} + +func (x *ProcessConfig) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ProcessConfig) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ProcessConfig) GetEnvs() map[string]string { + if x != nil { + return x.Envs + } + return nil +} + +func (x *ProcessConfig) GetCwd() string { + if x != nil && x.Cwd != nil { + return *x.Cwd + } + return "" +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_process_v1_process_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{2} +} + +type ProcessInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInfo) Reset() { + *x = ProcessInfo{} + mi := &file_process_v1_process_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInfo) ProtoMessage() {} + +func (x *ProcessInfo) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. +func (*ProcessInfo) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessInfo) GetConfig() *ProcessConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *ProcessInfo) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *ProcessInfo) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_process_v1_process_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{4} +} + +func (x *ListResponse) GetProcesses() []*ProcessInfo { + if x != nil { + return x.Processes + } + return nil +} + +type StartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartRequest) Reset() { + *x = StartRequest{} + mi := &file_process_v1_process_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartRequest) ProtoMessage() {} + +func (x *StartRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. +func (*StartRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{5} +} + +func (x *StartRequest) GetProcess() *ProcessConfig { + if x != nil { + return x.Process + } + return nil +} + +func (x *StartRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +func (x *StartRequest) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +func (x *StartRequest) GetStdin() bool { + if x != nil && x.Stdin != nil { + return *x.Stdin + } + return false +} + +type UpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRequest) Reset() { + *x = UpdateRequest{} + mi := &file_process_v1_process_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRequest) ProtoMessage() {} + +func (x *UpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. +func (*UpdateRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *UpdateRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +type UpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateResponse) Reset() { + *x = UpdateResponse{} + mi := &file_process_v1_process_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateResponse) ProtoMessage() {} + +func (x *UpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. +func (*UpdateResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{7} +} + +type ProcessEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ProcessEvent_Start + // *ProcessEvent_Data + // *ProcessEvent_End + // *ProcessEvent_Keepalive + Event isProcessEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent) Reset() { + *x = ProcessEvent{} + mi := &file_process_v1_process_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent) ProtoMessage() {} + +func (x *ProcessEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{8} +} + +func (x *ProcessEvent) GetEvent() isProcessEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_End); ok { + return x.End + } + } + return nil +} + +func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isProcessEvent_Event interface { + isProcessEvent_Event() +} + +type ProcessEvent_Start struct { + Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ProcessEvent_Data struct { + Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ProcessEvent_End struct { + End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ProcessEvent_Keepalive struct { + Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"` +} + +func (*ProcessEvent_Start) isProcessEvent_Event() {} + +func (*ProcessEvent_Data) isProcessEvent_Event() {} + +func (*ProcessEvent_End) isProcessEvent_Event() {} + +func (*ProcessEvent_Keepalive) isProcessEvent_Event() {} + +type StartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartResponse) Reset() { + *x = StartResponse{} + mi := &file_process_v1_process_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartResponse) ProtoMessage() {} + +func (x *StartResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. +func (*StartResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{9} +} + +func (x *StartResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type ConnectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectResponse) Reset() { + *x = ConnectResponse{} + mi := &file_process_v1_process_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectResponse) ProtoMessage() {} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. +func (*ConnectResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{10} +} + +func (x *ConnectResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type SendInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputRequest) Reset() { + *x = SendInputRequest{} + mi := &file_process_v1_process_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputRequest) ProtoMessage() {} + +func (x *SendInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead. +func (*SendInputRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{11} +} + +func (x *SendInputRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendInputRequest) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type SendInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputResponse) Reset() { + *x = SendInputResponse{} + mi := &file_process_v1_process_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputResponse) ProtoMessage() {} + +func (x *SendInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead. +func (*SendInputResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{12} +} + +type ProcessInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Input: + // + // *ProcessInput_Stdin + // *ProcessInput_Pty + Input isProcessInput_Input `protobuf_oneof:"input"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInput) Reset() { + *x = ProcessInput{} + mi := &file_process_v1_process_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInput) ProtoMessage() {} + +func (x *ProcessInput) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead. +func (*ProcessInput) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{13} +} + +func (x *ProcessInput) GetInput() isProcessInput_Input { + if x != nil { + return x.Input + } + return nil +} + +func (x *ProcessInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ProcessInput) GetPty() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessInput_Input interface { + isProcessInput_Input() +} + +type ProcessInput_Stdin struct { + Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"` +} + +type ProcessInput_Pty struct { + Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"` +} + +func (*ProcessInput_Stdin) isProcessInput_Input() {} + +func (*ProcessInput_Pty) isProcessInput_Input() {} + +type StreamInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *StreamInputRequest_Start + // *StreamInputRequest_Data + // *StreamInputRequest_Keepalive + Event isStreamInputRequest_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest) Reset() { + *x = StreamInputRequest{} + mi := &file_process_v1_process_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest) ProtoMessage() {} + +func (x *StreamInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead. +func (*StreamInputRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{14} +} + +func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Data); ok { + return x.Data + } + } + return nil +} + +func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isStreamInputRequest_Event interface { + isStreamInputRequest_Event() +} + +type StreamInputRequest_Start struct { + Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type StreamInputRequest_Data struct { + Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type StreamInputRequest_Keepalive struct { + Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*StreamInputRequest_Start) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Data) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {} + +type StreamInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputResponse) Reset() { + *x = StreamInputResponse{} + mi := &file_process_v1_process_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputResponse) ProtoMessage() {} + +func (x *StreamInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead. +func (*StreamInputResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{15} +} + +type SendSignalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalRequest) Reset() { + *x = SendSignalRequest{} + mi := &file_process_v1_process_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalRequest) ProtoMessage() {} + +func (x *SendSignalRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead. +func (*SendSignalRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{16} +} + +func (x *SendSignalRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendSignalRequest) GetSignal() Signal { + if x != nil { + return x.Signal + } + return Signal_SIGNAL_UNSPECIFIED +} + +type SendSignalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalResponse) Reset() { + *x = SendSignalResponse{} + mi := &file_process_v1_process_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalResponse) ProtoMessage() {} + +func (x *SendSignalResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead. +func (*SendSignalResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{17} +} + +type CloseStdinRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinRequest) Reset() { + *x = CloseStdinRequest{} + mi := &file_process_v1_process_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinRequest) ProtoMessage() {} + +func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead. +func (*CloseStdinRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{18} +} + +func (x *CloseStdinRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type CloseStdinResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinResponse) Reset() { + *x = CloseStdinResponse{} + mi := &file_process_v1_process_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinResponse) ProtoMessage() {} + +func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead. +func (*CloseStdinResponse) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{19} +} + +type ConnectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectRequest) Reset() { + *x = ConnectRequest{} + mi := &file_process_v1_process_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectRequest) ProtoMessage() {} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. +func (*ConnectRequest) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{20} +} + +func (x *ConnectRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type ProcessSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selector: + // + // *ProcessSelector_Pid + // *ProcessSelector_Tag + Selector isProcessSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessSelector) Reset() { + *x = ProcessSelector{} + mi := &file_process_v1_process_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessSelector) ProtoMessage() {} + +func (x *ProcessSelector) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead. +func (*ProcessSelector) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{21} +} + +func (x *ProcessSelector) GetSelector() isProcessSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *ProcessSelector) GetPid() uint32 { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Pid); ok { + return x.Pid + } + } + return 0 +} + +func (x *ProcessSelector) GetTag() string { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Tag); ok { + return x.Tag + } + } + return "" +} + +type isProcessSelector_Selector interface { + isProcessSelector_Selector() +} + +type ProcessSelector_Pid struct { + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"` +} + +type ProcessSelector_Tag struct { + Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"` +} + +func (*ProcessSelector_Pid) isProcessSelector_Selector() {} + +func (*ProcessSelector_Tag) isProcessSelector_Selector() {} + +type PTY_Size struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY_Size) Reset() { + *x = PTY_Size{} + mi := &file_process_v1_process_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY_Size) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY_Size) ProtoMessage() {} + +func (x *PTY_Size) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead. +func (*PTY_Size) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *PTY_Size) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PTY_Size) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +type ProcessEvent_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_StartEvent) Reset() { + *x = ProcessEvent_StartEvent{} + mi := &file_process_v1_process_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_StartEvent) ProtoMessage() {} + +func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{8, 0} +} + +func (x *ProcessEvent_StartEvent) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +type ProcessEvent_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Output: + // + // *ProcessEvent_DataEvent_Stdout + // *ProcessEvent_DataEvent_Stderr + // *ProcessEvent_DataEvent_Pty + Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_DataEvent) Reset() { + *x = ProcessEvent_DataEvent{} + mi := &file_process_v1_process_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_DataEvent) ProtoMessage() {} + +func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{8, 1} +} + +func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStdout() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetPty() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessEvent_DataEvent_Output interface { + isProcessEvent_DataEvent_Output() +} + +type ProcessEvent_DataEvent_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Pty struct { + Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"` +} + +func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {} + +type ProcessEvent_EndEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_EndEvent) Reset() { + *x = ProcessEvent_EndEvent{} + mi := &file_process_v1_process_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_EndEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_EndEvent) ProtoMessage() {} + +func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{8, 2} +} + +func (x *ProcessEvent_EndEvent) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ProcessEvent_EndEvent) GetExited() bool { + if x != nil { + return x.Exited + } + return false +} + +func (x *ProcessEvent_EndEvent) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProcessEvent_EndEvent) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +type ProcessEvent_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_KeepAlive) Reset() { + *x = ProcessEvent_KeepAlive{} + mi := &file_process_v1_process_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_KeepAlive) ProtoMessage() {} + +func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead. +func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{8, 3} +} + +type StreamInputRequest_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_StartEvent) Reset() { + *x = StreamInputRequest_StartEvent{} + mi := &file_process_v1_process_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_StartEvent) ProtoMessage() {} + +func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{14, 0} +} + +func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type StreamInputRequest_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_DataEvent) Reset() { + *x = StreamInputRequest_DataEvent{} + mi := &file_process_v1_process_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_DataEvent) ProtoMessage() {} + +func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{14, 1} +} + +func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type StreamInputRequest_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_KeepAlive) Reset() { + *x = StreamInputRequest_KeepAlive{} + mi := &file_process_v1_process_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_KeepAlive) ProtoMessage() {} + +func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_v1_process_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_v1_process_proto_rawDescGZIP(), []int{14, 2} +} + +var File_process_v1_process_proto protoreflect.FileDescriptor + +var file_process_v1_process_proto_rawDesc = string([]byte{ + 0x0a, 0x18, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x72, 0x6f, + 0x63, 0x65, 0x73, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x70, 0x72, 0x6f, 0x63, + 0x65, 0x73, 0x73, 0x22, 0x5c, 0x0a, 0x03, 0x50, 0x54, 0x59, 0x12, 0x25, 0x0a, 0x04, 0x73, 0x69, + 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, + 0x73, 0x73, 0x2e, 0x50, 0x54, 0x59, 0x2e, 0x53, 0x69, 0x7a, 0x65, 0x52, 0x04, 0x73, 0x69, 0x7a, + 0x65, 0x1a, 0x2e, 0x0a, 0x04, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x6c, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x12, 0x12, 0x0a, + 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x72, 0x6f, 0x77, + 0x73, 0x22, 0xc3, 0x01, 0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x6d, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x63, 0x6d, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x04, 0x65, 0x6e, 0x76, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, + 0x45, 0x6e, 0x76, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, 0x65, 0x6e, 0x76, 0x73, 0x12, + 0x15, 0x0a, 0x03, 0x63, 0x77, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, + 0x63, 0x77, 0x64, 0x88, 0x01, 0x01, 0x1a, 0x37, 0x0a, 0x09, 0x45, 0x6e, 0x76, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, + 0x06, 0x0a, 0x04, 0x5f, 0x63, 0x77, 0x64, 0x22, 0x0d, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x6e, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2e, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, + 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, 0x74, 0x61, 0x67, 0x88, 0x01, 0x01, 0x42, 0x06, + 0x0a, 0x04, 0x5f, 0x74, 0x61, 0x67, 0x22, 0x42, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x70, 0x72, 0x6f, 0x63, + 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x6e, 0x66, 0x6f, 0x52, + 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x65, 0x73, 0x22, 0xb1, 0x01, 0x0a, 0x0c, 0x53, + 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x30, 0x0a, 0x07, 0x70, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, + 0x03, 0x70, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x72, 0x6f, + 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x54, 0x59, 0x48, 0x00, 0x52, 0x03, 0x70, 0x74, 0x79, 0x88, + 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x01, 0x52, 0x03, 0x74, 0x61, 0x67, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x73, 0x74, 0x64, + 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x05, 0x73, 0x74, 0x64, 0x69, + 0x6e, 0x88, 0x01, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x70, 0x74, 0x79, 0x42, 0x06, 0x0a, 0x04, + 0x5f, 0x74, 0x61, 0x67, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x22, 0x70, + 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, + 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, + 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x03, 0x70, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0c, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x54, 0x59, 0x48, 0x00, + 0x52, 0x03, 0x70, 0x74, 0x79, 0x88, 0x01, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x70, 0x74, 0x79, + 0x22, 0x10, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x87, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x12, 0x38, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, + 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x35, 0x0a, + 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, 0x65, + 0x6e, 0x74, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x04, + 0x64, 0x61, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, + 0x65, 0x73, 0x73, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x64, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x48, 0x00, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x3f, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, + 0x61, 0x6c, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, 0x65, + 0x6e, 0x74, 0x2e, 0x4b, 0x65, 0x65, 0x70, 0x41, 0x6c, 0x69, 0x76, 0x65, 0x48, 0x00, 0x52, 0x09, + 0x6b, 0x65, 0x65, 0x70, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x1a, 0x1e, 0x0a, 0x0a, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x70, 0x69, 0x64, 0x1a, 0x5d, 0x0a, 0x09, 0x44, 0x61, 0x74, + 0x61, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, + 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, 0x72, 0x12, 0x12, 0x0a, 0x03, 0x70, 0x74, + 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x03, 0x70, 0x74, 0x79, 0x42, 0x08, + 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x1a, 0x7c, 0x0a, 0x08, 0x45, 0x6e, 0x64, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x11, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, + 0x65, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x69, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x06, 0x65, 0x78, 0x69, 0x74, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, + 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0x0b, 0x0a, 0x09, 0x4b, 0x65, 0x65, 0x70, 0x41, 0x6c, + 0x69, 0x76, 0x65, 0x42, 0x07, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x3c, 0x0a, 0x0d, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, + 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x3e, 0x0a, 0x0f, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, + 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x73, 0x0a, 0x10, 0x53, 0x65, + 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, + 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, + 0x73, 0x73, 0x12, 0x2b, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, + 0x65, 0x73, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x22, + 0x13, 0x0a, 0x11, 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x43, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x12, 0x16, 0x0a, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x03, + 0x70, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x03, 0x70, 0x74, 0x79, + 0x42, 0x07, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x22, 0xea, 0x02, 0x0a, 0x12, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x3e, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x12, 0x3b, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, + 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x61, 0x74, 0x61, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x45, 0x0a, + 0x09, 0x6b, 0x65, 0x65, 0x70, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4b, 0x65, + 0x65, 0x70, 0x41, 0x6c, 0x69, 0x76, 0x65, 0x48, 0x00, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x61, + 0x6c, 0x69, 0x76, 0x65, 0x1a, 0x40, 0x0a, 0x0a, 0x53, 0x74, 0x61, 0x72, 0x74, 0x45, 0x76, 0x65, + 0x6e, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x1a, 0x38, 0x0a, 0x09, 0x44, 0x61, 0x74, 0x61, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x12, 0x2b, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, + 0x63, 0x65, 0x73, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, + 0x1a, 0x0b, 0x0a, 0x09, 0x4b, 0x65, 0x65, 0x70, 0x41, 0x6c, 0x69, 0x76, 0x65, 0x42, 0x07, 0x0a, + 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x15, 0x0a, 0x13, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x70, 0x0a, + 0x11, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x27, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, + 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x22, + 0x14, 0x0a, 0x12, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x47, 0x0a, 0x11, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x74, + 0x64, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, + 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x22, 0x14, + 0x0a, 0x12, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x2e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x22, 0x45, 0x0a, 0x0f, 0x50, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x12, 0x0a, + 0x03, 0x70, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, + 0x64, 0x12, 0x12, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x03, 0x74, 0x61, 0x67, 0x42, 0x0a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x2a, 0x48, 0x0a, 0x06, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x12, 0x53, + 0x49, 0x47, 0x4e, 0x41, 0x4c, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x49, 0x47, 0x4e, 0x41, 0x4c, 0x5f, 0x53, 0x49, + 0x47, 0x54, 0x45, 0x52, 0x4d, 0x10, 0x0f, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x49, 0x47, 0x4e, 0x41, + 0x4c, 0x5f, 0x53, 0x49, 0x47, 0x4b, 0x49, 0x4c, 0x4c, 0x10, 0x09, 0x32, 0x91, 0x04, 0x0a, 0x07, + 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x33, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, + 0x14, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x38, 0x0a, 0x05, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x70, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x39, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x12, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, + 0x73, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x4a, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74, + 0x12, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, + 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x6e, + 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x42, 0x0a, + 0x09, 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x19, 0x2e, 0x70, 0x72, 0x6f, + 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, + 0x53, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x45, 0x0a, 0x0a, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, + 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, + 0x67, 0x6e, 0x61, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x0a, 0x43, 0x6c, 0x6f, 0x73, + 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, + 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x43, 0x6c, 0x6f, + 0x73, 0x65, 0x53, 0x74, 0x64, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, + 0x5b, 0x5a, 0x59, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x6e, + 0x6c, 0x79, 0x62, 0x6f, 0x78, 0x65, 0x73, 0x2f, 0x6f, 0x6e, 0x6c, 0x79, 0x62, 0x6f, 0x78, 0x65, + 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x2f, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x2d, + 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x2d, 0x65, 0x32, 0x62, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x2f, 0x65, 0x32, 0x62, 0x2f, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x2f, + 0x76, 0x31, 0x3b, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_process_v1_process_proto_rawDescOnce sync.Once + file_process_v1_process_proto_rawDescData []byte +) + +func file_process_v1_process_proto_rawDescGZIP() []byte { + file_process_v1_process_proto_rawDescOnce.Do(func() { + file_process_v1_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_v1_process_proto_rawDesc), len(file_process_v1_process_proto_rawDesc))) + }) + return file_process_v1_process_proto_rawDescData +} + +var file_process_v1_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_process_v1_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_process_v1_process_proto_goTypes = []any{ + (Signal)(0), // 0: process.Signal + (*PTY)(nil), // 1: process.PTY + (*ProcessConfig)(nil), // 2: process.ProcessConfig + (*ListRequest)(nil), // 3: process.ListRequest + (*ProcessInfo)(nil), // 4: process.ProcessInfo + (*ListResponse)(nil), // 5: process.ListResponse + (*StartRequest)(nil), // 6: process.StartRequest + (*UpdateRequest)(nil), // 7: process.UpdateRequest + (*UpdateResponse)(nil), // 8: process.UpdateResponse + (*ProcessEvent)(nil), // 9: process.ProcessEvent + (*StartResponse)(nil), // 10: process.StartResponse + (*ConnectResponse)(nil), // 11: process.ConnectResponse + (*SendInputRequest)(nil), // 12: process.SendInputRequest + (*SendInputResponse)(nil), // 13: process.SendInputResponse + (*ProcessInput)(nil), // 14: process.ProcessInput + (*StreamInputRequest)(nil), // 15: process.StreamInputRequest + (*StreamInputResponse)(nil), // 16: process.StreamInputResponse + (*SendSignalRequest)(nil), // 17: process.SendSignalRequest + (*SendSignalResponse)(nil), // 18: process.SendSignalResponse + (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest + (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse + (*ConnectRequest)(nil), // 21: process.ConnectRequest + (*ProcessSelector)(nil), // 22: process.ProcessSelector + (*PTY_Size)(nil), // 23: process.PTY.Size + nil, // 24: process.ProcessConfig.EnvsEntry + (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent + (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent + (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent + (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive + (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent + (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent + (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive +} +var file_process_v1_process_proto_depIdxs = []int32{ + 23, // 0: process.PTY.size:type_name -> process.PTY.Size + 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry + 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig + 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo + 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig + 1, // 5: process.StartRequest.pty:type_name -> process.PTY + 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector + 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY + 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent + 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent + 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent + 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive + 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent + 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent + 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector + 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput + 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent + 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent + 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive + 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector + 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal + 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector + 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector + 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector + 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput + 3, // 25: process.Process.List:input_type -> process.ListRequest + 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest + 6, // 27: process.Process.Start:input_type -> process.StartRequest + 7, // 28: process.Process.Update:input_type -> process.UpdateRequest + 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest + 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest + 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest + 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest + 5, // 33: process.Process.List:output_type -> process.ListResponse + 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse + 10, // 35: process.Process.Start:output_type -> process.StartResponse + 8, // 36: process.Process.Update:output_type -> process.UpdateResponse + 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse + 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse + 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse + 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse + 33, // [33:41] is the sub-list for method output_type + 25, // [25:33] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name +} + +func init() { file_process_v1_process_proto_init() } +func file_process_v1_process_proto_init() { + if File_process_v1_process_proto != nil { + return + } + file_process_v1_process_proto_msgTypes[1].OneofWrappers = []any{} + file_process_v1_process_proto_msgTypes[3].OneofWrappers = []any{} + file_process_v1_process_proto_msgTypes[5].OneofWrappers = []any{} + file_process_v1_process_proto_msgTypes[6].OneofWrappers = []any{} + file_process_v1_process_proto_msgTypes[8].OneofWrappers = []any{ + (*ProcessEvent_Start)(nil), + (*ProcessEvent_Data)(nil), + (*ProcessEvent_End)(nil), + (*ProcessEvent_Keepalive)(nil), + } + file_process_v1_process_proto_msgTypes[13].OneofWrappers = []any{ + (*ProcessInput_Stdin)(nil), + (*ProcessInput_Pty)(nil), + } + file_process_v1_process_proto_msgTypes[14].OneofWrappers = []any{ + (*StreamInputRequest_Start)(nil), + (*StreamInputRequest_Data)(nil), + (*StreamInputRequest_Keepalive)(nil), + } + file_process_v1_process_proto_msgTypes[21].OneofWrappers = []any{ + (*ProcessSelector_Pid)(nil), + (*ProcessSelector_Tag)(nil), + } + file_process_v1_process_proto_msgTypes[25].OneofWrappers = []any{ + (*ProcessEvent_DataEvent_Stdout)(nil), + (*ProcessEvent_DataEvent_Stderr)(nil), + (*ProcessEvent_DataEvent_Pty)(nil), + } + file_process_v1_process_proto_msgTypes[26].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_v1_process_proto_rawDesc), len(file_process_v1_process_proto_rawDesc)), + NumEnums: 1, + NumMessages: 31, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_process_v1_process_proto_goTypes, + DependencyIndexes: file_process_v1_process_proto_depIdxs, + EnumInfos: file_process_v1_process_proto_enumTypes, + MessageInfos: file_process_v1_process_proto_msgTypes, + }.Build() + File_process_v1_process_proto = out.File + file_process_v1_process_proto_goTypes = nil + file_process_v1_process_proto_depIdxs = nil +} diff --git a/worker/worker-bridge-e2b/internal/e2b/process/v1/process.proto b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.proto new file mode 100644 index 0000000..8209e7c --- /dev/null +++ b/worker/worker-bridge-e2b/internal/e2b/process/v1/process.proto @@ -0,0 +1,160 @@ +// Source: https://github.com/e2b-dev/E2B/blob/main/spec/envd/process/process.proto +syntax = "proto3"; + +package process; + +option go_package = "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1;processv1"; + +service Process { + rpc List(ListRequest) returns (ListResponse); + rpc Connect(ConnectRequest) returns (stream ConnectResponse); + rpc Start(StartRequest) returns (stream StartResponse); + rpc Update(UpdateRequest) returns (UpdateResponse); + rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); + rpc SendInput(SendInputRequest) returns (SendInputResponse); + rpc SendSignal(SendSignalRequest) returns (SendSignalResponse); + rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); +} + +message PTY { + Size size = 1; + message Size { + uint32 cols = 1; + uint32 rows = 2; + } +} + +message ProcessConfig { + string cmd = 1; + repeated string args = 2; + map envs = 3; + optional string cwd = 4; +} + +message ListRequest {} + +message ProcessInfo { + ProcessConfig config = 1; + uint32 pid = 2; + optional string tag = 3; +} + +message ListResponse { + repeated ProcessInfo processes = 1; +} + +message StartRequest { + ProcessConfig process = 1; + optional PTY pty = 2; + optional string tag = 3; + optional bool stdin = 4; +} + +message UpdateRequest { + ProcessSelector process = 1; + optional PTY pty = 2; +} + +message UpdateResponse {} + +message ProcessEvent { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + EndEvent end = 3; + KeepAlive keepalive = 4; + } + + message StartEvent { + uint32 pid = 1; + } + + message DataEvent { + oneof output { + bytes stdout = 1; + bytes stderr = 2; + bytes pty = 3; + } + } + + message EndEvent { + sint32 exit_code = 1; + bool exited = 2; + string status = 3; + optional string error = 4; + } + + message KeepAlive {} +} + +message StartResponse { + ProcessEvent event = 1; +} + +message ConnectResponse { + ProcessEvent event = 1; +} + +message SendInputRequest { + ProcessSelector process = 1; + ProcessInput input = 2; +} + +message SendInputResponse {} + +message ProcessInput { + oneof input { + bytes stdin = 1; + bytes pty = 2; + } +} + +message StreamInputRequest { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + KeepAlive keepalive = 3; + } + + message StartEvent { + ProcessSelector process = 1; + } + + message DataEvent { + ProcessInput input = 2; + } + + message KeepAlive {} +} + +message StreamInputResponse {} + +enum Signal { + SIGNAL_UNSPECIFIED = 0; + SIGNAL_SIGTERM = 15; + SIGNAL_SIGKILL = 9; +} + +message SendSignalRequest { + ProcessSelector process = 1; + Signal signal = 2; +} + +message SendSignalResponse {} + +message CloseStdinRequest { + ProcessSelector process = 1; +} + +message CloseStdinResponse {} + +message ConnectRequest { + ProcessSelector process = 1; +} + +message ProcessSelector { + oneof selector { + uint32 pid = 1; + string tag = 2; + } +} diff --git a/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect/process.connect.go b/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect/process.connect.go new file mode 100644 index 0000000..86bb3fe --- /dev/null +++ b/worker/worker-bridge-e2b/internal/e2b/process/v1/processv1connect/process.connect.go @@ -0,0 +1,306 @@ +// Source: https://github.com/e2b-dev/E2B/blob/main/spec/envd/process/process.proto + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: process/v1/process.proto + +package processv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b/process/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ProcessName is the fully-qualified name of the Process service. + ProcessName = "process.Process" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ProcessListProcedure is the fully-qualified name of the Process's List RPC. + ProcessListProcedure = "/process.Process/List" + // ProcessConnectProcedure is the fully-qualified name of the Process's Connect RPC. + ProcessConnectProcedure = "/process.Process/Connect" + // ProcessStartProcedure is the fully-qualified name of the Process's Start RPC. + ProcessStartProcedure = "/process.Process/Start" + // ProcessUpdateProcedure is the fully-qualified name of the Process's Update RPC. + ProcessUpdateProcedure = "/process.Process/Update" + // ProcessStreamInputProcedure is the fully-qualified name of the Process's StreamInput RPC. + ProcessStreamInputProcedure = "/process.Process/StreamInput" + // ProcessSendInputProcedure is the fully-qualified name of the Process's SendInput RPC. + ProcessSendInputProcedure = "/process.Process/SendInput" + // ProcessSendSignalProcedure is the fully-qualified name of the Process's SendSignal RPC. + ProcessSendSignalProcedure = "/process.Process/SendSignal" + // ProcessCloseStdinProcedure is the fully-qualified name of the Process's CloseStdin RPC. + ProcessCloseStdinProcedure = "/process.Process/CloseStdin" +) + +// ProcessClient is a client for the process.Process service. +type ProcessClient interface { + List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) + Connect(context.Context, *connect.Request[v1.ConnectRequest]) (*connect.ServerStreamForClient[v1.ConnectResponse], error) + Start(context.Context, *connect.Request[v1.StartRequest]) (*connect.ServerStreamForClient[v1.StartResponse], error) + Update(context.Context, *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error) + StreamInput(context.Context) *connect.ClientStreamForClient[v1.StreamInputRequest, v1.StreamInputResponse] + SendInput(context.Context, *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error) + CloseStdin(context.Context, *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error) +} + +// NewProcessClient constructs a client for the process.Process service. By default, it uses the +// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewProcessClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProcessClient { + baseURL = strings.TrimRight(baseURL, "/") + processMethods := v1.File_process_v1_process_proto.Services().ByName("Process").Methods() + return &processClient{ + list: connect.NewClient[v1.ListRequest, v1.ListResponse]( + httpClient, + baseURL+ProcessListProcedure, + connect.WithSchema(processMethods.ByName("List")), + connect.WithClientOptions(opts...), + ), + connect: connect.NewClient[v1.ConnectRequest, v1.ConnectResponse]( + httpClient, + baseURL+ProcessConnectProcedure, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithClientOptions(opts...), + ), + start: connect.NewClient[v1.StartRequest, v1.StartResponse]( + httpClient, + baseURL+ProcessStartProcedure, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithClientOptions(opts...), + ), + update: connect.NewClient[v1.UpdateRequest, v1.UpdateResponse]( + httpClient, + baseURL+ProcessUpdateProcedure, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithClientOptions(opts...), + ), + streamInput: connect.NewClient[v1.StreamInputRequest, v1.StreamInputResponse]( + httpClient, + baseURL+ProcessStreamInputProcedure, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithClientOptions(opts...), + ), + sendInput: connect.NewClient[v1.SendInputRequest, v1.SendInputResponse]( + httpClient, + baseURL+ProcessSendInputProcedure, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithClientOptions(opts...), + ), + sendSignal: connect.NewClient[v1.SendSignalRequest, v1.SendSignalResponse]( + httpClient, + baseURL+ProcessSendSignalProcedure, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithClientOptions(opts...), + ), + closeStdin: connect.NewClient[v1.CloseStdinRequest, v1.CloseStdinResponse]( + httpClient, + baseURL+ProcessCloseStdinProcedure, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithClientOptions(opts...), + ), + } +} + +// processClient implements ProcessClient. +type processClient struct { + list *connect.Client[v1.ListRequest, v1.ListResponse] + connect *connect.Client[v1.ConnectRequest, v1.ConnectResponse] + start *connect.Client[v1.StartRequest, v1.StartResponse] + update *connect.Client[v1.UpdateRequest, v1.UpdateResponse] + streamInput *connect.Client[v1.StreamInputRequest, v1.StreamInputResponse] + sendInput *connect.Client[v1.SendInputRequest, v1.SendInputResponse] + sendSignal *connect.Client[v1.SendSignalRequest, v1.SendSignalResponse] + closeStdin *connect.Client[v1.CloseStdinRequest, v1.CloseStdinResponse] +} + +// List calls process.Process.List. +func (c *processClient) List(ctx context.Context, req *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) { + return c.list.CallUnary(ctx, req) +} + +// Connect calls process.Process.Connect. +func (c *processClient) Connect(ctx context.Context, req *connect.Request[v1.ConnectRequest]) (*connect.ServerStreamForClient[v1.ConnectResponse], error) { + return c.connect.CallServerStream(ctx, req) +} + +// Start calls process.Process.Start. +func (c *processClient) Start(ctx context.Context, req *connect.Request[v1.StartRequest]) (*connect.ServerStreamForClient[v1.StartResponse], error) { + return c.start.CallServerStream(ctx, req) +} + +// Update calls process.Process.Update. +func (c *processClient) Update(ctx context.Context, req *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error) { + return c.update.CallUnary(ctx, req) +} + +// StreamInput calls process.Process.StreamInput. +func (c *processClient) StreamInput(ctx context.Context) *connect.ClientStreamForClient[v1.StreamInputRequest, v1.StreamInputResponse] { + return c.streamInput.CallClientStream(ctx) +} + +// SendInput calls process.Process.SendInput. +func (c *processClient) SendInput(ctx context.Context, req *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error) { + return c.sendInput.CallUnary(ctx, req) +} + +// SendSignal calls process.Process.SendSignal. +func (c *processClient) SendSignal(ctx context.Context, req *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error) { + return c.sendSignal.CallUnary(ctx, req) +} + +// CloseStdin calls process.Process.CloseStdin. +func (c *processClient) CloseStdin(ctx context.Context, req *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error) { + return c.closeStdin.CallUnary(ctx, req) +} + +// ProcessHandler is an implementation of the process.Process service. +type ProcessHandler interface { + List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) + Connect(context.Context, *connect.Request[v1.ConnectRequest], *connect.ServerStream[v1.ConnectResponse]) error + Start(context.Context, *connect.Request[v1.StartRequest], *connect.ServerStream[v1.StartResponse]) error + Update(context.Context, *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error) + StreamInput(context.Context, *connect.ClientStream[v1.StreamInputRequest]) (*connect.Response[v1.StreamInputResponse], error) + SendInput(context.Context, *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error) + CloseStdin(context.Context, *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error) +} + +// NewProcessHandler builds an HTTP handler from the service implementation. It returns the path on +// which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewProcessHandler(svc ProcessHandler, opts ...connect.HandlerOption) (string, http.Handler) { + processMethods := v1.File_process_v1_process_proto.Services().ByName("Process").Methods() + processListHandler := connect.NewUnaryHandler( + ProcessListProcedure, + svc.List, + connect.WithSchema(processMethods.ByName("List")), + connect.WithHandlerOptions(opts...), + ) + processConnectHandler := connect.NewServerStreamHandler( + ProcessConnectProcedure, + svc.Connect, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithHandlerOptions(opts...), + ) + processStartHandler := connect.NewServerStreamHandler( + ProcessStartProcedure, + svc.Start, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithHandlerOptions(opts...), + ) + processUpdateHandler := connect.NewUnaryHandler( + ProcessUpdateProcedure, + svc.Update, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithHandlerOptions(opts...), + ) + processStreamInputHandler := connect.NewClientStreamHandler( + ProcessStreamInputProcedure, + svc.StreamInput, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithHandlerOptions(opts...), + ) + processSendInputHandler := connect.NewUnaryHandler( + ProcessSendInputProcedure, + svc.SendInput, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithHandlerOptions(opts...), + ) + processSendSignalHandler := connect.NewUnaryHandler( + ProcessSendSignalProcedure, + svc.SendSignal, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithHandlerOptions(opts...), + ) + processCloseStdinHandler := connect.NewUnaryHandler( + ProcessCloseStdinProcedure, + svc.CloseStdin, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithHandlerOptions(opts...), + ) + return "/process.Process/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ProcessListProcedure: + processListHandler.ServeHTTP(w, r) + case ProcessConnectProcedure: + processConnectHandler.ServeHTTP(w, r) + case ProcessStartProcedure: + processStartHandler.ServeHTTP(w, r) + case ProcessUpdateProcedure: + processUpdateHandler.ServeHTTP(w, r) + case ProcessStreamInputProcedure: + processStreamInputHandler.ServeHTTP(w, r) + case ProcessSendInputProcedure: + processSendInputHandler.ServeHTTP(w, r) + case ProcessSendSignalProcedure: + processSendSignalHandler.ServeHTTP(w, r) + case ProcessCloseStdinProcedure: + processCloseStdinHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedProcessHandler returns CodeUnimplemented from all methods. +type UnimplementedProcessHandler struct{} + +func (UnimplementedProcessHandler) List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.List is not implemented")) +} + +func (UnimplementedProcessHandler) Connect(context.Context, *connect.Request[v1.ConnectRequest], *connect.ServerStream[v1.ConnectResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Connect is not implemented")) +} + +func (UnimplementedProcessHandler) Start(context.Context, *connect.Request[v1.StartRequest], *connect.ServerStream[v1.StartResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Start is not implemented")) +} + +func (UnimplementedProcessHandler) Update(context.Context, *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Update is not implemented")) +} + +func (UnimplementedProcessHandler) StreamInput(context.Context, *connect.ClientStream[v1.StreamInputRequest]) (*connect.Response[v1.StreamInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.StreamInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendInput(context.Context, *connect.Request[v1.SendInputRequest]) (*connect.Response[v1.SendInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendSignal(context.Context, *connect.Request[v1.SendSignalRequest]) (*connect.Response[v1.SendSignalResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendSignal is not implemented")) +} + +func (UnimplementedProcessHandler) CloseStdin(context.Context, *connect.Request[v1.CloseStdinRequest]) (*connect.Response[v1.CloseStdinResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.CloseStdin is not implemented")) +} diff --git a/worker/worker-bridge-e2b/internal/logging/logging.go b/worker/worker-bridge-e2b/internal/logging/logging.go new file mode 100644 index 0000000..7283bcf --- /dev/null +++ b/worker/worker-bridge-e2b/internal/logging/logging.go @@ -0,0 +1,70 @@ +package logging + +import ( + "fmt" + "log/slog" + "os" + "strings" +) + +const ( + defaultLogLevel = "info" + defaultLogFormat = "json" + defaultLogAddSource = false +) + +var singleLineReplacer = strings.NewReplacer( + "\r\n", "\\n", + "\n", "\\n", + "\r", "\\r", +) + +func formatSingleLine(format string, args ...any) string { + return singleLineReplacer.Replace(fmt.Sprintf(format, args...)) +} + +func init() { + Configure(defaultLogLevel, defaultLogFormat, defaultLogAddSource) +} + +func Configure(level string, format string, addSource bool) { + slog.SetDefault(newLogger(level, format, addSource)) +} + +func Infof(format string, args ...any) { + slog.Info(formatSingleLine(format, args...)) +} + +func Warnf(format string, args ...any) { + slog.Warn(formatSingleLine(format, args...)) +} + +func Errorf(format string, args ...any) { + slog.Error(formatSingleLine(format, args...)) +} + +func Fatalf(format string, args ...any) { + slog.Error(formatSingleLine(format, args...)) + os.Exit(1) +} + +func newLogger(level string, format string, addSource bool) *slog.Logger { + resolvedLevel := slog.LevelInfo + switch strings.TrimSpace(strings.ToLower(level)) { + case "debug": + resolvedLevel = slog.LevelDebug + case "warn": + resolvedLevel = slog.LevelWarn + case "error": + resolvedLevel = slog.LevelError + } + + options := &slog.HandlerOptions{ + Level: resolvedLevel, + AddSource: addSource, + } + if strings.TrimSpace(strings.ToLower(format)) == "text" { + return slog.New(slog.NewTextHandler(os.Stdout, options)) + } + return slog.New(slog.NewJSONHandler(os.Stdout, options)) +} diff --git a/worker/worker-bridge-e2b/internal/runner/capability_dispatch_test.go b/worker/worker-bridge-e2b/internal/runner/capability_dispatch_test.go new file mode 100644 index 0000000..f4e350b --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/capability_dispatch_test.go @@ -0,0 +1,136 @@ +package runner + +import ( + "context" + "encoding/json" + "testing" + "time" + + registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1" +) + +func TestAllCapabilityDispatchersEncodeResults(t *testing.T) { + originalPython := runPythonExec + originalTerminal := runTerminalExec + originalResource := runTerminalResource + t.Cleanup(func() { + runPythonExec = originalPython + runTerminalExec = originalTerminal + runTerminalResource = originalResource + }) + runPythonExec = func(_ context.Context, code string) (pythonExecRunResult, error) { + if code != "print(1)" { + t.Fatalf("unexpected Python code %q", code) + } + return pythonExecRunResult{Output: "1\n", ExitCode: 0}, nil + } + runTerminalExec = func(_ context.Context, req terminalExecRequest) (terminalExecRunResult, error) { + if req.Command != "pwd" || req.SessionID != "session-1" { + t.Fatalf("unexpected terminal request: %#v", req) + } + return terminalExecRunResult{ + SessionID: req.SessionID, + Stdout: "/workspace\n", + LeaseExpiresUnixMS: 1234, + }, nil + } + runTerminalResource = func(_ context.Context, req terminalResourceRequest) (terminalResourceRunResult, error) { + if req.SessionID != "session-1" || req.FilePath != "/tmp/a.txt" || req.Action != "read" { + t.Fatalf("unexpected resource request: %#v", req) + } + return terminalResourceRunResult{ + SessionID: req.SessionID, + FilePath: req.FilePath, + MIMEType: "text/plain", + SizeBytes: 1, + Blob: []byte("a"), + }, nil + } + + tests := []struct { + name string + capability string + payload string + assert func(*testing.T, []byte) + }{ + { + name: "echo", + capability: "echo", + payload: `{"message":"hello"}`, + assert: func(t *testing.T, payload []byte) { + if string(payload) != `{"message":"hello"}` { + t.Fatalf("unexpected echo payload %s", payload) + } + }, + }, + { + name: "pythonExec", + capability: "pythonExec", + payload: `{"code":"print(1)"}`, + assert: func(t *testing.T, payload []byte) { + var result pythonExecResult + if json.Unmarshal(payload, &result) != nil || result.Output != "1\n" || result.ExitCode != 0 { + t.Fatalf("unexpected Python result %s", payload) + } + }, + }, + { + name: "terminalExec", + capability: "terminalExec", + payload: `{"command":"pwd","session_id":"session-1"}`, + assert: func(t *testing.T, payload []byte) { + var result terminalExecRunResult + if json.Unmarshal(payload, &result) != nil || result.SessionID != "session-1" || result.Stdout != "/workspace\n" { + t.Fatalf("unexpected terminal result %s", payload) + } + }, + }, + { + name: "terminalResource", + capability: "terminalResource", + payload: `{"session_id":"session-1","file_path":"/tmp/a.txt","action":"read"}`, + assert: func(t *testing.T, payload []byte) { + var result terminalResourceRunResult + if json.Unmarshal(payload, &result) != nil || string(result.Blob) != "a" || result.MIMEType != "text/plain" { + t.Fatalf("unexpected resource result %s", payload) + } + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + request := buildCommandResultWithContext(context.Background(), ®istryv1.CommandDispatch{ + CommandId: "command-" + tc.name, + Capability: tc.capability, + PayloadJson: []byte(tc.payload), + }) + result := request.GetCommandResult() + if result == nil || result.GetError() != nil || result.GetCompletedUnixMs() == 0 { + t.Fatalf("unexpected command result: %#v", result) + } + tc.assert(t, result.GetPayloadJson()) + }) + } +} + +func TestExpiredDispatchDoesNotInvokeTool(t *testing.T) { + originalTerminal := runTerminalExec + t.Cleanup(func() { runTerminalExec = originalTerminal }) + called := false + runTerminalExec = func(context.Context, terminalExecRequest) (terminalExecRunResult, error) { + called = true + return terminalExecRunResult{}, nil + } + request := buildCommandResultWithContext(context.Background(), ®istryv1.CommandDispatch{ + CommandId: "expired", + Capability: "terminalExec", + PayloadJson: []byte(`{"command":"pwd"}`), + DeadlineUnixMs: time.Now().Add(-time.Second).UnixMilli(), + }) + if called { + t.Fatal("expired dispatch invoked the tool") + } + if code := request.GetCommandResult().GetError().GetCode(); code != "deadline_exceeded" { + t.Fatalf("unexpected error code %q", code) + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/capability_executor.go b/worker/worker-bridge-e2b/internal/runner/capability_executor.go new file mode 100644 index 0000000..bf5d03c --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/capability_executor.go @@ -0,0 +1,309 @@ +package runner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1" +) + +func buildCommandResult(dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest { + return buildCommandResultWithContext(context.Background(), dispatch) +} + +func buildCommandResultWithContext(baseCtx context.Context, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest { + commandID := strings.TrimSpace(dispatch.GetCommandId()) + if commandID == "" { + return ®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_CommandResult{ + CommandResult: ®istryv1.CommandResult{ + CommandId: commandID, + Error: ®istryv1.CommandError{ + Code: "invalid_command_id", + Message: "command_id is required", + }, + }, + }, + } + } + + if deadline := dispatch.GetDeadlineUnixMs(); deadline > 0 && time.Now().UnixMilli() > deadline { + return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded") + } + + capability := strings.TrimSpace(strings.ToLower(dispatch.GetCapability())) + switch capability { + case echoCapabilityName: + return buildEchoCommandResult(commandID, dispatch) + case pythonExecCapabilityName: + return buildPythonExecCommandResult(baseCtx, commandID, dispatch) + case terminalExecCapabilityName: + return buildTerminalExecCommandResult(baseCtx, commandID, dispatch) + case terminalResourceCapabilityName: + return buildTerminalResourceCommandResult(baseCtx, commandID, dispatch) + default: + return commandErrorResult(commandID, "unsupported_capability", fmt.Sprintf("capability %q is not supported", dispatch.GetCapability())) + } +} + +func buildEchoCommandResult(commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest { + message := "" + resultPayload := append([]byte(nil), dispatch.GetPayloadJson()...) + if len(resultPayload) > 0 { + decoded := struct { + Message string `json:"message"` + }{} + if err := json.Unmarshal(resultPayload, &decoded); err != nil { + return commandErrorResult(commandID, "invalid_payload", "payload_json is not valid echo payload") + } + message = decoded.Message + } + if strings.TrimSpace(message) == "" { + return commandErrorResult(commandID, "invalid_payload", "echo payload is required") + } + if len(resultPayload) == 0 { + encoded, err := json.Marshal(struct { + Message string `json:"message"` + }{ + Message: message, + }) + if err != nil { + return commandErrorResult(commandID, "encode_failed", "failed to encode echo payload") + } + resultPayload = encoded + } + + return ®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_CommandResult{ + CommandResult: ®istryv1.CommandResult{ + CommandId: commandID, + PayloadJson: resultPayload, + CompletedUnixMs: time.Now().UnixMilli(), + }, + }, + } +} + +type pythonExecPayload struct { + Code string `json:"code"` +} + +type pythonExecResult struct { + Output string `json:"output"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` +} + +type pythonExecRunResult struct { + Output string + Stderr string + ExitCode int +} + +func buildPythonExecCommandResult(baseCtx context.Context, commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest { + payload := append([]byte(nil), dispatch.GetPayloadJson()...) + if len(payload) == 0 { + return commandErrorResult(commandID, "invalid_payload", "pythonExec payload is required") + } + + decoded := pythonExecPayload{} + if err := json.Unmarshal(payload, &decoded); err != nil { + return commandErrorResult(commandID, "invalid_payload", "payload_json is not valid pythonExec payload") + } + if strings.TrimSpace(decoded.Code) == "" { + return commandErrorResult(commandID, "invalid_payload", "pythonExec code is required") + } + + commandCtx := baseCtx + if commandCtx == nil { + commandCtx = context.Background() + } + cancel := func() {} + if deadlineUnixMS := dispatch.GetDeadlineUnixMs(); deadlineUnixMS > 0 { + commandCtx, cancel = context.WithDeadline(commandCtx, time.UnixMilli(deadlineUnixMS)) + } + defer cancel() + + execResult, err := runPythonExec(commandCtx, decoded.Code) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded") + } + return commandErrorResult(commandID, "execution_failed", fmt.Sprintf("pythonExec execution failed: %v", err)) + } + + resultPayload, err := json.Marshal(pythonExecResult{ + Output: execResult.Output, + Stderr: execResult.Stderr, + ExitCode: execResult.ExitCode, + }) + if err != nil { + return commandErrorResult(commandID, "encode_failed", "failed to encode pythonExec payload") + } + + return ®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_CommandResult{ + CommandResult: ®istryv1.CommandResult{ + CommandId: commandID, + PayloadJson: resultPayload, + CompletedUnixMs: time.Now().UnixMilli(), + }, + }, + } +} + +func buildTerminalExecCommandResult(baseCtx context.Context, commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest { + payload := append([]byte(nil), dispatch.GetPayloadJson()...) + if len(payload) == 0 { + return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalExec payload is required") + } + + decoded := terminalExecPayload{} + if err := json.Unmarshal(payload, &decoded); err != nil { + return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "payload_json is not valid terminalExec payload") + } + if strings.TrimSpace(decoded.Command) == "" { + return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalExec command is required") + } + + commandCtx := baseCtx + if commandCtx == nil { + commandCtx = context.Background() + } + cancel := func() {} + if deadlineUnixMS := dispatch.GetDeadlineUnixMs(); deadlineUnixMS > 0 { + commandCtx, cancel = context.WithDeadline(commandCtx, time.UnixMilli(deadlineUnixMS)) + } + defer cancel() + + execResult, err := runTerminalExec(commandCtx, terminalExecRequest{ + Command: decoded.Command, + SessionID: decoded.SessionID, + CreateIfMissing: decoded.CreateIfMissing, + LeaseTTLSec: decoded.LeaseTTLSec, + }) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded") + } + var terminalErr *terminalExecError + if errors.As(err, &terminalErr) { + return commandErrorResult(commandID, terminalErr.Code(), terminalErr.Error()) + } + return commandErrorResult(commandID, "execution_failed", fmt.Sprintf("terminalExec execution failed: %v", err)) + } + + resultPayload, err := json.Marshal(execResult) + if err != nil { + return commandErrorResult(commandID, "encode_failed", "failed to encode terminalExec payload") + } + + return ®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_CommandResult{ + CommandResult: ®istryv1.CommandResult{ + CommandId: commandID, + PayloadJson: resultPayload, + CompletedUnixMs: time.Now().UnixMilli(), + }, + }, + } +} + +func buildTerminalResourceCommandResult(baseCtx context.Context, commandID string, dispatch *registryv1.CommandDispatch) *registryv1.ConnectRequest { + payload := append([]byte(nil), dispatch.GetPayloadJson()...) + if len(payload) == 0 { + return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalResource payload is required") + } + + decoded := terminalResourcePayload{} + if err := json.Unmarshal(payload, &decoded); err != nil { + return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "payload_json is not valid terminalResource payload") + } + if strings.TrimSpace(decoded.SessionID) == "" || strings.TrimSpace(decoded.FilePath) == "" { + return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalResource session_id and file_path are required") + } + if strings.EqualFold(strings.TrimSpace(decoded.Action), terminalResourceActionExport) && strings.TrimSpace(decoded.SignedURL) == "" { + return commandErrorResult(commandID, terminalExecCodeInvalidPayload, "terminalResource signed_url is required for export") + } + + commandCtx := baseCtx + if commandCtx == nil { + commandCtx = context.Background() + } + cancel := func() {} + if deadlineUnixMS := dispatch.GetDeadlineUnixMs(); deadlineUnixMS > 0 { + commandCtx, cancel = context.WithDeadline(commandCtx, time.UnixMilli(deadlineUnixMS)) + } + defer cancel() + + resourceResult, err := runTerminalResource(commandCtx, terminalResourceRequest{ + SessionID: decoded.SessionID, + FilePath: decoded.FilePath, + Action: decoded.Action, + SignedURL: decoded.SignedURL, + Headers: cloneStringMap(decoded.Headers), + }) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return commandErrorResult(commandID, "deadline_exceeded", "command deadline exceeded") + } + var terminalErr *terminalExecError + if errors.As(err, &terminalErr) { + return commandErrorResult(commandID, terminalErr.Code(), terminalErr.Error()) + } + return commandErrorResult(commandID, "execution_failed", fmt.Sprintf("terminalResource execution failed: %v", err)) + } + + resultPayload, err := json.Marshal(resourceResult) + if err != nil { + return commandErrorResult(commandID, "encode_failed", "failed to encode terminalResource payload") + } + + return ®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_CommandResult{ + CommandResult: ®istryv1.CommandResult{ + CommandId: commandID, + PayloadJson: resultPayload, + CompletedUnixMs: time.Now().UnixMilli(), + }, + }, + } +} + +func cloneStringMap(values map[string]string) map[string]string { + if len(values) == 0 { + return nil + } + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func commandErrorResult(commandID string, code string, message string) *registryv1.ConnectRequest { + return ®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_CommandResult{ + CommandResult: ®istryv1.CommandResult{ + CommandId: commandID, + CompletedUnixMs: time.Now().UnixMilli(), + Error: ®istryv1.CommandError{ + Code: code, + Message: message, + }, + }, + }, + } +} + +func runTerminalExecUnavailable(context.Context, terminalExecRequest) (terminalExecRunResult, error) { + return terminalExecRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage) +} + +func runTerminalResourceUnavailable(context.Context, terminalResourceRequest) (terminalResourceRunResult, error) { + return terminalResourceRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage) +} diff --git a/worker/worker-bridge-e2b/internal/runner/console_session_test.go b/worker/worker-bridge-e2b/internal/runner/console_session_test.go new file mode 100644 index 0000000..bba9f37 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/console_session_test.go @@ -0,0 +1,295 @@ +package runner + +import ( + "context" + "encoding/json" + "errors" + "net" + "strings" + "sync/atomic" + "testing" + "time" + + registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestConsoleSessionContract(t *testing.T) { + originalActiveSessionCount := activeSessionCountFn + activeSessionCountFn = func() int32 { return 3 } + t.Cleanup(func() { activeSessionCountFn = originalActiveSessionCount }) + + service := &consoleContractService{} + server := grpc.NewServer() + registryv1.RegisterWorkerRegistryServiceServer(server, service) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + go func() { _ = server.Serve(listener) }() + + cfg := consoleTestConfig() + cfg.ConsoleGRPCTarget = listener.Addr().String() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + err = runSession(ctx, cfg) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("expected FailedPrecondition, got %v", err) + } + if atomic.LoadInt32(&service.commandResults) != 1 { + t.Fatalf("expected one command result") + } +} + +func TestHeartbeatToleratesOneMissAndFailsAfterTwo(t *testing.T) { + cfg := consoleTestConfig() + cfg.CallTimeout = 20 * time.Millisecond + + outbound := make(chan *registryv1.ConnectRequest, 8) + acks := make(chan *registryv1.HeartbeatAck, 1) + sessionErrors := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- heartbeatLoop(ctx, outbound, acks, sessionErrors, cfg, "session-1", 5*time.Millisecond) + }() + <-outbound // First heartbeat times out. + second := <-outbound + if second.GetHeartbeat() == nil { + t.Fatalf("expected second heartbeat") + } + acks <- ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1} + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("expected recovery after one missed ack, got %v", err) + } + + outbound = make(chan *registryv1.ConnectRequest, 8) + err := heartbeatLoop(context.Background(), outbound, make(chan *registryv1.HeartbeatAck), make(chan error), cfg, "session-2", 5*time.Millisecond) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected failure after two missed acks, got %v", err) + } +} + +func TestConsoleSessionDispatchesAllCapabilities(t *testing.T) { + originalPython := runPythonExec + originalTerminal := runTerminalExec + originalResource := runTerminalResource + t.Cleanup(func() { + runPythonExec = originalPython + runTerminalExec = originalTerminal + runTerminalResource = originalResource + }) + runPythonExec = func(context.Context, string) (pythonExecRunResult, error) { + return pythonExecRunResult{Output: "python-ok", ExitCode: 0}, nil + } + runTerminalExec = func(context.Context, terminalExecRequest) (terminalExecRunResult, error) { + return terminalExecRunResult{SessionID: "terminal-session", Stdout: "terminal-ok"}, nil + } + runTerminalResource = func(context.Context, terminalResourceRequest) (terminalResourceRunResult, error) { + return terminalResourceRunResult{ + SessionID: "terminal-session", + FilePath: "/tmp/file.txt", + MIMEType: "text/plain", + SizeBytes: 2, + Blob: []byte("ok"), + }, nil + } + + service := &allCapabilityContractService{} + server := grpc.NewServer() + registryv1.RegisterWorkerRegistryServiceServer(server, service) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + go func() { _ = server.Serve(listener) }() + cfg := consoleTestConfig() + cfg.ConsoleGRPCTarget = listener.Addr().String() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := runSession(ctx, cfg); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("expected FailedPrecondition after all results, got %v", err) + } + if got := atomic.LoadInt32(&service.results); got != 4 { + t.Fatalf("expected all four capability results, got %d", got) + } +} + +func consoleTestConfig() config.Config { + return config.Config{ + ConsoleGRPCTarget: "127.0.0.1:1", + ConsoleTLS: false, + WorkerID: "worker-e2b-1", + WorkerSecret: "worker-secret", + HeartbeatInterval: 10 * time.Millisecond, + HeartbeatJitter: 0, + CallTimeout: time.Second, + NodeName: "e2b-test", + ExecutorKind: "e2b", + EchoMaxInflight: 4, + PythonExecMaxInflight: 4, + TerminalExecMaxInflight: 4, + TerminalResourceMaxInflight: 4, + } +} + +type consoleContractService struct { + registryv1.UnimplementedWorkerRegistryServiceServer + commandResults int32 +} + +type allCapabilityContractService struct { + registryv1.UnimplementedWorkerRegistryServiceServer + results int32 +} + +func (s *allCapabilityContractService) Connect(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error { + first, err := stream.Recv() + if err != nil { + return err + } + if first.GetHello() == nil { + return status.Error(codes.InvalidArgument, "hello required") + } + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_ConnectAck{ + ConnectAck: ®istryv1.ConnectAck{SessionId: "all-capabilities", HeartbeatIntervalSec: 1}, + }, + }); err != nil { + return err + } + firstHeartbeat, err := stream.Recv() + if err != nil || firstHeartbeat.GetHeartbeat() == nil { + return status.Error(codes.InvalidArgument, "heartbeat required") + } + dispatches := []*registryv1.CommandDispatch{ + {CommandId: "echo", Capability: "echo", PayloadJson: []byte(`{"message":"echo-ok"}`)}, + {CommandId: "python", Capability: "pythonExec", PayloadJson: []byte(`{"code":"print(1)"}`)}, + {CommandId: "terminal", Capability: "terminalExec", PayloadJson: []byte(`{"command":"pwd"}`)}, + {CommandId: "resource", Capability: "terminalResource", PayloadJson: []byte(`{"session_id":"terminal-session","file_path":"/tmp/file.txt","action":"read"}`)}, + } + for _, dispatch := range dispatches { + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_CommandDispatch{CommandDispatch: dispatch}, + }); err != nil { + return err + } + } + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_HeartbeatAck{ + HeartbeatAck: ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1}, + }, + }); err != nil { + return err + } + seen := map[string]bool{} + for len(seen) < len(dispatches) { + frame, err := stream.Recv() + if err != nil { + return err + } + result := frame.GetCommandResult() + if result == nil { + continue + } + if result.GetError() != nil || len(result.GetPayloadJson()) == 0 { + return status.Errorf(codes.InvalidArgument, "capability %s failed", result.GetCommandId()) + } + seen[result.GetCommandId()] = true + } + atomic.StoreInt32(&s.results, int32(len(seen))) + return status.Error(codes.FailedPrecondition, "contract complete") +} + +func (s *consoleContractService) Connect(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error { + first, err := stream.Recv() + if err != nil { + return err + } + hello := first.GetHello() + if hello == nil || hello.GetNodeId() != "worker-e2b-1" || hello.GetWorkerSecret() != "worker-secret" { + return status.Error(codes.Unauthenticated, "invalid hello identity") + } + if hello.GetExecutorKind() != "e2b" || !strings.HasPrefix(hello.GetNodeName(), "e2b-") { + return status.Error(codes.InvalidArgument, "invalid hello metadata") + } + capabilities := map[string]int32{} + for _, declaration := range hello.GetCapabilities() { + capabilities[declaration.GetName()] = declaration.GetMaxInflight() + } + for _, name := range []string{"echo", "pythonExec", "terminalExec", "terminalResource"} { + if capabilities[name] != 4 { + return status.Errorf(codes.InvalidArgument, "invalid capability %s", name) + } + } + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_ConnectAck{ + ConnectAck: ®istryv1.ConnectAck{ + SessionId: "console-session", + HeartbeatIntervalSec: 1, + }, + }, + }); err != nil { + return err + } + + heartbeatFrame, err := stream.Recv() + if err != nil { + return err + } + heartbeat := heartbeatFrame.GetHeartbeat() + if heartbeat == nil || + heartbeat.GetNodeId() != hello.GetNodeId() || + heartbeat.GetSessionId() != "console-session" || + heartbeat.GetActiveSessionCount() != 3 { + return status.Error(codes.InvalidArgument, "invalid heartbeat") + } + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_CommandDispatch{ + CommandDispatch: ®istryv1.CommandDispatch{ + CommandId: "echo-command", + Capability: "echo", + PayloadJson: []byte(`{"message":"from-console"}`), + }, + }, + }); err != nil { + return err + } + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_HeartbeatAck{ + HeartbeatAck: ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1}, + }, + }); err != nil { + return err + } + for { + frame, err := stream.Recv() + if err != nil { + return err + } + if result := frame.GetCommandResult(); result != nil { + var payload struct { + Message string `json:"message"` + } + if result.GetCommandId() != "echo-command" || + json.Unmarshal(result.GetPayloadJson(), &payload) != nil || + payload.Message != "from-console" { + return status.Error(codes.InvalidArgument, "invalid command result") + } + atomic.AddInt32(&s.commandResults, 1) + return status.Error(codes.FailedPrecondition, "session replaced") + } + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/e2b_backend.go b/worker/worker-bridge-e2b/internal/runner/e2b_backend.go new file mode 100644 index 0000000..1c2c74c --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/e2b_backend.go @@ -0,0 +1,16 @@ +package runner + +import ( + "context" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" +) + +type e2bBackend interface { + Create(context.Context, string, int) (*e2b.Sandbox, error) + SetTimeout(context.Context, string, int) error + Kill(context.Context, string) error + Run(context.Context, *e2b.Sandbox, string, int) (e2b.CommandResult, error) + ReadFile(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error) + OpenFile(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) +} diff --git a/worker/worker-bridge-e2b/internal/runner/full_worker_integration_test.go b/worker/worker-bridge-e2b/internal/runner/full_worker_integration_test.go new file mode 100644 index 0000000..d57f1bb --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/full_worker_integration_test.go @@ -0,0 +1,251 @@ +package runner + +import ( + "context" + "encoding/json" + "net" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestIntegrationConsoleDispatchesAllCapabilitiesToE2B(t *testing.T) { + if os.Getenv("E2B_INTEGRATION") != "1" { + t.Skip("set E2B_INTEGRATION=1 to run against E2B") + } + apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY")) + pythonTemplate := strings.TrimSpace(os.Getenv("E2B_PYTHON_TEMPLATE")) + terminalTemplate := strings.TrimSpace(os.Getenv("E2B_TERMINAL_TEMPLATE")) + if apiKey == "" || pythonTemplate == "" || terminalTemplate == "" { + t.Fatal("E2B_API_KEY, E2B_PYTHON_TEMPLATE and E2B_TERMINAL_TEMPLATE are required") + } + client, err := e2b.NewClient(e2b.Config{ + APIKey: apiKey, + APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")), + Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")), + RequestTimeout: 60 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + backend := &recordingE2BBackend{e2bBackend: client} + manager := newLiveTerminalManager(backend, terminalTemplate, 2) + defer manager.Close() + python := newPythonExecRunner(backend, pythonTemplate, 120) + + originalPython := runPythonExec + originalTerminal := runTerminalExec + originalResource := runTerminalResource + originalActiveSessions := activeSessionCountFn + runPythonExec = python.Execute + runTerminalExec = manager.Execute + runTerminalResource = manager.ResolveResource + activeSessionCountFn = manager.ActiveSessionCount + t.Cleanup(func() { + runPythonExec = originalPython + runTerminalExec = originalTerminal + runTerminalResource = originalResource + activeSessionCountFn = originalActiveSessions + }) + + service := &liveAllCapabilityService{} + server := grpc.NewServer() + registryv1.RegisterWorkerRegistryServiceServer(server, service) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + go func() { _ = server.Serve(listener) }() + + cfg := consoleTestConfig() + cfg.ConsoleGRPCTarget = listener.Addr().String() + cfg.CallTimeout = 3 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := runSession(ctx, cfg); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("full worker session failed: %v", err) + } + if atomic.LoadInt32(&service.verifiedResults) != 4 { + t.Fatalf("expected four verified capability results, got %d", service.verifiedResults) + } + manager.Close() + if backend.killCount() != 2 { + t.Fatalf("expected Python and terminal sandboxes to be cleaned up, kill_count=%d", backend.killCount()) + } +} + +type liveAllCapabilityService struct { + registryv1.UnimplementedWorkerRegistryServiceServer + verifiedResults int32 +} + +func (s *liveAllCapabilityService) Connect(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error { + first, err := stream.Recv() + if err != nil { + return err + } + if first.GetHello() == nil { + return status.Error(codes.InvalidArgument, "hello required") + } + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_ConnectAck{ + ConnectAck: ®istryv1.ConnectAck{SessionId: "live-all-tools", HeartbeatIntervalSec: 1}, + }, + }); err != nil { + return err + } + if err := s.waitForHeartbeat(stream); err != nil { + return err + } + deadline := time.Now().Add(90 * time.Second).UnixMilli() + for _, dispatch := range []*registryv1.CommandDispatch{ + { + CommandId: "live-echo", + Capability: "echo", + PayloadJson: []byte(`{"message":"echo-dispatch-live"}`), + DeadlineUnixMs: deadline, + }, + { + CommandId: "live-python", + Capability: "pythonExec", + PayloadJson: []byte(`{"code":"print(\"python-dispatch-live\")"}`), + DeadlineUnixMs: deadline, + }, + { + CommandId: "live-terminal", + Capability: "terminalExec", + PayloadJson: []byte(`{"command":"printf 'resource-dispatch-live' > /tmp/onlyboxes-dispatch-live.txt; printf 'terminal-dispatch-live'"}`), + DeadlineUnixMs: deadline, + }, + } { + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_CommandDispatch{CommandDispatch: dispatch}, + }); err != nil { + return err + } + } + + seen := map[string]bool{} + terminalSessionID := "" + for len(seen) < 3 { + frame, err := stream.Recv() + if err != nil { + return err + } + if heartbeat := frame.GetHeartbeat(); heartbeat != nil { + if err := s.sendHeartbeatAck(stream); err != nil { + return err + } + continue + } + result := frame.GetCommandResult() + if result == nil || result.GetError() != nil { + return status.Errorf(codes.InvalidArgument, "tool failed: %#v", result) + } + switch result.GetCommandId() { + case "live-echo": + var decoded struct { + Message string `json:"message"` + } + if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil || decoded.Message != "echo-dispatch-live" { + return status.Error(codes.InvalidArgument, "invalid echo result") + } + case "live-python": + var decoded pythonExecResult + if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil || + strings.TrimSpace(decoded.Output) != "python-dispatch-live" || + decoded.ExitCode != 0 { + return status.Error(codes.InvalidArgument, "invalid Python result") + } + case "live-terminal": + var decoded terminalExecRunResult + if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil || + decoded.Stdout != "terminal-dispatch-live" || + decoded.SessionID == "" { + return status.Error(codes.InvalidArgument, "invalid terminal result") + } + terminalSessionID = decoded.SessionID + default: + return status.Error(codes.InvalidArgument, "unknown command result") + } + seen[result.GetCommandId()] = true + } + if terminalSessionID == "" { + return status.Error(codes.InvalidArgument, "terminal session missing") + } + resourcePayload, _ := json.Marshal(terminalResourcePayload{ + SessionID: terminalSessionID, + FilePath: "/tmp/onlyboxes-dispatch-live.txt", + Action: terminalResourceActionRead, + }) + if err := stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_CommandDispatch{ + CommandDispatch: ®istryv1.CommandDispatch{ + CommandId: "live-resource", + Capability: "terminalResource", + PayloadJson: resourcePayload, + DeadlineUnixMs: deadline, + }, + }, + }); err != nil { + return err + } + for { + frame, err := stream.Recv() + if err != nil { + return err + } + if frame.GetHeartbeat() != nil { + if err := s.sendHeartbeatAck(stream); err != nil { + return err + } + continue + } + result := frame.GetCommandResult() + if result == nil { + continue + } + if result.GetCommandId() != "live-resource" || result.GetError() != nil { + return status.Errorf(codes.InvalidArgument, "resource failed: %#v", result) + } + var decoded terminalResourceRunResult + if json.Unmarshal(result.GetPayloadJson(), &decoded) != nil || + string(decoded.Blob) != "resource-dispatch-live" { + return status.Error(codes.InvalidArgument, "invalid resource result") + } + atomic.StoreInt32(&s.verifiedResults, 4) + return status.Error(codes.FailedPrecondition, "live capability contract complete") + } +} + +func (s *liveAllCapabilityService) waitForHeartbeat(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error { + for { + frame, err := stream.Recv() + if err != nil { + return err + } + if frame.GetHeartbeat() != nil { + return s.sendHeartbeatAck(stream) + } + } +} + +func (s *liveAllCapabilityService) sendHeartbeatAck(stream grpc.BidiStreamingServer[registryv1.ConnectRequest, registryv1.ConnectResponse]) error { + return stream.Send(®istryv1.ConnectResponse{ + Payload: ®istryv1.ConnectResponse_HeartbeatAck{ + HeartbeatAck: ®istryv1.HeartbeatAck{HeartbeatIntervalSec: 1}, + }, + }) +} diff --git a/worker/worker-bridge-e2b/internal/runner/hello_builder.go b/worker/worker-bridge-e2b/internal/runner/hello_builder.go new file mode 100644 index 0000000..41bd4ea --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/hello_builder.go @@ -0,0 +1,49 @@ +package runner + +import ( + "fmt" + "strings" + + registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/buildinfo" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config" +) + +func buildHello(cfg config.Config) (*registryv1.ConnectHello, error) { + nodeName := strings.TrimSpace(cfg.NodeName) + if nodeName == "" { + suffix := cfg.WorkerID + if len(suffix) > 8 { + suffix = suffix[:8] + } + nodeName = fmt.Sprintf("worker-bridge-e2b-%s", suffix) + } + + hello := ®istryv1.ConnectHello{ + NodeId: cfg.WorkerID, + NodeName: nodeName, + ExecutorKind: cfg.ExecutorKind, + Labels: cfg.Labels, + Version: buildinfo.Version, + WorkerSecret: cfg.WorkerSecret, + Capabilities: []*registryv1.CapabilityDeclaration{ + { + Name: echoCapabilityName, + MaxInflight: int32(cfg.EchoMaxInflight), + }, + { + Name: pythonExecCapabilityDeclared, + MaxInflight: int32(cfg.PythonExecMaxInflight), + }, + { + Name: terminalExecCapabilityDeclared, + MaxInflight: int32(cfg.TerminalExecMaxInflight), + }, + { + Name: terminalResourceCapabilityDeclared, + MaxInflight: int32(cfg.TerminalResourceMaxInflight), + }, + }, + } + return hello, nil +} diff --git a/worker/worker-bridge-e2b/internal/runner/python_runtime.go b/worker/worker-bridge-e2b/internal/runner/python_runtime.go new file mode 100644 index 0000000..030cc11 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/python_runtime.go @@ -0,0 +1,69 @@ +package runner + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging" +) + +const pythonExecCleanupTimeout = 10 * time.Second + +type pythonExecRunner struct { + backend e2bBackend + template string + timeoutSec int +} + +func newPythonExecRunner(backend e2bBackend, template string, timeoutSec int) *pythonExecRunner { + return &pythonExecRunner{ + backend: backend, + template: strings.TrimSpace(template), + timeoutSec: timeoutSec, + } +} + +func (r *pythonExecRunner) Execute(ctx context.Context, code string) (pythonExecRunResult, error) { + if r == nil || r.backend == nil { + return pythonExecRunResult{}, errors.New("E2B python executor is unavailable") + } + timeoutSec := r.timeoutSec + if deadline, ok := ctx.Deadline(); ok { + remaining := int(time.Until(deadline).Seconds()) + 1 + if remaining > 0 && remaining < timeoutSec { + timeoutSec = remaining + } + } + if timeoutSec <= 0 { + timeoutSec = 60 + } + sandbox, err := r.backend.Create(ctx, r.template, timeoutSec) + if err != nil { + return pythonExecRunResult{}, fmt.Errorf("create E2B python sandbox: %w", err) + } + defer r.killSandbox(sandbox.ID) + + encoded := base64.StdEncoding.EncodeToString([]byte(code)) + command := "printf '%s' '" + encoded + "' | base64 -d > /tmp/onlyboxes-pythonexec.py && uv run /tmp/onlyboxes-pythonexec.py" + result, err := r.backend.Run(ctx, sandbox, command, -1) + if err != nil { + return pythonExecRunResult{}, err + } + return pythonExecRunResult{ + Output: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + }, nil +} + +func (r *pythonExecRunner) killSandbox(sandboxID string) { + ctx, cancel := context.WithTimeout(context.Background(), pythonExecCleanupTimeout) + defer cancel() + if err := r.backend.Kill(ctx, sandboxID); err != nil { + logging.Warnf("pythonExec cleanup failed: sandbox_id=%s err=%v", sandboxID, err) + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/python_runtime_integration_test.go b/worker/worker-bridge-e2b/internal/runner/python_runtime_integration_test.go new file mode 100644 index 0000000..83153e1 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/python_runtime_integration_test.go @@ -0,0 +1,43 @@ +package runner + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" +) + +func TestIntegrationPythonExec(t *testing.T) { + if os.Getenv("E2B_INTEGRATION") != "1" { + t.Skip("set E2B_INTEGRATION=1 to run against E2B") + } + apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY")) + template := strings.TrimSpace(os.Getenv("E2B_PYTHON_TEMPLATE")) + if apiKey == "" || template == "" { + t.Fatal("E2B_API_KEY and E2B_PYTHON_TEMPLATE are required") + } + backend, err := e2b.NewClient(e2b.Config{ + APIKey: apiKey, + APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")), + Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")), + RequestTimeout: 60 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + result, err := newPythonExecRunner(backend, template, 120).Execute( + ctx, + `print("python-template-ok")`, + ) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 || strings.TrimSpace(result.Output) != "python-template-ok" { + t.Fatalf("unexpected python result: %#v", result) + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/python_runtime_test.go b/worker/worker-bridge-e2b/internal/runner/python_runtime_test.go new file mode 100644 index 0000000..0774090 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/python_runtime_test.go @@ -0,0 +1,120 @@ +package runner + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" +) + +type fakePythonBackend struct { + mu sync.Mutex + template string + timeoutSec int + command string + outputLimit int + killed []string + runResult e2b.CommandResult + runErr error +} + +func (f *fakePythonBackend) Create(_ context.Context, template string, timeoutSec int) (*e2b.Sandbox, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.template = template + f.timeoutSec = timeoutSec + return &e2b.Sandbox{ID: "python-sandbox"}, nil +} + +func (f *fakePythonBackend) SetTimeout(context.Context, string, int) error { return nil } + +func (f *fakePythonBackend) Kill(_ context.Context, sandboxID string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.killed = append(f.killed, sandboxID) + return nil +} + +func (f *fakePythonBackend) Run(_ context.Context, _ *e2b.Sandbox, command string, outputLimit int) (e2b.CommandResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.command = command + f.outputLimit = outputLimit + return f.runResult, f.runErr +} + +func (f *fakePythonBackend) ReadFile(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error) { + return e2b.File{}, nil +} + +func (f *fakePythonBackend) OpenFile(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) { + return e2b.FileReader{Body: io.NopCloser(bytes.NewReader(nil))}, nil +} + +func TestPythonExecUsesOneShotUVSandboxAndAlwaysCleansUp(t *testing.T) { + t.Parallel() + backend := &fakePythonBackend{ + runResult: e2b.CommandResult{Stdout: "ok\n", Stderr: "warning\n", ExitCode: 3}, + } + runner := newPythonExecRunner(backend, "python-template", 300) + result, err := runner.Execute(context.Background(), `print("sensitive source")`) + if err != nil { + t.Fatal(err) + } + if result.Output != "ok\n" || result.Stderr != "warning\n" || result.ExitCode != 3 { + t.Fatalf("unexpected result: %#v", result) + } + backend.mu.Lock() + defer backend.mu.Unlock() + if backend.template != "python-template" || backend.timeoutSec != 300 { + t.Fatalf("unexpected create request: template=%q timeout=%d", backend.template, backend.timeoutSec) + } + if !strings.Contains(backend.command, "uv run /tmp/onlyboxes-pythonexec.py") { + t.Fatalf("python command does not invoke uv: %q", backend.command) + } + if strings.Contains(backend.command, "sensitive source") { + t.Fatalf("raw source was embedded in the shell command") + } + if backend.outputLimit != -1 { + t.Fatalf("python output should be unbounded, got %d", backend.outputLimit) + } + if len(backend.killed) != 1 || backend.killed[0] != "python-sandbox" { + t.Fatalf("sandbox was not cleaned up: %v", backend.killed) + } +} + +func TestPythonExecBoundsSandboxTTLByCommandDeadline(t *testing.T) { + t.Parallel() + backend := &fakePythonBackend{} + runner := newPythonExecRunner(backend, "python-template", 300) + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + if _, err := runner.Execute(ctx, "pass"); err != nil { + t.Fatal(err) + } + backend.mu.Lock() + defer backend.mu.Unlock() + if backend.timeoutSec < 1 || backend.timeoutSec > 2 { + t.Fatalf("expected deadline-bounded sandbox timeout, got %d", backend.timeoutSec) + } +} + +func TestPythonExecCleansUpAfterRunFailure(t *testing.T) { + t.Parallel() + backend := &fakePythonBackend{runErr: errors.New("stream failed")} + runner := newPythonExecRunner(backend, "python-template", 60) + if _, err := runner.Execute(context.Background(), "pass"); err == nil { + t.Fatal("expected run error") + } + backend.mu.Lock() + defer backend.mu.Unlock() + if len(backend.killed) != 1 || backend.killed[0] != "python-sandbox" { + t.Fatalf("sandbox was not cleaned up: %v", backend.killed) + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/runner.go b/worker/worker-bridge-e2b/internal/runner/runner.go new file mode 100644 index 0000000..6d8b477 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/runner.go @@ -0,0 +1,131 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + minHeartbeatInterval = 1 * time.Second + initialReconnectDelay = 1 * time.Second + maxReconnectDelay = 15 * time.Second + echoCapabilityName = "echo" + pythonExecCapabilityName = "pythonexec" + pythonExecCapabilityDeclared = "pythonExec" +) + +var waitReconnect = waitReconnectDelay +var applyJitter = jitterDuration +var runPythonExec = func(context.Context, string) (pythonExecRunResult, error) { + return pythonExecRunResult{}, errors.New("E2B python executor is unavailable") +} +var runTerminalExec = runTerminalExecUnavailable +var runTerminalResource = runTerminalResourceUnavailable +var activeSessionCountFn = func() int32 { return 0 } + +func Run(ctx context.Context, cfg config.Config) error { + if strings.TrimSpace(cfg.WorkerID) == "" { + return errors.New("WORKER_ID is required") + } + if strings.TrimSpace(cfg.WorkerSecret) == "" { + return errors.New("WORKER_SECRET is required") + } + if strings.TrimSpace(cfg.E2BAPIKey) == "" { + return errors.New("WORKER_E2B_API_KEY is required") + } + if strings.TrimSpace(cfg.E2BPythonTemplate) == "" { + return errors.New("WORKER_E2B_PYTHON_TEMPLATE is required") + } + if strings.TrimSpace(cfg.E2BTerminalTemplate) == "" { + return errors.New("WORKER_E2B_TERMINAL_TEMPLATE is required") + } + + backend, err := e2b.NewClient(e2b.Config{ + APIKey: cfg.E2BAPIKey, + APIURL: cfg.E2BAPIURL, + Domain: cfg.E2BDomain, + SandboxURL: cfg.E2BSandboxURL, + RequestTimeout: cfg.E2BRequestTimeout, + }) + if err != nil { + return fmt.Errorf("configure E2B client: %w", err) + } + + terminalManager := newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: cfg.E2BTerminalTemplate, + LeaseMinSec: cfg.TerminalLeaseMinSec, + LeaseMaxSec: cfg.TerminalLeaseMaxSec, + LeaseDefaultSec: cfg.TerminalLeaseDefaultSec, + OutputLimitBytes: cfg.TerminalOutputLimitBytes, + ExportMaxBytes: cfg.TerminalExportMaxBytes, + SessionMaxInflight: cfg.TerminalSessionMaxInflight, + }) + pythonRunner := newPythonExecRunner( + backend, + cfg.E2BPythonTemplate, + cfg.E2BPythonTimeoutSec, + ) + originalRunPythonExec := runPythonExec + runPythonExec = pythonRunner.Execute + originalRunTerminalExec := runTerminalExec + runTerminalExec = terminalManager.Execute + originalRunTerminalResource := runTerminalResource + runTerminalResource = terminalManager.ResolveResource + originalActiveSessionCountFn := activeSessionCountFn + activeSessionCountFn = terminalManager.ActiveSessionCount + defer func() { + runPythonExec = originalRunPythonExec + runTerminalExec = originalRunTerminalExec + runTerminalResource = originalRunTerminalResource + activeSessionCountFn = originalActiveSessionCountFn + terminalManager.Close() + }() + + logging.Infof("pythonExec configured: backend=e2b template=%s", cfg.E2BPythonTemplate) + logging.Infof("terminalExec configured: backend=e2b template=%s", cfg.E2BTerminalTemplate) + logging.Infof( + "terminalExec configured: lease_min_sec=%d lease_max_sec=%d lease_default_sec=%d output_limit_bytes=%d", + terminalManager.leaseMinSec, + terminalManager.leaseMaxSec, + terminalManager.leaseDefaultSec, + terminalManager.outputLimitBytes, + ) + + reconnectDelay := initialReconnectDelay + for { + if err := ctx.Err(); err != nil { + return err + } + + err := runSession(ctx, cfg) + if err == nil { + return nil + } + + if errCtx := ctx.Err(); errCtx != nil { + return errCtx + } + + if status.Code(err) == codes.FailedPrecondition { + logging.Warnf("registry session replaced for node_id=%s, reconnecting", cfg.WorkerID) + reconnectDelay = initialReconnectDelay + } else { + logging.Warnf("registry session interrupted: %v", err) + } + + if err := waitReconnect(ctx, reconnectDelay); err != nil { + return err + } + reconnectDelay = nextReconnectDelay(reconnectDelay) + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/session_client.go b/worker/worker-bridge-e2b/internal/runner/session_client.go new file mode 100644 index 0000000..25f2643 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/session_client.go @@ -0,0 +1,418 @@ +package runner + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "math/big" + "strconv" + "strings" + "time" + + registryv1 "github.com/onlyboxes/onlyboxes/api/gen/go/registry/v1" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/config" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/proto" +) + +func runSession(ctx context.Context, cfg config.Config) error { + conn, err := dial(ctx, cfg) + if err != nil { + return fmt.Errorf("dial console: %w", err) + } + defer conn.Close() + + client := registryv1.NewWorkerRegistryServiceClient(conn) + stream, err := client.Connect(ctx) + if err != nil { + return fmt.Errorf("open connect stream: %w", err) + } + defer stream.CloseSend() + + hello, err := buildHello(cfg) + if err != nil { + return fmt.Errorf("build hello: %w", err) + } + + if err := stream.Send(®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_Hello{Hello: hello}, + }); err != nil { + return fmt.Errorf("send hello: %w", err) + } + + resp, err := recvWithTimeout(ctx, cfg.CallTimeout, stream.Recv) + if err != nil { + return fmt.Errorf("recv connect_ack: %w", err) + } + ack := resp.GetConnectAck() + if ack == nil { + return fmt.Errorf("unexpected first response frame") + } + sessionID := strings.TrimSpace(ack.GetSessionId()) + if sessionID == "" { + return fmt.Errorf("connect_ack.session_id is required") + } + + heartbeatInterval := durationFromServer(ack.GetHeartbeatIntervalSec(), cfg.HeartbeatInterval) + logging.Infof("worker connected: node_id=%s node_name=%s session_id=%s", hello.GetNodeId(), hello.GetNodeName(), sessionID) + + sessionCtx, cancel := context.WithCancel(ctx) + defer cancel() + + outbound := make(chan *registryv1.ConnectRequest, 64) + heartbeatAckCh := make(chan *registryv1.HeartbeatAck, 16) + sessionErrCh := make(chan error, 4) + + go senderLoop(sessionCtx, stream, outbound, sessionErrCh) + go receiverLoop(sessionCtx, stream, outbound, heartbeatAckCh, sessionErrCh) + + return heartbeatLoop(sessionCtx, outbound, heartbeatAckCh, sessionErrCh, cfg, sessionID, heartbeatInterval) +} + +func dial(ctx context.Context, cfg config.Config) (*grpc.ClientConn, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + var creds grpc.DialOption + if cfg.ConsoleTLS { + creds = grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})) + } else { + creds = grpc.WithTransportCredentials(insecure.NewCredentials()) + } + return grpc.NewClient(cfg.ConsoleGRPCTarget, creds) +} + +func senderLoop( + ctx context.Context, + stream grpc.BidiStreamingClient[registryv1.ConnectRequest, registryv1.ConnectResponse], + outbound <-chan *registryv1.ConnectRequest, + errCh chan<- error, +) { + for { + select { + case <-ctx.Done(): + return + case req := <-outbound: + if req == nil { + continue + } + if err := stream.Send(req); err != nil { + reportSessionErr(errCh, fmt.Errorf("stream send failed: %w", err)) + return + } + } + } +} + +func receiverLoop( + ctx context.Context, + stream grpc.BidiStreamingClient[registryv1.ConnectRequest, registryv1.ConnectResponse], + outbound chan<- *registryv1.ConnectRequest, + heartbeatAckCh chan<- *registryv1.HeartbeatAck, + errCh chan<- error, +) { + for { + resp, err := stream.Recv() + if err != nil { + reportSessionErr(errCh, fmt.Errorf("stream receive failed: %w", err)) + return + } + + switch { + case resp.GetHeartbeatAck() != nil: + select { + case <-ctx.Done(): + return + case heartbeatAckCh <- resp.GetHeartbeatAck(): + } + case resp.GetCommandDispatch() != nil: + dispatch := resp.GetCommandDispatch() + capability := strings.TrimSpace(strings.ToLower(dispatch.GetCapability())) + commandID := strings.TrimSpace(dispatch.GetCommandId()) + summary := commandDispatchSummaryForLog(capability, dispatch.GetPayloadJson()) + logging.Infof("command dispatch received: command_id=%s capability=%s summary=%s", commandID, capability, summary) + + dispatchCopy, ok := proto.Clone(dispatch).(*registryv1.CommandDispatch) + if !ok || dispatchCopy == nil { + reportSessionErr(errCh, errors.New("clone command dispatch failed")) + return + } + + go func(dispatch *registryv1.CommandDispatch) { + resultReq := buildCommandResultWithContext(ctx, dispatch) + if sendErr := enqueueRequest(ctx, outbound, resultReq); sendErr != nil { + if errors.Is(sendErr, context.Canceled) || errors.Is(sendErr, context.DeadlineExceeded) { + return + } + reportSessionErr(errCh, fmt.Errorf("enqueue command result: %w", sendErr)) + } + }(dispatchCopy) + default: + reportSessionErr(errCh, errors.New("unexpected response frame")) + return + } + } +} + +func commandDispatchSummaryForLog(capability string, payload []byte) string { + parseFailed := fmt.Sprintf("payload_len=%d summary=parse_failed", len(payload)) + + switch strings.TrimSpace(strings.ToLower(capability)) { + case echoCapabilityName: + decoded := struct { + Message string `json:"message"` + }{} + if err := json.Unmarshal(payload, &decoded); err != nil { + return parseFailed + } + if strings.TrimSpace(decoded.Message) == "" { + return parseFailed + } + return fmt.Sprintf("message_len=%d", len(decoded.Message)) + case pythonExecCapabilityName: + decoded := pythonExecPayload{} + if err := json.Unmarshal(payload, &decoded); err != nil { + return parseFailed + } + if strings.TrimSpace(decoded.Code) == "" { + return parseFailed + } + return fmt.Sprintf("code_len=%d", len(decoded.Code)) + case terminalExecCapabilityName: + decoded := terminalExecPayload{} + if err := json.Unmarshal(payload, &decoded); err != nil { + return parseFailed + } + if strings.TrimSpace(decoded.Command) == "" { + return parseFailed + } + + leaseTTLSec := "default" + if decoded.LeaseTTLSec != nil { + leaseTTLSec = strconv.Itoa(*decoded.LeaseTTLSec) + } + return fmt.Sprintf( + "command_len=%d session_id_present=%t create_if_missing=%t lease_ttl_sec=%s", + len(decoded.Command), + strings.TrimSpace(decoded.SessionID) != "", + decoded.CreateIfMissing, + leaseTTLSec, + ) + case terminalResourceCapabilityName: + decoded := terminalResourcePayload{} + if err := json.Unmarshal(payload, &decoded); err != nil { + return parseFailed + } + sessionPresent := strings.TrimSpace(decoded.SessionID) != "" + path := strings.TrimSpace(decoded.FilePath) + if !sessionPresent || path == "" { + return parseFailed + } + + actionSummary := "default" + switch strings.TrimSpace(strings.ToLower(decoded.Action)) { + case "": + actionSummary = "default" + case terminalResourceActionValidate: + actionSummary = terminalResourceActionValidate + case terminalResourceActionRead: + actionSummary = terminalResourceActionRead + case terminalResourceActionExport: + actionSummary = terminalResourceActionExport + default: + actionSummary = "invalid" + } + signedURLPresent := strings.TrimSpace(decoded.SignedURL) != "" + return fmt.Sprintf( + "action=%s session_id_present=%t file_path_len=%d signed_url_present=%t", + actionSummary, + sessionPresent, + len(path), + signedURLPresent, + ) + default: + return fmt.Sprintf("payload_len=%d summary=unsupported_capability", len(payload)) + } +} + +func heartbeatLoop( + ctx context.Context, + outbound chan<- *registryv1.ConnectRequest, + heartbeatAckCh <-chan *registryv1.HeartbeatAck, + sessionErrCh <-chan error, + cfg config.Config, + sessionID string, + heartbeatInterval time.Duration, +) error { + interval := heartbeatInterval + consecutiveAckTimeouts := 0 + + for { + waitFor := applyJitter(interval, cfg.HeartbeatJitter) + timer := time.NewTimer(waitFor) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case err := <-sessionErrCh: + timer.Stop() + return err + case <-timer.C: + } + + if err := enqueueRequest(ctx, outbound, ®istryv1.ConnectRequest{ + Payload: ®istryv1.ConnectRequest_Heartbeat{ + Heartbeat: ®istryv1.HeartbeatFrame{ + NodeId: cfg.WorkerID, + SessionId: sessionID, + ActiveSessionCount: activeSessionCountFn(), + }, + }, + }); err != nil { + return fmt.Errorf("enqueue heartbeat: %w", err) + } + + ackTimer := time.NewTimer(cfg.CallTimeout) + waitAck := true + for waitAck { + select { + case <-ctx.Done(): + ackTimer.Stop() + return ctx.Err() + case err := <-sessionErrCh: + ackTimer.Stop() + return err + case <-ackTimer.C: + consecutiveAckTimeouts++ + if consecutiveAckTimeouts >= 2 { + return context.DeadlineExceeded + } + waitAck = false + case heartbeatAck := <-heartbeatAckCh: + ackTimer.Stop() + consecutiveAckTimeouts = 0 + interval = durationFromServer(heartbeatAck.GetHeartbeatIntervalSec(), interval) + waitAck = false + } + } + } +} + +func enqueueRequest(ctx context.Context, outbound chan<- *registryv1.ConnectRequest, req *registryv1.ConnectRequest) error { + select { + case <-ctx.Done(): + return ctx.Err() + case outbound <- req: + return nil + } +} + +func reportSessionErr(errCh chan<- error, err error) { + if err == nil { + return + } + select { + case errCh <- err: + default: + } +} + +func recvWithTimeout( + ctx context.Context, + timeout time.Duration, + recv func() (*registryv1.ConnectResponse, error), +) (*registryv1.ConnectResponse, error) { + if timeout <= 0 { + return recv() + } + + type recvResult struct { + resp *registryv1.ConnectResponse + err error + } + + resultCh := make(chan recvResult, 1) + go func() { + resp, err := recv() + resultCh <- recvResult{resp: resp, err: err} + }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + return nil, context.DeadlineExceeded + case result := <-resultCh: + return result.resp, result.err + } +} + +func durationFromServer(seconds int32, fallback time.Duration) time.Duration { + if seconds > 0 { + return time.Duration(seconds) * time.Second + } + if fallback <= 0 { + return 5 * time.Second + } + return fallback +} + +func jitterDuration(base time.Duration, jitterPct int) time.Duration { + if base <= 0 { + base = minHeartbeatInterval + } + if jitterPct <= 0 { + return base + } + + maxDelta := int64(base) * int64(jitterPct) / 100 + if maxDelta <= 0 { + return base + } + + random, err := rand.Int(rand.Reader, big.NewInt(maxDelta*2+1)) + if err != nil { + return base + } + delta := random.Int64() - maxDelta + jittered := base + time.Duration(delta) + if jittered < minHeartbeatInterval { + return minHeartbeatInterval + } + return jittered +} + +func waitReconnectDelay(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + delay = initialReconnectDelay + } + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func nextReconnectDelay(current time.Duration) time.Duration { + if current <= 0 { + return initialReconnectDelay + } + next := current * 2 + if next > maxReconnectDelay { + return maxReconnectDelay + } + return next +} diff --git a/worker/worker-bridge-e2b/internal/runner/session_client_test.go b/worker/worker-bridge-e2b/internal/runner/session_client_test.go new file mode 100644 index 0000000..4949d17 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/session_client_test.go @@ -0,0 +1,106 @@ +package runner + +import ( + "fmt" + "testing" +) + +func TestCommandDispatchSummaryForLog(t *testing.T) { + t.Parallel() + + leaseTTL := 90 + invalidJSONPayload := []byte("not-json") + missingRequiredFieldsPayload := []byte(`{"session_id":"s1","file_path":" "}`) + unsupportedPayload := []byte(`{"x":1}`) + tests := []struct { + name string + capability string + payload []byte + want string + }{ + { + name: "echo_payload_logs_message_length", + capability: echoCapabilityName, + payload: []byte(`{"message":"hello"}`), + want: "message_len=5", + }, + { + name: "python_exec_payload_logs_code_length", + capability: pythonExecCapabilityName, + payload: []byte(`{"code":"abc"}`), + want: "code_len=3", + }, + { + name: "terminal_exec_payload_logs_fields_with_default_lease", + capability: terminalExecCapabilityName, + payload: []byte(`{"command":"pwd","session_id":"sess-1"}`), + want: "command_len=3 session_id_present=true create_if_missing=false lease_ttl_sec=default", + }, + { + name: "terminal_exec_payload_logs_fields_with_explicit_lease", + capability: terminalExecCapabilityName, + payload: []byte(fmt.Sprintf(`{"command":"ls","create_if_missing":true,"lease_ttl_sec":%d}`, leaseTTL)), + want: "command_len=2 session_id_present=false create_if_missing=true lease_ttl_sec=90", + }, + { + name: "terminal_resource_payload_logs_read_action", + capability: terminalResourceCapabilityName, + payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"read"}`), + want: "action=read session_id_present=true file_path_len=6 signed_url_present=false", + }, + { + name: "terminal_resource_payload_logs_validate_action", + capability: terminalResourceCapabilityName, + payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"validate"}`), + want: "action=validate session_id_present=true file_path_len=6 signed_url_present=false", + }, + { + name: "terminal_resource_payload_logs_default_action", + capability: terminalResourceCapabilityName, + payload: []byte(`{"session_id":"s1","file_path":"/tmp/a"}`), + want: "action=default session_id_present=true file_path_len=6 signed_url_present=false", + }, + { + name: "terminal_resource_payload_logs_export_action", + capability: terminalResourceCapabilityName, + payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"export","signed_url":"https://uploads.example.com/put"}`), + want: "action=export session_id_present=true file_path_len=6 signed_url_present=true", + }, + { + name: "terminal_resource_payload_logs_invalid_action", + capability: terminalResourceCapabilityName, + payload: []byte(`{"session_id":"s1","file_path":"/tmp/a","action":"download"}`), + want: "action=invalid session_id_present=true file_path_len=6 signed_url_present=false", + }, + { + name: "invalid_json_falls_back_to_parse_failed", + capability: pythonExecCapabilityName, + payload: invalidJSONPayload, + want: fmt.Sprintf("payload_len=%d summary=parse_failed", len(invalidJSONPayload)), + }, + { + name: "missing_required_fields_falls_back_to_parse_failed", + capability: terminalResourceCapabilityName, + payload: missingRequiredFieldsPayload, + want: fmt.Sprintf("payload_len=%d summary=parse_failed", len(missingRequiredFieldsPayload)), + }, + { + name: "unsupported_capability_logs_fallback_summary", + capability: "unknown", + payload: unsupportedPayload, + want: fmt.Sprintf("payload_len=%d summary=unsupported_capability", len(unsupportedPayload)), + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := commandDispatchSummaryForLog(tc.capability, tc.payload) + if got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go new file mode 100644 index 0000000..e36d6f8 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go @@ -0,0 +1,447 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/logging" +) + +const ( + terminalExecCapabilityName = "terminalexec" + terminalExecCapabilityDeclared = "terminalExec" + terminalExecJanitorInterval = 5 * time.Second + terminalExecCleanupTimeout = 10 * time.Second + terminalExecNoSessionMessage = "session not found" + terminalExecBusyMessage = "session is busy" + terminalExecNotReadyMessage = "terminal executor is unavailable" + defaultTerminalSessionMaxInflight = 1 +) + +const ( + terminalExecCodeSessionNotFound = "session_not_found" + terminalExecCodeSessionBusy = "session_busy" + terminalExecCodeInvalidPayload = "invalid_payload" +) + +type terminalExecPayload struct { + Command string `json:"command"` + SessionID string `json:"session_id,omitempty"` + CreateIfMissing bool `json:"create_if_missing,omitempty"` + LeaseTTLSec *int `json:"lease_ttl_sec,omitempty"` +} + +type terminalExecRequest struct { + Command string + SessionID string + CreateIfMissing bool + LeaseTTLSec *int +} + +type terminalExecRunResult struct { + SessionID string `json:"session_id"` + Created bool `json:"created"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + StdoutTruncated bool `json:"stdout_truncated"` + StderrTruncated bool `json:"stderr_truncated"` + LeaseExpiresUnixMS int64 `json:"lease_expires_unix_ms"` +} + +type terminalExecError struct { + code string + message string +} + +func (e *terminalExecError) Error() string { + if e == nil { + return "terminal execution failed" + } + return e.message +} + +func (e *terminalExecError) Code() string { + if e == nil { + return "" + } + return e.code +} + +func newTerminalExecError(code, message string) *terminalExecError { + return &terminalExecError{code: strings.TrimSpace(code), message: strings.TrimSpace(message)} +} + +type terminalSession struct { + sessionID string + sandbox *e2b.Sandbox + leaseExpiresAt time.Time + inflight int + destroying bool + ready chan struct{} + initErr error +} + +type terminalSessionManagerConfig struct { + Backend e2bBackend + Template string + LeaseMinSec int + LeaseMaxSec int + LeaseDefaultSec int + OutputLimitBytes int + ExportMaxBytes int + SessionMaxInflight int + // JanitorInterval is test-only tuning in practice; zero selects the + // production interval. + JanitorInterval time.Duration +} + +type terminalSessionManager struct { + mu sync.Mutex + sessions map[string]*terminalSession + + backend e2bBackend + template string + leaseMinSec int + leaseMaxSec int + leaseDefaultSec int + outputLimitBytes int + exportMaxBytes int + sessionMaxInflight int + janitorInterval time.Duration + + stopCh chan struct{} + doneCh chan struct{} + closeOnce sync.Once +} + +func newTerminalSessionManager(cfg terminalSessionManagerConfig) *terminalSessionManager { + leaseMin := cfg.LeaseMinSec + if leaseMin <= 0 { + leaseMin = 60 + } + leaseMax := cfg.LeaseMaxSec + if leaseMax < leaseMin { + leaseMax = leaseMin + } + leaseDefault := cfg.LeaseDefaultSec + if leaseDefault < leaseMin { + leaseDefault = leaseMin + } + if leaseDefault > leaseMax { + leaseDefault = leaseMax + } + outputLimit := cfg.OutputLimitBytes + if outputLimit <= 0 { + outputLimit = 1024 * 1024 + } + maxInflight := cfg.SessionMaxInflight + if maxInflight <= 0 { + maxInflight = defaultTerminalSessionMaxInflight + } + janitorInterval := cfg.JanitorInterval + if janitorInterval <= 0 { + janitorInterval = terminalExecJanitorInterval + } + manager := &terminalSessionManager{ + sessions: map[string]*terminalSession{}, + backend: cfg.Backend, + template: strings.TrimSpace(cfg.Template), + leaseMinSec: leaseMin, + leaseMaxSec: leaseMax, + leaseDefaultSec: leaseDefault, + outputLimitBytes: outputLimit, + exportMaxBytes: cfg.ExportMaxBytes, + sessionMaxInflight: maxInflight, + janitorInterval: janitorInterval, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + go manager.janitorLoop() + return manager +} + +func (m *terminalSessionManager) ActiveSessionCount() int32 { + if m == nil { + return 0 + } + m.mu.Lock() + defer m.mu.Unlock() + var count int32 + for _, session := range m.sessions { + if session != nil && !session.destroying { + count++ + } + } + return count +} + +func (m *terminalSessionManager) Execute(ctx context.Context, req terminalExecRequest) (terminalExecRunResult, error) { + if m == nil || m.backend == nil { + return terminalExecRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage) + } + command := strings.TrimSpace(req.Command) + if command == "" { + return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "command is required") + } + leaseDuration, err := m.resolveLeaseDuration(req.LeaseTTLSec) + if err != nil { + return terminalExecRunResult{}, err + } + session, created, err := m.claimSession(strings.TrimSpace(req.SessionID), time.Now().Add(leaseDuration), req.CreateIfMissing) + if err != nil { + return terminalExecRunResult{}, err + } + if err := m.awaitSessionReady(ctx, session, created); err != nil { + return terminalExecRunResult{}, err + } + if err := m.syncSandboxTimeout(ctx, session); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + m.releaseAndDestroySession(session.sessionID) + return terminalExecRunResult{}, err + } + if errors.Is(err, e2b.ErrSandboxNotFound) { + m.releaseAndDestroySession(session.sessionID) + return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + m.releaseSession(session.sessionID) + return terminalExecRunResult{}, fmt.Errorf("extend E2B sandbox timeout: %w", err) + } + result, err := m.backend.Run(ctx, session.sandbox, command, m.outputLimitBytes) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + m.releaseAndDestroySession(session.sessionID) + return terminalExecRunResult{}, err + } + if errors.Is(err, e2b.ErrSandboxNotFound) { + m.releaseAndDestroySession(session.sessionID) + return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + m.releaseSession(session.sessionID) + return terminalExecRunResult{}, err + } + leaseExpires, ok := m.releaseSession(session.sessionID) + if !ok { + return terminalExecRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + return terminalExecRunResult{ + SessionID: session.sessionID, + Created: created, + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + StdoutTruncated: result.StdoutTruncated, + StderrTruncated: result.StderrTruncated, + LeaseExpiresUnixMS: leaseExpires.UnixMilli(), + }, nil +} + +func (m *terminalSessionManager) resolveLeaseDuration(value *int) (time.Duration, error) { + seconds := m.leaseDefaultSec + if value != nil { + seconds = *value + } + if seconds < m.leaseMinSec || seconds > m.leaseMaxSec { + return 0, newTerminalExecError( + terminalExecCodeInvalidPayload, + fmt.Sprintf("lease_ttl_sec must be between %d and %d", m.leaseMinSec, m.leaseMaxSec), + ) + } + return time.Duration(seconds) * time.Second, nil +} + +func (m *terminalSessionManager) claimSession(sessionID string, leaseTarget time.Time, createIfMissing bool) (*terminalSession, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if sessionID == "" { + return m.newSessionLocked(uuid.NewString(), leaseTarget), true, nil + } + if existing, ok := m.sessions[sessionID]; ok && existing != nil && !existing.destroying { + if existing.inflight >= m.sessionMaxInflight { + return nil, false, newTerminalExecError(terminalExecCodeSessionBusy, terminalExecBusyMessage) + } + existing.inflight++ + if existing.leaseExpiresAt.Before(leaseTarget) { + existing.leaseExpiresAt = leaseTarget + } + return existing, false, nil + } + existing, exists := m.sessions[sessionID] + if !createIfMissing || (exists && existing != nil && existing.destroying) { + return nil, false, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + return m.newSessionLocked(sessionID, leaseTarget), true, nil +} + +func (m *terminalSessionManager) newSessionLocked(sessionID string, leaseTarget time.Time) *terminalSession { + session := &terminalSession{ + sessionID: sessionID, + leaseExpiresAt: leaseTarget, + inflight: 1, + ready: make(chan struct{}), + } + m.sessions[sessionID] = session + return session +} + +func (m *terminalSessionManager) awaitSessionReady(ctx context.Context, session *terminalSession, created bool) error { + if created { + timeout := secondsUntil(session.leaseExpiresAt) + sandbox, err := m.backend.Create(ctx, m.template, timeout) + session.sandbox = sandbox + session.initErr = err + close(session.ready) + if err != nil { + m.releaseAndDestroySession(session.sessionID) + return fmt.Errorf("create E2B terminal sandbox: %w", err) + } + return nil + } + select { + case <-session.ready: + case <-ctx.Done(): + m.releaseSession(session.sessionID) + return ctx.Err() + } + if session.initErr != nil { + m.releaseSession(session.sessionID) + return session.initErr + } + return nil +} + +func (m *terminalSessionManager) syncSandboxTimeout(ctx context.Context, session *terminalSession) error { + m.mu.Lock() + expires := session.leaseExpiresAt + sandbox := session.sandbox + m.mu.Unlock() + if sandbox == nil { + return errors.New("E2B sandbox is unavailable") + } + return m.backend.SetTimeout(ctx, sandbox.ID, secondsUntil(expires)) +} + +func secondsUntil(target time.Time) int { + seconds := int(time.Until(target).Seconds()) + 1 + if seconds < 1 { + return 1 + } + return seconds +} + +func (m *terminalSessionManager) releaseSession(sessionID string) (time.Time, bool) { + m.mu.Lock() + session, ok := m.sessions[sessionID] + if !ok || session == nil { + m.mu.Unlock() + return time.Time{}, false + } + if session.inflight > 0 { + session.inflight-- + } + expires := session.leaseExpiresAt + var sandbox *e2b.Sandbox + if session.destroying && session.inflight == 0 { + delete(m.sessions, sessionID) + sandbox = session.sandbox + } + m.mu.Unlock() + if sandbox != nil { + m.killSandbox(sandbox) + } + return expires, true +} + +func (m *terminalSessionManager) releaseAndDestroySession(sessionID string) { + m.mu.Lock() + session, ok := m.sessions[sessionID] + if !ok || session == nil { + m.mu.Unlock() + return + } + session.destroying = true + if session.inflight > 0 { + session.inflight-- + } + var sandbox *e2b.Sandbox + if session.inflight == 0 { + delete(m.sessions, sessionID) + sandbox = session.sandbox + } + m.mu.Unlock() + if sandbox != nil { + m.killSandbox(sandbox) + } +} + +func (m *terminalSessionManager) janitorLoop() { + ticker := time.NewTicker(m.janitorInterval) + defer ticker.Stop() + defer close(m.doneCh) + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.cleanupExpiredSessions() + } + } +} + +func (m *terminalSessionManager) cleanupExpiredSessions() { + now := time.Now() + var expired []*e2b.Sandbox + m.mu.Lock() + for id, session := range m.sessions { + if session == nil || session.destroying || session.inflight > 0 || session.leaseExpiresAt.After(now) { + continue + } + delete(m.sessions, id) + if session.sandbox != nil { + expired = append(expired, session.sandbox) + } + } + m.mu.Unlock() + for _, sandbox := range expired { + m.killSandbox(sandbox) + } +} + +func (m *terminalSessionManager) Close() { + if m == nil { + return + } + m.closeOnce.Do(func() { + close(m.stopCh) + <-m.doneCh + m.mu.Lock() + var sandboxes []*e2b.Sandbox + for _, session := range m.sessions { + if session != nil && session.sandbox != nil { + sandboxes = append(sandboxes, session.sandbox) + } + } + m.sessions = map[string]*terminalSession{} + m.mu.Unlock() + for _, sandbox := range sandboxes { + m.killSandbox(sandbox) + } + }) +} + +func (m *terminalSessionManager) killSandbox(sandbox *e2b.Sandbox) { + if sandbox == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), terminalExecCleanupTimeout) + defer cancel() + if err := m.backend.Kill(ctx, sandbox.ID); err != nil { + logging.Warnf("terminalExec cleanup failed: sandbox_id=%s err=%v", sandbox.ID, err) + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go new file mode 100644 index 0000000..3785e8f --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go @@ -0,0 +1,250 @@ +package runner + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" +) + +const ( + terminalResourceCapabilityName = "terminalresource" + terminalResourceCapabilityDeclared = "terminalResource" + terminalResourceActionValidate = "validate" + terminalResourceActionRead = "read" + terminalResourceActionExport = "export" + terminalResourceCodeFileNotFound = "file_not_found" + terminalResourceCodePathIsDir = "path_is_directory" + terminalResourceCodeFileTooLarge = "file_too_large" +) + +type terminalResourcePayload struct { + SessionID string `json:"session_id"` + FilePath string `json:"file_path"` + Action string `json:"action,omitempty"` + SignedURL string `json:"signed_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +type terminalResourceRequest struct { + SessionID string + FilePath string + Action string + SignedURL string + Headers map[string]string +} + +type terminalResourceRunResult struct { + SessionID string `json:"session_id"` + FilePath string `json:"file_path"` + MIMEType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + Blob []byte `json:"blob,omitempty"` +} + +type terminalResourceProbeResult struct { + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` + MIMEType string `json:"mime_type,omitempty"` + Size int64 `json:"size_bytes"` +} + +func (m *terminalSessionManager) ResolveResource(ctx context.Context, req terminalResourceRequest) (terminalResourceRunResult, error) { + if m == nil || m.backend == nil { + return terminalResourceRunResult{}, newTerminalExecError("execution_failed", terminalExecNotReadyMessage) + } + sessionID := strings.TrimSpace(req.SessionID) + filePath := strings.TrimSpace(req.FilePath) + if sessionID == "" || filePath == "" { + return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "session_id and file_path are required") + } + action := normalizeTerminalResourceAction(req.Action) + if action == "" { + return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "action must be validate, read, or export") + } + if action == terminalResourceActionExport && strings.TrimSpace(req.SignedURL) == "" { + return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "signed_url is required for export") + } + + session, _, err := m.claimSession(sessionID, timeZero, false) + if err != nil { + return terminalResourceRunResult{}, err + } + if err := m.awaitSessionReady(ctx, session, false); err != nil { + return terminalResourceRunResult{}, err + } + result, err := m.resolveResource(ctx, session, filePath, action, req.SignedURL, req.Headers) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + m.releaseAndDestroySession(sessionID) + return terminalResourceRunResult{}, err + } + if errors.Is(err, e2b.ErrSandboxNotFound) { + m.releaseAndDestroySession(sessionID) + return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + m.releaseSession(sessionID) + return terminalResourceRunResult{}, err + } + if _, ok := m.releaseSession(sessionID); !ok { + return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + return result, nil +} + +var timeZero = time.Time{} + +func (m *terminalSessionManager) resolveResource( + ctx context.Context, + session *terminalSession, + filePath, action, signedURL string, + headers map[string]string, +) (terminalResourceRunResult, error) { + probe, err := m.probeResource(ctx, session, filePath) + if err != nil { + return terminalResourceRunResult{}, err + } + result := terminalResourceRunResult{ + SessionID: session.sessionID, + FilePath: filePath, + MIMEType: probe.MIMEType, + SizeBytes: probe.Size, + } + switch action { + case terminalResourceActionValidate: + return result, nil + case terminalResourceActionRead: + file, err := m.backend.ReadFile(ctx, session.sandbox, filePath, int64(m.outputLimitBytes)) + if errors.Is(err, e2b.ErrFileNotFound) { + return terminalResourceRunResult{}, newTerminalExecError(terminalResourceCodeFileNotFound, "file not found") + } + if errors.Is(err, e2b.ErrFileTooLarge) { + return terminalResourceRunResult{}, newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds read limit") + } + if err != nil { + return terminalResourceRunResult{}, err + } + result.MIMEType = file.MIMEType + result.SizeBytes = file.Size + result.Blob = file.Content + return result, nil + case terminalResourceActionExport: + if m.exportMaxBytes > 0 && probe.Size > int64(m.exportMaxBytes) { + return terminalResourceRunResult{}, newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit") + } + if err := m.exportResource(ctx, session.sandbox, filePath, signedURL, headers); err != nil { + return terminalResourceRunResult{}, err + } + return result, nil + default: + return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeInvalidPayload, "invalid action") + } +} + +func (m *terminalSessionManager) probeResource(ctx context.Context, session *terminalSession, filePath string) (terminalResourceProbeResult, error) { + encodedPath := base64.StdEncoding.EncodeToString([]byte(filePath)) + script := `import base64,json,mimetypes,os; p=base64.b64decode("` + encodedPath + `").decode(); ` + + `print(json.dumps(({"error":"file_not_found","message":"file not found"} if not os.path.exists(p) else ` + + `({"error":"path_is_directory","message":"path is directory"} if os.path.isdir(p) else ` + + `{"mime_type":mimetypes.guess_type(p)[0] or "application/octet-stream","size_bytes":os.path.getsize(p)}))))` + command := "python3 -c '" + script + "'" + output, err := m.backend.Run(ctx, session.sandbox, command, 64*1024) + if err != nil { + return terminalResourceProbeResult{}, err + } + if output.ExitCode != 0 { + return terminalResourceProbeResult{}, fmt.Errorf("probe resource failed: exit_code=%d stderr=%s", output.ExitCode, strings.TrimSpace(output.Stderr)) + } + probe := terminalResourceProbeResult{} + if err := json.Unmarshal([]byte(strings.TrimSpace(output.Stdout)), &probe); err != nil { + return terminalResourceProbeResult{}, fmt.Errorf("decode resource metadata: %w", err) + } + if probe.Error != "" { + return terminalResourceProbeResult{}, newTerminalExecError(probe.Error, terminalResourceErrorMessage(probe.Error, probe.Message)) + } + if probe.MIMEType == "" { + probe.MIMEType = "application/octet-stream" + } + return probe, nil +} + +func (m *terminalSessionManager) exportResource( + ctx context.Context, + sandbox *e2b.Sandbox, + filePath, signedURL string, + headers map[string]string, +) error { + source, err := m.backend.OpenFile(ctx, sandbox, filePath) + if errors.Is(err, e2b.ErrFileNotFound) { + return newTerminalExecError(terminalResourceCodeFileNotFound, "file not found") + } + if err != nil { + return err + } + defer source.Body.Close() + if m.exportMaxBytes > 0 && source.Size > int64(m.exportMaxBytes) { + return newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, signedURL, source.Body) + if err != nil { + return fmt.Errorf("build export request: %w", err) + } + if source.Size >= 0 { + req.ContentLength = source.Size + } + for key, value := range headers { + if key = strings.TrimSpace(key); key != "" { + req.Header.Set(key, value) + } + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("upload export file: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + message := strings.TrimSpace(string(body)) + if message == "" { + message = resp.Status + } + return fmt.Errorf("upload export file failed: %s", message) + } + return nil +} + +func normalizeTerminalResourceAction(action string) string { + switch strings.ToLower(strings.TrimSpace(action)) { + case "", terminalResourceActionValidate: + return terminalResourceActionValidate + case terminalResourceActionRead: + return terminalResourceActionRead + case terminalResourceActionExport: + return terminalResourceActionExport + default: + return "" + } +} + +func terminalResourceErrorMessage(code, fallback string) string { + if fallback = strings.TrimSpace(fallback); fallback != "" { + return fallback + } + switch code { + case terminalResourceCodeFileNotFound: + return "file not found" + case terminalResourceCodePathIsDir: + return "path is directory" + case terminalResourceCodeFileTooLarge: + return "file exceeds limit" + default: + return "terminal resource operation failed" + } +} diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go new file mode 100644 index 0000000..ab12d71 --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go @@ -0,0 +1,298 @@ +package runner + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" +) + +type recordingE2BBackend struct { + e2bBackend + mu sync.Mutex + killIDs []string +} + +func (b *recordingE2BBackend) Kill(ctx context.Context, sandboxID string) error { + err := b.e2bBackend.Kill(ctx, sandboxID) + if err == nil { + b.mu.Lock() + b.killIDs = append(b.killIDs, sandboxID) + b.mu.Unlock() + } + return err +} + +func (b *recordingE2BBackend) killCount() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.killIDs) +} + +func TestIntegrationTerminalSessionConcurrentExecAndResources(t *testing.T) { + backend, template := liveTerminalBackend(t) + manager := newLiveTerminalManager(backend, template, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: `printf 'resource-live' > /tmp/onlyboxes-resource-live.txt`, + }) + if err != nil { + t.Fatal(err) + } + + startGate := make(chan struct{}) + results := make(chan outcome, 2) + startedAt := time.Now() + for _, command := range []string{ + `sleep 2; printf 'concurrent-a'`, + `sleep 2; printf 'concurrent-b'`, + } { + command := command + go func() { + <-startGate + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: command, + SessionID: seed.SessionID, + }) + results <- outcome{result: result, err: err} + }() + } + close(startGate) + waitForSessionInflight(t, manager, seed.SessionID, 2, 3*time.Second) + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "printf overflow", + SessionID: seed.SessionID, + }); terminalErrorCode(err) != terminalExecCodeSessionBusy { + t.Fatalf("expected third concurrent terminalExec to return session_busy, got %v", err) + } + outputs := map[string]bool{} + for range 2 { + result := <-results + if result.err != nil { + t.Fatal(result.err) + } + if result.result.SessionID != seed.SessionID { + t.Fatalf("concurrent command switched sessions: %#v", result.result) + } + outputs[result.result.Stdout] = true + } + if !outputs["concurrent-a"] || !outputs["concurrent-b"] { + t.Fatalf("unexpected concurrent outputs: %v", outputs) + } + if elapsed := time.Since(startedAt); elapsed >= 3500*time.Millisecond { + t.Fatalf("commands appear serial, elapsed=%s", elapsed) + } + + validate, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/onlyboxes-resource-live.txt", + Action: terminalResourceActionValidate, + }) + if err != nil { + t.Fatal(err) + } + if validate.SizeBytes != int64(len("resource-live")) { + t.Fatalf("unexpected validate result: %#v", validate) + } + read, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/onlyboxes-resource-live.txt", + Action: terminalResourceActionRead, + }) + if err != nil { + t.Fatal(err) + } + if string(read.Blob) != "resource-live" { + t.Fatalf("unexpected read result: %#v", read) + } + + var uploaded []byte + uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + uploaded, _ = io.ReadAll(req.Body) + if req.Header.Get("X-Onlyboxes-Test") != "export" { + t.Errorf("export header was not forwarded") + } + w.WriteHeader(http.StatusNoContent) + })) + defer uploadServer.Close() + if _, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/onlyboxes-resource-live.txt", + Action: terminalResourceActionExport, + SignedURL: uploadServer.URL, + Headers: map[string]string{"X-Onlyboxes-Test": "export"}, + }); err != nil { + t.Fatal(err) + } + if string(uploaded) != "resource-live" { + t.Fatalf("unexpected exported content %q", uploaded) + } + + execDone := make(chan outcome, 1) + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: `sleep 4; printf 'mixed-exec-ok'`, + SessionID: seed.SessionID, + }) + execDone <- outcome{result: result, err: err} + }() + waitForSessionInflight(t, manager, seed.SessionID, 1, 3*time.Second) + mixedRead, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/onlyboxes-resource-live.txt", + Action: terminalResourceActionRead, + }) + if err != nil || string(mixedRead.Blob) != "resource-live" { + t.Fatalf("mixed terminalResource failed: result=%#v err=%v", mixedRead, err) + } + select { + case early := <-execDone: + t.Fatalf("terminalExec ended before concurrent resource completed: %#v err=%v", early.result, early.err) + default: + } + mixedExec := <-execDone + if mixedExec.err != nil || mixedExec.result.Stdout != "mixed-exec-ok" { + t.Fatalf("mixed terminalExec failed: %#v err=%v", mixedExec.result, mixedExec.err) + } + + manager.Close() + if backend.killCount() != 1 { + t.Fatalf("expected one E2B sandbox cleanup, got %d", backend.killCount()) + } +} + +func TestIntegrationTimedOutCommandDoesNotKillSibling(t *testing.T) { + backend, template := liveTerminalBackend(t) + manager := newLiveTerminalManager(backend, template, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "printf seed"}) + if err != nil { + t.Fatal(err) + } + siblingDone := make(chan outcome, 1) + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: `sleep 2; printf 'sibling-live-ok'`, + SessionID: seed.SessionID, + }) + siblingDone <- outcome{result: result, err: err} + }() + waitForSessionInflight(t, manager, seed.SessionID, 1, 3*time.Second) + timeoutCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, err = manager.Execute(timeoutCtx, terminalExecRequest{ + Command: `sleep 5; printf should-not-complete`, + SessionID: seed.SessionID, + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline exceeded, got %v", err) + } + manager.mu.Lock() + sessionAfterTimeout := manager.sessions[seed.SessionID] + if sessionAfterTimeout == nil || !sessionAfterTimeout.destroying || sessionAfterTimeout.inflight != 1 { + manager.mu.Unlock() + t.Fatalf("unexpected session state after timeout: %#v", sessionAfterTimeout) + } + manager.mu.Unlock() + if backend.killCount() != 0 { + t.Fatal("sandbox was killed before sibling drained") + } + sibling := <-siblingDone + if sibling.err != nil || sibling.result.Stdout != "sibling-live-ok" { + t.Fatalf("sibling was disrupted: %#v err=%v", sibling.result, sibling.err) + } + manager.mu.Lock() + _, sessionStillPresent := manager.sessions[seed.SessionID] + manager.mu.Unlock() + if sessionStillPresent { + t.Fatal("destroying session remained registered after sibling drained") + } + waitForCondition(t, 5*time.Second, func() bool { return backend.killCount() == 1 }, "sandbox was not killed after sibling drained") + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "printf after", + SessionID: seed.SessionID, + }); terminalErrorCode(err) != terminalExecCodeSessionNotFound { + t.Fatalf("timed-out session still accepted commands: %v", err) + } +} + +func TestIntegrationJanitorExpiresTerminalSession(t *testing.T) { + backend, template := liveTerminalBackend(t) + manager := newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: template, + LeaseMinSec: 1, + LeaseMaxSec: 30, + LeaseDefaultSec: 1, + OutputLimitBytes: 1024, + SessionMaxInflight: 1, + JanitorInterval: 100 * time.Millisecond, + }) + defer manager.Close() + session, err := manager.Execute(context.Background(), terminalExecRequest{Command: "printf expiring"}) + if err != nil { + t.Fatal(err) + } + waitForCondition(t, 5*time.Second, func() bool { + return manager.ActiveSessionCount() == 0 && backend.killCount() == 1 + }, "janitor did not expire and clean up the live E2B sandbox") + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "printf after", + SessionID: session.SessionID, + }); terminalErrorCode(err) != terminalExecCodeSessionNotFound { + t.Fatalf("expired session still accepted commands: %v", err) + } +} + +func liveTerminalBackend(t *testing.T) (*recordingE2BBackend, string) { + t.Helper() + if os.Getenv("E2B_INTEGRATION") != "1" { + t.Skip("set E2B_INTEGRATION=1 to run against E2B") + } + apiKey := strings.TrimSpace(os.Getenv("E2B_API_KEY")) + template := strings.TrimSpace(os.Getenv("E2B_TERMINAL_TEMPLATE")) + if apiKey == "" || template == "" { + t.Fatal("E2B_API_KEY and E2B_TERMINAL_TEMPLATE are required") + } + client, err := e2b.NewClient(e2b.Config{ + APIKey: apiKey, + APIURL: strings.TrimSpace(os.Getenv("E2B_API_URL")), + Domain: strings.TrimSpace(os.Getenv("E2B_DOMAIN")), + RequestTimeout: 60 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + return &recordingE2BBackend{e2bBackend: client}, template +} + +func newLiveTerminalManager(backend e2bBackend, template string, maxInflight int) *terminalSessionManager { + return newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: template, + LeaseMinSec: 1, + LeaseMaxSec: 120, + LeaseDefaultSec: 60, + OutputLimitBytes: 1024 * 1024, + ExportMaxBytes: 1024 * 1024, + SessionMaxInflight: maxInflight, + }) +} + +func waitForSessionInflight(t *testing.T, manager *terminalSessionManager, sessionID string, want int, timeout time.Duration) { + t.Helper() + waitForCondition(t, timeout, func() bool { + manager.mu.Lock() + defer manager.mu.Unlock() + session := manager.sessions[sessionID] + return session != nil && session.inflight == want + }, "session did not reach expected inflight count") +} diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go new file mode 100644 index 0000000..19cfd7e --- /dev/null +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go @@ -0,0 +1,682 @@ +package runner + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + "github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/e2b" +) + +type fakeE2BBackend struct { + mu sync.Mutex + created int + killed int + killedIDs []string + timeouts []int + runStarted chan struct{} + runRelease chan struct{} + createFn func(context.Context, string, int) (*e2b.Sandbox, error) + timeoutFn func(context.Context, string, int) error + runFn func(context.Context, *e2b.Sandbox, string, int) (e2b.CommandResult, error) + readFn func(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error) + openFn func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) +} + +func (f *fakeE2BBackend) Create(ctx context.Context, template string, timeout int) (*e2b.Sandbox, error) { + if f.createFn != nil { + return f.createFn(ctx, template, timeout) + } + f.mu.Lock() + defer f.mu.Unlock() + f.created++ + return &e2b.Sandbox{ID: "sandbox-" + strconv.Itoa(f.created), Domain: "test"}, nil +} + +func (f *fakeE2BBackend) SetTimeout(ctx context.Context, sandboxID string, timeout int) error { + if f.timeoutFn != nil { + return f.timeoutFn(ctx, sandboxID, timeout) + } + f.mu.Lock() + defer f.mu.Unlock() + f.timeouts = append(f.timeouts, timeout) + return nil +} + +func (f *fakeE2BBackend) Kill(_ context.Context, sandboxID string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.killed++ + f.killedIDs = append(f.killedIDs, sandboxID) + return nil +} + +func (f *fakeE2BBackend) Run(ctx context.Context, sandbox *e2b.Sandbox, command string, outputLimit int) (e2b.CommandResult, error) { + if f.runFn != nil { + return f.runFn(ctx, sandbox, command, outputLimit) + } + if f.runStarted != nil { + select { + case f.runStarted <- struct{}{}: + default: + } + } + if f.runRelease != nil { + select { + case <-ctx.Done(): + return e2b.CommandResult{}, ctx.Err() + case <-f.runRelease: + } + } + return e2b.CommandResult{Stdout: command, ExitCode: 0}, nil +} + +func (f *fakeE2BBackend) ReadFile(ctx context.Context, sandbox *e2b.Sandbox, filePath string, limit int64) (e2b.File, error) { + if f.readFn != nil { + return f.readFn(ctx, sandbox, filePath, limit) + } + return e2b.File{Content: []byte("ok"), MIMEType: "text/plain", Size: 2}, nil +} + +func (f *fakeE2BBackend) OpenFile(ctx context.Context, sandbox *e2b.Sandbox, filePath string) (e2b.FileReader, error) { + if f.openFn != nil { + return f.openFn(ctx, sandbox, filePath) + } + return e2b.FileReader{Body: io.NopCloser(bytes.NewReader([]byte("ok"))), MIMEType: "text/plain", Size: 2}, nil +} + +func newTestTerminalManager(backend e2bBackend, maxInflight int) *terminalSessionManager { + return newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: "terminal-template", + LeaseMinSec: 1, + LeaseMaxSec: 60, + LeaseDefaultSec: 10, + OutputLimitBytes: 1024, + SessionMaxInflight: maxInflight, + }) +} + +func TestTerminalSessionCreatesReusesAndExtendsE2BLease(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + manager := newTestTerminalManager(backend, 1) + defer manager.Close() + + first, err := manager.Execute(context.Background(), terminalExecRequest{Command: "pwd"}) + if err != nil { + t.Fatal(err) + } + if !first.Created || first.SessionID == "" || first.Stdout != "pwd" { + t.Fatalf("unexpected first result: %#v", first) + } + lease := 20 + second, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "ls", + SessionID: first.SessionID, + LeaseTTLSec: &lease, + }) + if err != nil { + t.Fatal(err) + } + if second.Created || second.SessionID != first.SessionID || second.Stdout != "ls" { + t.Fatalf("unexpected reused result: %#v", second) + } + if second.LeaseExpiresUnixMS <= first.LeaseExpiresUnixMS { + t.Fatalf("lease was not extended: first=%d second=%d", first.LeaseExpiresUnixMS, second.LeaseExpiresUnixMS) + } + backend.mu.Lock() + defer backend.mu.Unlock() + if backend.created != 1 { + t.Fatalf("expected one E2B sandbox, got %d", backend.created) + } + if len(backend.timeouts) != 2 || backend.timeouts[1] < 19 { + t.Fatalf("unexpected E2B timeout updates: %v", backend.timeouts) + } +} + +func TestTerminalSessionRejectsCommandsPastPerSessionLimit(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{ + runStarted: make(chan struct{}, 1), + runRelease: make(chan struct{}), + } + manager := newTestTerminalManager(backend, 1) + defer manager.Close() + + firstDone := make(chan terminalExecRunResult, 1) + firstErr := make(chan error, 1) + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{Command: "first"}) + firstDone <- result + firstErr <- err + }() + <-backend.runStarted + + manager.mu.Lock() + var sessionID string + for id := range manager.sessions { + sessionID = id + } + manager.mu.Unlock() + _, err := manager.Execute(context.Background(), terminalExecRequest{Command: "second", SessionID: sessionID}) + var terminalErr *terminalExecError + if !errors.As(err, &terminalErr) || terminalErr.Code() != terminalExecCodeSessionBusy { + t.Fatalf("expected session_busy, got %v", err) + } + close(backend.runRelease) + if err := <-firstErr; err != nil { + t.Fatal(err) + } + if result := <-firstDone; result.SessionID != sessionID { + t.Fatalf("unexpected first result: %#v", result) + } +} + +func TestTerminalSessionAllowsMultipleConcurrentExecsWithinLimit(t *testing.T) { + t.Parallel() + started := make(chan string, 2) + release := make(chan struct{}) + backend := &fakeE2BBackend{} + backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + if command == "seed" { + return e2b.CommandResult{}, nil + } + started <- command + select { + case <-ctx.Done(): + return e2b.CommandResult{}, ctx.Err() + case <-release: + return e2b.CommandResult{Stdout: command}, nil + } + } + manager := newTestTerminalManager(backend, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + + type outcome struct { + result terminalExecRunResult + err error + } + outcomes := make(chan outcome, 2) + for _, command := range []string{"first", "second"} { + command := command + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: command, + SessionID: seed.SessionID, + }) + outcomes <- outcome{result: result, err: err} + }() + } + seen := map[string]bool{} + for len(seen) < 2 { + select { + case command := <-started: + seen[command] = true + case <-time.After(time.Second): + t.Fatal("two commands were not concurrently in flight") + } + } + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "overflow", + SessionID: seed.SessionID, + }); terminalErrorCode(err) != terminalExecCodeSessionBusy { + t.Fatalf("expected third command to return session_busy, got %v", err) + } + close(release) + for range 2 { + outcome := <-outcomes + if outcome.err != nil { + t.Fatal(outcome.err) + } + if outcome.result.SessionID != seed.SessionID || !seen[outcome.result.Stdout] { + t.Fatalf("unexpected concurrent result: %#v", outcome.result) + } + } +} + +func TestTerminalSessionJanitorAutomaticallyKillsExpiredSandbox(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + manager := newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: "terminal-template", + LeaseMinSec: 1, + LeaseMaxSec: 60, + LeaseDefaultSec: 1, + OutputLimitBytes: 1024, + SessionMaxInflight: 1, + JanitorInterval: 10 * time.Millisecond, + }) + defer manager.Close() + created, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + manager.mu.Lock() + manager.sessions[created.SessionID].leaseExpiresAt = time.Now().Add(-time.Second) + manager.mu.Unlock() + + waitForCondition(t, time.Second, func() bool { + backend.mu.Lock() + defer backend.mu.Unlock() + return backend.killed == 1 + }, "janitor did not kill expired E2B sandbox") + if manager.ActiveSessionCount() != 0 { + t.Fatalf("expired session still counted as active") + } + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "after-expiry", + SessionID: created.SessionID, + }); terminalErrorCode(err) != terminalExecCodeSessionNotFound { + t.Fatalf("expected session_not_found after janitor cleanup, got %v", err) + } +} + +func TestTerminalSessionCreationGateSharesOneSandbox(t *testing.T) { + t.Parallel() + createStarted := make(chan struct{}) + createRelease := make(chan struct{}) + var runMu sync.Mutex + runCount := 0 + backend := &fakeE2BBackend{} + backend.createFn = func(ctx context.Context, _ string, _ int) (*e2b.Sandbox, error) { + close(createStarted) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-createRelease: + return &e2b.Sandbox{ID: "shared-sandbox"}, nil + } + } + backend.runFn = func(context.Context, *e2b.Sandbox, string, int) (e2b.CommandResult, error) { + runMu.Lock() + runCount++ + runMu.Unlock() + return e2b.CommandResult{}, nil + } + manager := newTestTerminalManager(backend, 2) + defer manager.Close() + + errs := make(chan error, 2) + go func() { + _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "creator", + SessionID: "shared-session", + CreateIfMissing: true, + }) + errs <- err + }() + <-createStarted + go func() { + _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "waiter", + SessionID: "shared-session", + CreateIfMissing: true, + }) + errs <- err + }() + time.Sleep(30 * time.Millisecond) + runMu.Lock() + if runCount != 0 { + runMu.Unlock() + t.Fatalf("command ran before sandbox creation completed") + } + runMu.Unlock() + close(createRelease) + for range 2 { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + runMu.Lock() + defer runMu.Unlock() + if runCount != 2 { + t.Fatalf("expected both commands to use the created sandbox, run_count=%d", runCount) + } +} + +func TestTimedOutCommandDoesNotKillConcurrentSibling(t *testing.T) { + t.Parallel() + siblingStarted := make(chan struct{}) + siblingRelease := make(chan struct{}) + backend := &fakeE2BBackend{} + backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + switch command { + case "seed": + return e2b.CommandResult{}, nil + case "sibling": + close(siblingStarted) + select { + case <-ctx.Done(): + return e2b.CommandResult{}, ctx.Err() + case <-siblingRelease: + return e2b.CommandResult{Stdout: "sibling-ok"}, nil + } + case "timeout": + <-ctx.Done() + return e2b.CommandResult{}, ctx.Err() + default: + return e2b.CommandResult{Stdout: command}, nil + } + } + manager := newTestTerminalManager(backend, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + siblingDone := make(chan outcome, 1) + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "sibling", + SessionID: seed.SessionID, + }) + siblingDone <- outcome{result: result, err: err} + }() + <-siblingStarted + timeoutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := manager.Execute(timeoutCtx, terminalExecRequest{ + Command: "timeout", + SessionID: seed.SessionID, + }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline exceeded, got %v", err) + } + backend.mu.Lock() + killedWhileSiblingRunning := backend.killed + backend.mu.Unlock() + if killedWhileSiblingRunning != 0 { + t.Fatalf("sandbox was killed while sibling command was running") + } + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "new-command", + SessionID: seed.SessionID, + }); terminalErrorCode(err) != terminalExecCodeSessionNotFound { + t.Fatalf("destroying session accepted a new command: %v", err) + } + close(siblingRelease) + outcome := <-siblingDone + if outcome.err != nil || outcome.result.Stdout != "sibling-ok" { + t.Fatalf("sibling did not finish successfully: %#v err=%v", outcome.result, outcome.err) + } + waitForCondition(t, time.Second, func() bool { + backend.mu.Lock() + defer backend.mu.Unlock() + return backend.killed == 1 + }, "sandbox was not killed after sibling drained") +} + +func TestTimeoutDuringLeaseSyncAlsoWaitsForSiblingBeforeCleanup(t *testing.T) { + t.Parallel() + siblingStarted := make(chan struct{}) + siblingRelease := make(chan struct{}) + backend := &fakeE2BBackend{} + backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + if command == "seed" { + return e2b.CommandResult{}, nil + } + close(siblingStarted) + select { + case <-ctx.Done(): + return e2b.CommandResult{}, ctx.Err() + case <-siblingRelease: + return e2b.CommandResult{Stdout: "sibling-ok"}, nil + } + } + manager := newTestTerminalManager(backend, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + siblingDone := make(chan outcome, 1) + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "sibling", + SessionID: seed.SessionID, + }) + siblingDone <- outcome{result: result, err: err} + }() + <-siblingStarted + backend.timeoutFn = func(ctx context.Context, _ string, _ int) error { + <-ctx.Done() + return ctx.Err() + } + timeoutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := manager.Execute(timeoutCtx, terminalExecRequest{ + Command: "never-starts", + SessionID: seed.SessionID, + }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline exceeded during lease sync, got %v", err) + } + backend.mu.Lock() + killedBeforeDrain := backend.killed + backend.mu.Unlock() + if killedBeforeDrain != 0 { + t.Fatal("lease sync timeout killed sandbox before sibling drained") + } + close(siblingRelease) + sibling := <-siblingDone + if sibling.err != nil || sibling.result.Stdout != "sibling-ok" { + t.Fatalf("sibling was disrupted: %#v err=%v", sibling.result, sibling.err) + } + waitForCondition(t, time.Second, func() bool { + backend.mu.Lock() + defer backend.mu.Unlock() + return backend.killed == 1 + }, "sandbox was not cleaned after sibling drained") +} + +func TestTerminalResourceValidateReadAndExport(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + backend.runFn = func(_ context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + if command == "seed" { + return e2b.CommandResult{}, nil + } + return e2b.CommandResult{ + Stdout: `{"mime_type":"text/plain","size_bytes":5}`, + ExitCode: 0, + }, nil + } + backend.readFn = func(context.Context, *e2b.Sandbox, string, int64) (e2b.File, error) { + return e2b.File{Content: []byte("hello"), MIMEType: "text/plain", Size: 5}, nil + } + backend.openFn = func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) { + return e2b.FileReader{ + Body: io.NopCloser(bytes.NewReader([]byte("hello"))), + MIMEType: "text/plain", + Size: 5, + }, nil + } + manager := newTestTerminalManager(backend, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + validate, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/hello.txt", + Action: terminalResourceActionValidate, + }) + if err != nil { + t.Fatal(err) + } + if validate.MIMEType != "text/plain" || validate.SizeBytes != 5 || validate.Blob != nil { + t.Fatalf("unexpected validate result: %#v", validate) + } + read, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/hello.txt", + Action: terminalResourceActionRead, + }) + if err != nil { + t.Fatal(err) + } + if string(read.Blob) != "hello" || read.SizeBytes != 5 { + t.Fatalf("unexpected read result: %#v", read) + } + + var uploaded []byte + var uploadHeader string + uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + uploaded, _ = io.ReadAll(req.Body) + uploadHeader = req.Header.Get("X-Test-Export") + w.WriteHeader(http.StatusNoContent) + })) + defer uploadServer.Close() + exported, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/hello.txt", + Action: terminalResourceActionExport, + SignedURL: uploadServer.URL, + Headers: map[string]string{"X-Test-Export": "forwarded"}, + }) + if err != nil { + t.Fatal(err) + } + if string(uploaded) != "hello" || uploadHeader != "forwarded" { + t.Fatalf("unexpected export upload body=%q header=%q", uploaded, uploadHeader) + } + if exported.Blob != nil || exported.SizeBytes != 5 { + t.Fatalf("unexpected export result: %#v", exported) + } +} + +func TestTerminalResourceSharesSessionConcurrencyWithExec(t *testing.T) { + t.Parallel() + execStarted := make(chan struct{}) + execRelease := make(chan struct{}) + backend := &fakeE2BBackend{} + backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + switch command { + case "seed": + return e2b.CommandResult{}, nil + case "block": + close(execStarted) + select { + case <-ctx.Done(): + return e2b.CommandResult{}, ctx.Err() + case <-execRelease: + return e2b.CommandResult{Stdout: "done"}, nil + } + default: + return e2b.CommandResult{Stdout: `{"mime_type":"text/plain","size_bytes":2}`}, nil + } + } + manager := newTestTerminalManager(backend, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + execDone := make(chan error, 1) + go func() { + _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "block", + SessionID: seed.SessionID, + }) + execDone <- err + }() + <-execStarted + resource, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/file.txt", + Action: terminalResourceActionRead, + }) + if err != nil { + t.Fatal(err) + } + if string(resource.Blob) != "ok" { + t.Fatalf("unexpected resource result: %#v", resource) + } + close(execRelease) + if err := <-execDone; err != nil { + t.Fatal(err) + } +} + +func TestTerminalResourceReturnsBusyWhenExecUsesOnlySlot(t *testing.T) { + t.Parallel() + execStarted := make(chan struct{}) + execRelease := make(chan struct{}) + backend := &fakeE2BBackend{} + backend.runFn = func(ctx context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + if command == "seed" { + return e2b.CommandResult{}, nil + } + close(execStarted) + select { + case <-ctx.Done(): + return e2b.CommandResult{}, ctx.Err() + case <-execRelease: + return e2b.CommandResult{}, nil + } + } + manager := newTestTerminalManager(backend, 1) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + execDone := make(chan error, 1) + go func() { + _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "block", + SessionID: seed.SessionID, + }) + execDone <- err + }() + <-execStarted + _, err = manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/file.txt", + Action: terminalResourceActionValidate, + }) + if terminalErrorCode(err) != terminalExecCodeSessionBusy { + t.Fatalf("expected resource to share session_busy limit, got %v", err) + } + close(execRelease) + if err := <-execDone; err != nil { + t.Fatal(err) + } +} + +type outcome struct { + result terminalExecRunResult + err error +} + +func terminalErrorCode(err error) string { + var terminalErr *terminalExecError + if errors.As(err, &terminalErr) { + return terminalErr.Code() + } + return "" +} + +func waitForCondition(t *testing.T, timeout time.Duration, predicate func() bool, message string) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if predicate() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal(message) +} From 079a61b00aec8e80e071a0774ef33026c8cf47ae Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 13:47:27 +0800 Subject: [PATCH 02/12] feat(worker): support direct sandbox exports --- .../worker-bridge-e2b/README/config-file.md | 3 +- worker/worker-bridge-e2b/README/overview.md | 6 +- worker/worker-bridge-e2b/config.example.toml | 2 + .../internal/config/config.go | 12 +++ .../internal/config/source_test.go | 17 ++++ .../internal/runner/runner.go | 4 +- .../internal/runner/terminal_exec.go | 3 + .../internal/runner/terminal_resource.go | 97 +++++++++++++++++++ .../internal/runner/terminal_session_test.go | 87 +++++++++++++++++ 9 files changed, 227 insertions(+), 4 deletions(-) diff --git a/worker/worker-bridge-e2b/README/config-file.md b/worker/worker-bridge-e2b/README/config-file.md index 64b1f4b..407439e 100644 --- a/worker/worker-bridge-e2b/README/config-file.md +++ b/worker/worker-bridge-e2b/README/config-file.md @@ -76,6 +76,7 @@ owner = "team-a" | `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | `terminal_lease_default_sec` | `60` | 正整数;自动限制在最小值与最大值之间 | | `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | `terminal_output_limit_bytes` | `1048576` | 正整数;分别限制 stdout、stderr 和 `terminalResource.read` | | `WORKER_TERMINAL_EXPORT_MAX_BYTES` | `terminal_export_max_bytes` | `0` | 非负整数;`0` 表示不限制导出大小 | +| `WORKER_TERMINAL_EXPORT_MODE` | `terminal_export_mode` | `worker` | `worker`:文件流经 worker;`sandbox`:E2B 沙箱使用 `python3` 直接上传 | | `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | `terminal_session_max_inflight` | `1` | 正整数;同一 session 内 `terminalExec` 与 `terminalResource` 共享的并发上限 | ## 能力并发 @@ -97,6 +98,6 @@ owner = "team-a" | `WORKER_LOG_FORMAT` | `log_format` | `json` | `json`、`text` | | `WORKER_LOG_ADD_SOURCE` | `log_add_source` | `false` | 是否记录源码位置;布尔值接受 `1/0`、`true/false`、`yes/no`、`on/off` | -正整数、百分比、枚举或布尔值无效时使用该项默认值。`WORKER_TERMINAL_EXPORT_MAX_BYTES` 的负数也会回退为 `0`。 +正整数、百分比、枚举或布尔值无效时使用该项默认值。`WORKER_TERMINAL_EXPORT_MAX_BYTES` 的负数也会回退为 `0`;无效的 `WORKER_TERMINAL_EXPORT_MODE` 回退为 `worker`。 配置文件中包含 worker secret 或 E2B API Key 时,应设置为仅运行用户可读,并且不要提交到版本库。可复制根目录的 [`config.example.toml`](../config.example.toml) 作为起点。 diff --git a/worker/worker-bridge-e2b/README/overview.md b/worker/worker-bridge-e2b/README/overview.md index 6da6b92..1f8b83a 100644 --- a/worker/worker-bridge-e2b/README/overview.md +++ b/worker/worker-bridge-e2b/README/overview.md @@ -138,9 +138,11 @@ worker 在 hello 中声明以下四项能力: - `validate` 返回 MIME 类型与大小。 - `read` 返回 `blob`;JSON 编码后表现为 base64。 - `read` 大小上限为 `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES`。 -- `export` 从 E2B 流式下载文件,再通过 HTTP `PUT` 上传到 console 提供的预签名 URL。 +- `export` 通过 HTTP `PUT` 上传到 console 提供的预签名 URL。 +- `WORKER_TERMINAL_EXPORT_MODE=worker` 时,worker 从 E2B 流式下载并转发文件;这是默认模式。 +- `WORKER_TERMINAL_EXPORT_MODE=sandbox` 时,终端模板中的 `python3` 直接从 E2B 沙箱上传,文件内容不经过 worker。 - `WORKER_TERMINAL_EXPORT_MAX_BYTES=0` 表示不限制导出大小。 -- 终端模板必须提供 `python3`,用于安全地探测文件类型、大小和目录状态。 +- 终端模板必须提供 `python3`,用于安全地探测文件类型、大小和目录状态,并在 `sandbox` 导出模式下执行流式上传。 领域错误为 `file_not_found`、`path_is_directory` 和 `file_too_large`。 diff --git a/worker/worker-bridge-e2b/config.example.toml b/worker/worker-bridge-e2b/config.example.toml index e04aa5c..dab8e28 100644 --- a/worker/worker-bridge-e2b/config.example.toml +++ b/worker/worker-bridge-e2b/config.example.toml @@ -33,6 +33,8 @@ terminal_lease_default_sec = 60 terminal_output_limit_bytes = 1048576 # 0 means no export size limit. terminal_export_max_bytes = 0 +# worker streams files through this process; sandbox uploads directly from E2B. +terminal_export_mode = "worker" terminal_session_max_inflight = 1 echo_max_inflight = 4 diff --git a/worker/worker-bridge-e2b/internal/config/config.go b/worker/worker-bridge-e2b/internal/config/config.go index b29a92d..13503ea 100644 --- a/worker/worker-bridge-e2b/internal/config/config.go +++ b/worker/worker-bridge-e2b/internal/config/config.go @@ -20,6 +20,7 @@ const ( defaultTerminalLeaseMax = 1800 defaultTerminalLeaseTTL = 60 defaultTerminalOutputMax = 1024 * 1024 + defaultTerminalExportMode = "worker" defaultTerminalSessionInflight = 1 defaultLogLevel = "info" defaultLogFormat = "json" @@ -51,6 +52,7 @@ type Config struct { TerminalLeaseDefaultSec int TerminalOutputLimitBytes int TerminalExportMaxBytes int + TerminalExportMode string TerminalSessionMaxInflight int EchoMaxInflight int PythonExecMaxInflight int @@ -114,6 +116,7 @@ func Load() Config { TerminalLeaseDefaultSec: terminalLeaseDefaultSec, TerminalOutputLimitBytes: src.positiveInt("WORKER_TERMINAL_OUTPUT_LIMIT_BYTES", defaultTerminalOutputMax), TerminalExportMaxBytes: src.nonNegativeInt("WORKER_TERMINAL_EXPORT_MAX_BYTES", 0), + TerminalExportMode: src.terminalExportMode("WORKER_TERMINAL_EXPORT_MODE", defaultTerminalExportMode), TerminalSessionMaxInflight: src.positiveInt("WORKER_TERMINAL_SESSION_MAX_INFLIGHT", defaultTerminalSessionInflight), EchoMaxInflight: src.positiveInt("WORKER_ECHO_MAX_INFLIGHT", defaultMaxInflight), PythonExecMaxInflight: src.positiveInt("WORKER_PYTHON_EXEC_MAX_INFLIGHT", defaultMaxInflight), @@ -193,6 +196,15 @@ func (s source) logFormat(key, fallback string) string { } } +func (s source) terminalExportMode(key, fallback string) string { + switch value := strings.ToLower(strings.TrimSpace(s.get(key))); value { + case "worker", "sandbox": + return value + default: + return fallback + } +} + func defaultCallTimeoutSec(heartbeatSec int) int { if heartbeatSec <= 0 { heartbeatSec = defaultHeartbeatInterval diff --git a/worker/worker-bridge-e2b/internal/config/source_test.go b/worker/worker-bridge-e2b/internal/config/source_test.go index ef05990..c7ed69e 100644 --- a/worker/worker-bridge-e2b/internal/config/source_test.go +++ b/worker/worker-bridge-e2b/internal/config/source_test.go @@ -29,6 +29,7 @@ e2b_api_key = "test-api-key" e2b_python_template = "python-template" e2b_terminal_template = "terminal-template" e2b_request_timeout_sec = 12 +terminal_export_mode = "sandbox" log_level = "debug" log_add_source = true @@ -63,6 +64,9 @@ description = "gpu,shared" if cfg.E2BRequestTimeout != 12*time.Second { t.Fatalf("unexpected E2B request timeout %s", cfg.E2BRequestTimeout) } + if cfg.TerminalExportMode != "sandbox" { + t.Fatalf("unexpected terminal export mode %q", cfg.TerminalExportMode) + } if cfg.LogLevel != "debug" || !cfg.LogAddSource { t.Fatalf("unexpected log config %q/%t", cfg.LogLevel, cfg.LogAddSource) } @@ -71,6 +75,19 @@ description = "gpu,shared" } } +func TestTerminalExportModeDefaultsToWorker(t *testing.T) { + writeConfigFile(t, `terminal_export_mode = "invalid"`) + + if got := Load().TerminalExportMode; got != defaultTerminalExportMode { + t.Fatalf("expected default terminal export mode %q, got %q", defaultTerminalExportMode, got) + } + + t.Setenv("WORKER_TERMINAL_EXPORT_MODE", "SANDBOX") + if got := Load().TerminalExportMode; got != "sandbox" { + t.Fatalf("expected case-insensitive sandbox mode, got %q", got) + } +} + func TestEnvOverridesConfigFile(t *testing.T) { writeConfigFile(t, ` console_grpc_target = "10.0.0.1:50051" diff --git a/worker/worker-bridge-e2b/internal/runner/runner.go b/worker/worker-bridge-e2b/internal/runner/runner.go index 6d8b477..66435b5 100644 --- a/worker/worker-bridge-e2b/internal/runner/runner.go +++ b/worker/worker-bridge-e2b/internal/runner/runner.go @@ -68,6 +68,7 @@ func Run(ctx context.Context, cfg config.Config) error { LeaseDefaultSec: cfg.TerminalLeaseDefaultSec, OutputLimitBytes: cfg.TerminalOutputLimitBytes, ExportMaxBytes: cfg.TerminalExportMaxBytes, + ExportMode: cfg.TerminalExportMode, SessionMaxInflight: cfg.TerminalSessionMaxInflight, }) pythonRunner := newPythonExecRunner( @@ -94,11 +95,12 @@ func Run(ctx context.Context, cfg config.Config) error { logging.Infof("pythonExec configured: backend=e2b template=%s", cfg.E2BPythonTemplate) logging.Infof("terminalExec configured: backend=e2b template=%s", cfg.E2BTerminalTemplate) logging.Infof( - "terminalExec configured: lease_min_sec=%d lease_max_sec=%d lease_default_sec=%d output_limit_bytes=%d", + "terminalExec configured: lease_min_sec=%d lease_max_sec=%d lease_default_sec=%d output_limit_bytes=%d export_mode=%s", terminalManager.leaseMinSec, terminalManager.leaseMaxSec, terminalManager.leaseDefaultSec, terminalManager.outputLimitBytes, + terminalManager.exportMode, ) reconnectDelay := initialReconnectDelay diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go index e36d6f8..54c19e3 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go @@ -96,6 +96,7 @@ type terminalSessionManagerConfig struct { LeaseDefaultSec int OutputLimitBytes int ExportMaxBytes int + ExportMode string SessionMaxInflight int // JanitorInterval is test-only tuning in practice; zero selects the // production interval. @@ -113,6 +114,7 @@ type terminalSessionManager struct { leaseDefaultSec int outputLimitBytes int exportMaxBytes int + exportMode string sessionMaxInflight int janitorInterval time.Duration @@ -158,6 +160,7 @@ func newTerminalSessionManager(cfg terminalSessionManagerConfig) *terminalSessio leaseDefaultSec: leaseDefault, outputLimitBytes: outputLimit, exportMaxBytes: cfg.ExportMaxBytes, + exportMode: normalizeTerminalExportMode(cfg.ExportMode), sessionMaxInflight: maxInflight, janitorInterval: janitorInterval, stopCh: make(chan struct{}), diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go index 3785e8f..83f2245 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go @@ -20,11 +20,44 @@ const ( terminalResourceActionValidate = "validate" terminalResourceActionRead = "read" terminalResourceActionExport = "export" + terminalExportModeWorker = "worker" + terminalExportModeSandbox = "sandbox" terminalResourceCodeFileNotFound = "file_not_found" terminalResourceCodePathIsDir = "path_is_directory" terminalResourceCodeFileTooLarge = "file_too_large" ) +const sandboxExportScript = `import base64 +import http.client +import json +import os +import sys +import urllib.parse + +config = json.loads(base64.b64decode(sys.argv[1])) +file_path = config["file_path"] +url = urllib.parse.urlsplit(config["signed_url"]) +if url.scheme not in ("http", "https") or not url.hostname: + raise ValueError("signed_url must be an absolute HTTP or HTTPS URL") + +target = url.path or "/" +if url.query: + target += "?" + url.query +headers = dict(config.get("headers") or {}) +headers["Content-Length"] = str(os.path.getsize(file_path)) +connection_type = http.client.HTTPSConnection if url.scheme == "https" else http.client.HTTPConnection +connection = connection_type(url.hostname, url.port) +try: + with open(file_path, "rb") as source: + connection.request("PUT", target, body=source, headers=headers) + response = connection.getresponse() + response.read(1024) + if response.status < 200 or response.status >= 300: + raise RuntimeError("upload returned HTTP status %d" % response.status) +finally: + connection.close() +` + type terminalResourcePayload struct { SessionID string `json:"session_id"` FilePath string `json:"file_path"` @@ -180,6 +213,18 @@ func (m *terminalSessionManager) exportResource( sandbox *e2b.Sandbox, filePath, signedURL string, headers map[string]string, +) error { + if m.exportMode == terminalExportModeSandbox { + return m.exportResourceFromSandbox(ctx, sandbox, filePath, signedURL, headers) + } + return m.exportResourceThroughWorker(ctx, sandbox, filePath, signedURL, headers) +} + +func (m *terminalSessionManager) exportResourceThroughWorker( + ctx context.Context, + sandbox *e2b.Sandbox, + filePath, signedURL string, + headers map[string]string, ) error { source, err := m.backend.OpenFile(ctx, sandbox, filePath) if errors.Is(err, e2b.ErrFileNotFound) { @@ -220,6 +265,58 @@ func (m *terminalSessionManager) exportResource( return nil } +func (m *terminalSessionManager) exportResourceFromSandbox( + ctx context.Context, + sandbox *e2b.Sandbox, + filePath, signedURL string, + headers map[string]string, +) error { + command, err := buildSandboxExportCommand(filePath, signedURL, headers) + if err != nil { + return err + } + output, err := m.backend.Run(ctx, sandbox, command, 64*1024) + if err != nil { + return fmt.Errorf("run sandbox export: %w", err) + } + if output.ExitCode != 0 { + message := strings.TrimSpace(output.Stderr) + if message == "" { + message = strings.TrimSpace(output.Stdout) + } + if message == "" { + message = "upload command failed" + } + return fmt.Errorf("sandbox export failed: exit_code=%d: %s", output.ExitCode, message) + } + return nil +} + +func buildSandboxExportCommand(filePath, signedURL string, headers map[string]string) (string, error) { + payload, err := json.Marshal(struct { + FilePath string `json:"file_path"` + SignedURL string `json:"signed_url"` + Headers map[string]string `json:"headers,omitempty"` + }{ + FilePath: filePath, + SignedURL: signedURL, + Headers: headers, + }) + if err != nil { + return "", fmt.Errorf("encode sandbox export request: %w", err) + } + encodedScript := base64.StdEncoding.EncodeToString([]byte(sandboxExportScript)) + encodedPayload := base64.StdEncoding.EncodeToString(payload) + return "python3 -c 'exec(__import__(\"base64\").b64decode(\"" + encodedScript + "\"))' " + encodedPayload, nil +} + +func normalizeTerminalExportMode(mode string) string { + if strings.EqualFold(strings.TrimSpace(mode), terminalExportModeSandbox) { + return terminalExportModeSandbox + } + return terminalExportModeWorker +} + func normalizeTerminalResourceAction(action string) string { switch strings.ToLower(strings.TrimSpace(action)) { case "", terminalResourceActionValidate: diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go index 19cfd7e..abf1367 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go @@ -7,7 +7,11 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" "strconv" + "strings" "sync" "testing" "time" @@ -557,6 +561,89 @@ func TestTerminalResourceValidateReadAndExport(t *testing.T) { } } +func TestTerminalResourceSandboxExportUsesSandboxCommand(t *testing.T) { + t.Parallel() + var exportCommand string + backend := &fakeE2BBackend{} + backend.runFn = func(_ context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + switch { + case command == "seed": + return e2b.CommandResult{}, nil + case strings.Contains(command, "mimetypes.guess_type"): + return e2b.CommandResult{Stdout: `{"mime_type":"text/plain","size_bytes":5}`}, nil + default: + exportCommand = command + return e2b.CommandResult{}, nil + } + } + backend.openFn = func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) { + t.Fatal("sandbox export must not open the file through the worker") + return e2b.FileReader{}, nil + } + manager := newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: "terminal-template", + LeaseMinSec: 1, + LeaseMaxSec: 60, + LeaseDefaultSec: 10, + OutputLimitBytes: 1024, + ExportMode: terminalExportModeSandbox, + SessionMaxInflight: 1, + }) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + if _, err := manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/hello.txt", + Action: terminalResourceActionExport, + SignedURL: "https://uploads.example.com/object?signature=secret", + Headers: map[string]string{"X-Test-Export": "forwarded"}, + }); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(exportCommand, "python3 -c ") { + t.Fatalf("expected sandbox Python upload command, got %q", exportCommand) + } +} + +func TestSandboxExportCommandStreamsFile(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is required") + } + filePath := filepath.Join(t.TempDir(), "export.txt") + if err := os.WriteFile(filePath, []byte("sandbox-direct"), 0o600); err != nil { + t.Fatal(err) + } + var uploaded []byte + var receivedHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + uploaded, _ = io.ReadAll(req.Body) + receivedHeader = req.Header.Get("X-Test-Export") + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + command, err := buildSandboxExportCommand( + filePath, + server.URL+"/upload?signature=secret", + map[string]string{"X-Test-Export": "forwarded"}, + ) + if err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/bash", "-l", "-c", command).CombinedOutput() + if err != nil { + t.Fatalf("sandbox export command failed: %v: %s", err, output) + } + if string(uploaded) != "sandbox-direct" || receivedHeader != "forwarded" { + t.Fatalf("unexpected upload body=%q header=%q", uploaded, receivedHeader) + } +} + func TestTerminalResourceSharesSessionConcurrencyWithExec(t *testing.T) { t.Parallel() execStarted := make(chan struct{}) From 40fee29b7f60471fa9df4732d0a04a38964bd773 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 14:06:06 +0800 Subject: [PATCH 03/12] tune(worker): adjust E2B cloud defaults --- .../worker-bridge-e2b/README/config-file.md | 16 ++++---- worker/worker-bridge-e2b/README/overview.md | 4 +- worker/worker-bridge-e2b/config.example.toml | 14 +++---- .../internal/config/config.go | 19 ++++++---- .../internal/config/source_test.go | 38 ++++++++++++++++--- .../internal/runner/terminal_exec.go | 31 +++++++++++---- .../internal/runner/terminal_resource.go | 6 +-- .../terminal_session_integration_test.go | 1 + .../internal/runner/terminal_session_test.go | 1 + 9 files changed, 88 insertions(+), 42 deletions(-) diff --git a/worker/worker-bridge-e2b/README/config-file.md b/worker/worker-bridge-e2b/README/config-file.md index 407439e..1958220 100644 --- a/worker/worker-bridge-e2b/README/config-file.md +++ b/worker/worker-bridge-e2b/README/config-file.md @@ -73,11 +73,11 @@ owner = "team-a" | --- | --- | --- | --- | | `WORKER_TERMINAL_LEASE_MIN_SEC` | `terminal_lease_min_sec` | `60` | 正整数;请求可指定的最短 lease | | `WORKER_TERMINAL_LEASE_MAX_SEC` | `terminal_lease_max_sec` | `1800` | 正整数;小于最小值时自动提高到最小值 | -| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | `terminal_lease_default_sec` | `60` | 正整数;自动限制在最小值与最大值之间 | +| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | `terminal_lease_default_sec` | `300` | 正整数;自动限制在最小值与最大值之间 | | `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | `terminal_output_limit_bytes` | `1048576` | 正整数;分别限制 stdout、stderr 和 `terminalResource.read` | | `WORKER_TERMINAL_EXPORT_MAX_BYTES` | `terminal_export_max_bytes` | `0` | 非负整数;`0` 表示不限制导出大小 | -| `WORKER_TERMINAL_EXPORT_MODE` | `terminal_export_mode` | `worker` | `worker`:文件流经 worker;`sandbox`:E2B 沙箱使用 `python3` 直接上传 | -| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | `terminal_session_max_inflight` | `1` | 正整数;同一 session 内 `terminalExec` 与 `terminalResource` 共享的并发上限 | +| `WORKER_TERMINAL_EXPORT_MODE` | `terminal_export_mode` | `sandbox` | `worker`:文件流经 worker;`sandbox`:E2B 沙箱使用 `python3` 直接上传 | +| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | `terminal_session_max_inflight` | `128` | 正整数;同一 session 内 `terminalExec` 与 `terminalResource` 共享的并发上限 | ## 能力并发 @@ -85,10 +85,10 @@ owner = "team-a" | 环境变量 | `config.toml` 键 | 默认值 | 约束 | | --- | --- | --- | --- | -| `WORKER_ECHO_MAX_INFLIGHT` | `echo_max_inflight` | `4` | 正整数 | -| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | `python_exec_max_inflight` | `4` | 正整数 | -| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | `terminal_exec_max_inflight` | `4` | 正整数 | -| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | `terminal_resource_max_inflight` | `4` | 正整数 | +| `WORKER_ECHO_MAX_INFLIGHT` | `echo_max_inflight` | `128` | 正整数 | +| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | `python_exec_max_inflight` | `32` | 正整数 | +| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | `terminal_exec_max_inflight` | `64` | 正整数 | +| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | `terminal_resource_max_inflight` | `128` | 正整数 | ## 日志 @@ -98,6 +98,6 @@ owner = "team-a" | `WORKER_LOG_FORMAT` | `log_format` | `json` | `json`、`text` | | `WORKER_LOG_ADD_SOURCE` | `log_add_source` | `false` | 是否记录源码位置;布尔值接受 `1/0`、`true/false`、`yes/no`、`on/off` | -正整数、百分比、枚举或布尔值无效时使用该项默认值。`WORKER_TERMINAL_EXPORT_MAX_BYTES` 的负数也会回退为 `0`;无效的 `WORKER_TERMINAL_EXPORT_MODE` 回退为 `worker`。 +正整数、百分比、枚举或布尔值无效时使用该项默认值。`WORKER_TERMINAL_EXPORT_MAX_BYTES` 的负数也会回退为 `0`;无效的 `WORKER_TERMINAL_EXPORT_MODE` 回退为 `sandbox`。 配置文件中包含 worker secret 或 E2B API Key 时,应设置为仅运行用户可读,并且不要提交到版本库。可复制根目录的 [`config.example.toml`](../config.example.toml) 作为起点。 diff --git a/worker/worker-bridge-e2b/README/overview.md b/worker/worker-bridge-e2b/README/overview.md index 1f8b83a..975c0b7 100644 --- a/worker/worker-bridge-e2b/README/overview.md +++ b/worker/worker-bridge-e2b/README/overview.md @@ -139,8 +139,8 @@ worker 在 hello 中声明以下四项能力: - `read` 返回 `blob`;JSON 编码后表现为 base64。 - `read` 大小上限为 `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES`。 - `export` 通过 HTTP `PUT` 上传到 console 提供的预签名 URL。 -- `WORKER_TERMINAL_EXPORT_MODE=worker` 时,worker 从 E2B 流式下载并转发文件;这是默认模式。 -- `WORKER_TERMINAL_EXPORT_MODE=sandbox` 时,终端模板中的 `python3` 直接从 E2B 沙箱上传,文件内容不经过 worker。 +- `WORKER_TERMINAL_EXPORT_MODE=sandbox` 时,终端模板中的 `python3` 直接从 E2B 沙箱上传,文件内容不经过 worker;这是默认模式。 +- `WORKER_TERMINAL_EXPORT_MODE=worker` 时,worker 从 E2B 流式下载并转发文件。 - `WORKER_TERMINAL_EXPORT_MAX_BYTES=0` 表示不限制导出大小。 - 终端模板必须提供 `python3`,用于安全地探测文件类型、大小和目录状态,并在 `sandbox` 导出模式下执行流式上传。 diff --git a/worker/worker-bridge-e2b/config.example.toml b/worker/worker-bridge-e2b/config.example.toml index dab8e28..3b12659 100644 --- a/worker/worker-bridge-e2b/config.example.toml +++ b/worker/worker-bridge-e2b/config.example.toml @@ -29,18 +29,18 @@ e2b_python_timeout_sec = 300 terminal_lease_min_sec = 60 terminal_lease_max_sec = 1800 -terminal_lease_default_sec = 60 +terminal_lease_default_sec = 300 terminal_output_limit_bytes = 1048576 # 0 means no export size limit. terminal_export_max_bytes = 0 # worker streams files through this process; sandbox uploads directly from E2B. -terminal_export_mode = "worker" -terminal_session_max_inflight = 1 +terminal_export_mode = "sandbox" +terminal_session_max_inflight = 128 -echo_max_inflight = 4 -python_exec_max_inflight = 4 -terminal_exec_max_inflight = 4 -terminal_resource_max_inflight = 4 +echo_max_inflight = 128 +python_exec_max_inflight = 32 +terminal_exec_max_inflight = 64 +terminal_resource_max_inflight = 128 log_level = "info" log_format = "json" diff --git a/worker/worker-bridge-e2b/internal/config/config.go b/worker/worker-bridge-e2b/internal/config/config.go index 13503ea..357241f 100644 --- a/worker/worker-bridge-e2b/internal/config/config.go +++ b/worker/worker-bridge-e2b/internal/config/config.go @@ -18,13 +18,16 @@ const ( defaultE2BPythonTimeout = 300 defaultTerminalLeaseMin = 60 defaultTerminalLeaseMax = 1800 - defaultTerminalLeaseTTL = 60 + defaultTerminalLeaseTTL = 300 defaultTerminalOutputMax = 1024 * 1024 - defaultTerminalExportMode = "worker" - defaultTerminalSessionInflight = 1 + defaultTerminalExportMode = "sandbox" + defaultTerminalSessionInflight = 128 defaultLogLevel = "info" defaultLogFormat = "json" - defaultMaxInflight = 4 + defaultEchoMaxInflight = 128 + defaultPythonExecMaxInflight = 32 + defaultTerminalExecMaxInflight = 64 + defaultResourceMaxInflight = 128 ) type Config struct { @@ -118,10 +121,10 @@ func Load() Config { TerminalExportMaxBytes: src.nonNegativeInt("WORKER_TERMINAL_EXPORT_MAX_BYTES", 0), TerminalExportMode: src.terminalExportMode("WORKER_TERMINAL_EXPORT_MODE", defaultTerminalExportMode), TerminalSessionMaxInflight: src.positiveInt("WORKER_TERMINAL_SESSION_MAX_INFLIGHT", defaultTerminalSessionInflight), - EchoMaxInflight: src.positiveInt("WORKER_ECHO_MAX_INFLIGHT", defaultMaxInflight), - PythonExecMaxInflight: src.positiveInt("WORKER_PYTHON_EXEC_MAX_INFLIGHT", defaultMaxInflight), - TerminalExecMaxInflight: src.positiveInt("WORKER_TERMINAL_EXEC_MAX_INFLIGHT", defaultMaxInflight), - TerminalResourceMaxInflight: src.positiveInt("WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT", defaultMaxInflight), + EchoMaxInflight: src.positiveInt("WORKER_ECHO_MAX_INFLIGHT", defaultEchoMaxInflight), + PythonExecMaxInflight: src.positiveInt("WORKER_PYTHON_EXEC_MAX_INFLIGHT", defaultPythonExecMaxInflight), + TerminalExecMaxInflight: src.positiveInt("WORKER_TERMINAL_EXEC_MAX_INFLIGHT", defaultTerminalExecMaxInflight), + TerminalResourceMaxInflight: src.positiveInt("WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT", defaultResourceMaxInflight), LogLevel: src.logLevel("WORKER_LOG_LEVEL", defaultLogLevel), LogFormat: src.logFormat("WORKER_LOG_FORMAT", defaultLogFormat), LogAddSource: src.boolValue("WORKER_LOG_ADD_SOURCE", false), diff --git a/worker/worker-bridge-e2b/internal/config/source_test.go b/worker/worker-bridge-e2b/internal/config/source_test.go index c7ed69e..9260fef 100644 --- a/worker/worker-bridge-e2b/internal/config/source_test.go +++ b/worker/worker-bridge-e2b/internal/config/source_test.go @@ -75,16 +75,42 @@ description = "gpu,shared" } } -func TestTerminalExportModeDefaultsToWorker(t *testing.T) { +func TestCloudDefaultsAndTerminalExportMode(t *testing.T) { writeConfigFile(t, `terminal_export_mode = "invalid"`) - if got := Load().TerminalExportMode; got != defaultTerminalExportMode { + cfg := Load() + if got := cfg.TerminalExportMode; got != defaultTerminalExportMode { t.Fatalf("expected default terminal export mode %q, got %q", defaultTerminalExportMode, got) } - - t.Setenv("WORKER_TERMINAL_EXPORT_MODE", "SANDBOX") - if got := Load().TerminalExportMode; got != "sandbox" { - t.Fatalf("expected case-insensitive sandbox mode, got %q", got) + if got := cfg.TerminalSessionMaxInflight; got != defaultTerminalSessionInflight { + t.Fatalf("expected default terminal session max inflight %d, got %d", defaultTerminalSessionInflight, got) + } + if cfg.TerminalLeaseMaxSec != defaultTerminalLeaseMax || + cfg.TerminalLeaseDefaultSec != defaultTerminalLeaseTTL || + cfg.TerminalOutputLimitBytes != defaultTerminalOutputMax { + t.Fatalf( + "unexpected terminal defaults: max_lease=%d default_lease=%d output_limit=%d", + cfg.TerminalLeaseMaxSec, + cfg.TerminalLeaseDefaultSec, + cfg.TerminalOutputLimitBytes, + ) + } + if cfg.EchoMaxInflight != defaultEchoMaxInflight || + cfg.PythonExecMaxInflight != defaultPythonExecMaxInflight || + cfg.TerminalExecMaxInflight != defaultTerminalExecMaxInflight || + cfg.TerminalResourceMaxInflight != defaultResourceMaxInflight { + t.Fatalf( + "unexpected capability defaults: echo=%d python=%d terminal=%d resource=%d", + cfg.EchoMaxInflight, + cfg.PythonExecMaxInflight, + cfg.TerminalExecMaxInflight, + cfg.TerminalResourceMaxInflight, + ) + } + + t.Setenv("WORKER_TERMINAL_EXPORT_MODE", "WORKER") + if got := Load().TerminalExportMode; got != "worker" { + t.Fatalf("expected case-insensitive worker mode, got %q", got) } } diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go index 54c19e3..8953cd9 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go @@ -21,7 +21,11 @@ const ( terminalExecNoSessionMessage = "session not found" terminalExecBusyMessage = "session is busy" terminalExecNotReadyMessage = "terminal executor is unavailable" - defaultTerminalSessionMaxInflight = 1 + defaultTerminalLeaseMinSec = 60 + defaultTerminalLeaseMaxSec = 1800 + defaultTerminalLeaseSec = 300 + defaultTerminalOutputLimitBytes = 1024 * 1024 + defaultTerminalSessionMaxInflight = 128 ) const ( @@ -126,22 +130,23 @@ type terminalSessionManager struct { func newTerminalSessionManager(cfg terminalSessionManagerConfig) *terminalSessionManager { leaseMin := cfg.LeaseMinSec if leaseMin <= 0 { - leaseMin = 60 + leaseMin = defaultTerminalLeaseMinSec } leaseMax := cfg.LeaseMaxSec + if leaseMax <= 0 { + leaseMax = defaultTerminalLeaseMaxSec + } if leaseMax < leaseMin { leaseMax = leaseMin } leaseDefault := cfg.LeaseDefaultSec - if leaseDefault < leaseMin { - leaseDefault = leaseMin - } - if leaseDefault > leaseMax { - leaseDefault = leaseMax + if leaseDefault <= 0 { + leaseDefault = defaultTerminalLeaseSec } + leaseDefault = clampTerminalValue(leaseDefault, leaseMin, leaseMax) outputLimit := cfg.OutputLimitBytes if outputLimit <= 0 { - outputLimit = 1024 * 1024 + outputLimit = defaultTerminalOutputLimitBytes } maxInflight := cfg.SessionMaxInflight if maxInflight <= 0 { @@ -170,6 +175,16 @@ func newTerminalSessionManager(cfg terminalSessionManagerConfig) *terminalSessio return manager } +func clampTerminalValue(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + func (m *terminalSessionManager) ActiveSessionCount() int32 { if m == nil { return 0 diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go index 83f2245..c8bfbe5 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go @@ -311,10 +311,10 @@ func buildSandboxExportCommand(filePath, signedURL string, headers map[string]st } func normalizeTerminalExportMode(mode string) string { - if strings.EqualFold(strings.TrimSpace(mode), terminalExportModeSandbox) { - return terminalExportModeSandbox + if strings.EqualFold(strings.TrimSpace(mode), terminalExportModeWorker) { + return terminalExportModeWorker } - return terminalExportModeWorker + return terminalExportModeSandbox } func normalizeTerminalResourceAction(action string) string { diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go index ab12d71..d4e2edb 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_integration_test.go @@ -283,6 +283,7 @@ func newLiveTerminalManager(backend e2bBackend, template string, maxInflight int LeaseDefaultSec: 60, OutputLimitBytes: 1024 * 1024, ExportMaxBytes: 1024 * 1024, + ExportMode: terminalExportModeWorker, SessionMaxInflight: maxInflight, }) } diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go index abf1367..d2eacbb 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go @@ -104,6 +104,7 @@ func newTestTerminalManager(backend e2bBackend, maxInflight int) *terminalSessio LeaseMaxSec: 60, LeaseDefaultSec: 10, OutputLimitBytes: 1024, + ExportMode: terminalExportModeWorker, SessionMaxInflight: maxInflight, }) } From 527496c0140d23787b8c17b209f7281975430d63 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 14:40:28 +0800 Subject: [PATCH 04/12] fix(worker): serialize E2B lease updates --- .../internal/runner/terminal_exec.go | 61 +++++--- .../internal/runner/terminal_session_test.go | 138 +++++++++++++++++- 2 files changed, 179 insertions(+), 20 deletions(-) diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go index 8953cd9..a3e9fe1 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go @@ -83,13 +83,15 @@ func newTerminalExecError(code, message string) *terminalExecError { } type terminalSession struct { - sessionID string - sandbox *e2b.Sandbox - leaseExpiresAt time.Time - inflight int - destroying bool - ready chan struct{} - initErr error + sessionID string + sandbox *e2b.Sandbox + desiredLeaseExpiresAt time.Time + confirmedLeaseExpiresAt time.Time + leaseSyncMu sync.Mutex + inflight int + destroying bool + ready chan struct{} + initErr error } type terminalSessionManagerConfig struct { @@ -285,8 +287,8 @@ func (m *terminalSessionManager) claimSession(sessionID string, leaseTarget time return nil, false, newTerminalExecError(terminalExecCodeSessionBusy, terminalExecBusyMessage) } existing.inflight++ - if existing.leaseExpiresAt.Before(leaseTarget) { - existing.leaseExpiresAt = leaseTarget + if existing.desiredLeaseExpiresAt.Before(leaseTarget) { + existing.desiredLeaseExpiresAt = leaseTarget } return existing, false, nil } @@ -299,10 +301,10 @@ func (m *terminalSessionManager) claimSession(sessionID string, leaseTarget time func (m *terminalSessionManager) newSessionLocked(sessionID string, leaseTarget time.Time) *terminalSession { session := &terminalSession{ - sessionID: sessionID, - leaseExpiresAt: leaseTarget, - inflight: 1, - ready: make(chan struct{}), + sessionID: sessionID, + desiredLeaseExpiresAt: leaseTarget, + inflight: 1, + ready: make(chan struct{}), } m.sessions[sessionID] = session return session @@ -310,10 +312,18 @@ func (m *terminalSessionManager) newSessionLocked(sessionID string, leaseTarget func (m *terminalSessionManager) awaitSessionReady(ctx context.Context, session *terminalSession, created bool) error { if created { - timeout := secondsUntil(session.leaseExpiresAt) + m.mu.Lock() + leaseExpiresAt := session.desiredLeaseExpiresAt + m.mu.Unlock() + timeout := secondsUntil(leaseExpiresAt) sandbox, err := m.backend.Create(ctx, m.template, timeout) + m.mu.Lock() session.sandbox = sandbox session.initErr = err + if err == nil { + session.confirmedLeaseExpiresAt = leaseExpiresAt + } + m.mu.Unlock() close(session.ready) if err != nil { m.releaseAndDestroySession(session.sessionID) @@ -335,14 +345,29 @@ func (m *terminalSessionManager) awaitSessionReady(ctx context.Context, session } func (m *terminalSessionManager) syncSandboxTimeout(ctx context.Context, session *terminalSession) error { + session.leaseSyncMu.Lock() + defer session.leaseSyncMu.Unlock() + m.mu.Lock() - expires := session.leaseExpiresAt + expires := session.desiredLeaseExpiresAt + confirmedExpires := session.confirmedLeaseExpiresAt sandbox := session.sandbox m.mu.Unlock() if sandbox == nil { return errors.New("E2B sandbox is unavailable") } - return m.backend.SetTimeout(ctx, sandbox.ID, secondsUntil(expires)) + if !confirmedExpires.Before(expires) { + return nil + } + if err := m.backend.SetTimeout(ctx, sandbox.ID, secondsUntil(expires)); err != nil { + return err + } + m.mu.Lock() + if session.confirmedLeaseExpiresAt.Before(expires) { + session.confirmedLeaseExpiresAt = expires + } + m.mu.Unlock() + return nil } func secondsUntil(target time.Time) int { @@ -363,7 +388,7 @@ func (m *terminalSessionManager) releaseSession(sessionID string) (time.Time, bo if session.inflight > 0 { session.inflight-- } - expires := session.leaseExpiresAt + expires := session.confirmedLeaseExpiresAt var sandbox *e2b.Sandbox if session.destroying && session.inflight == 0 { delete(m.sessions, sessionID) @@ -417,7 +442,7 @@ func (m *terminalSessionManager) cleanupExpiredSessions() { var expired []*e2b.Sandbox m.mu.Lock() for id, session := range m.sessions { - if session == nil || session.destroying || session.inflight > 0 || session.leaseExpiresAt.After(now) { + if session == nil || session.destroying || session.inflight > 0 || session.confirmedLeaseExpiresAt.After(now) { continue } delete(m.sessions, id) diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go index d2eacbb..58b6de0 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go @@ -142,11 +142,145 @@ func TestTerminalSessionCreatesReusesAndExtendsE2BLease(t *testing.T) { if backend.created != 1 { t.Fatalf("expected one E2B sandbox, got %d", backend.created) } - if len(backend.timeouts) != 2 || backend.timeouts[1] < 19 { + if len(backend.timeouts) != 1 || backend.timeouts[0] < 19 { t.Fatalf("unexpected E2B timeout updates: %v", backend.timeouts) } } +func TestConcurrentLeaseUpdatesAreAppliedInIncreasingOrder(t *testing.T) { + t.Parallel() + firstSyncStarted := make(chan struct{}) + firstSyncRelease := make(chan struct{}) + var timeoutMu sync.Mutex + var timeouts []int + activeSyncs := 0 + maxActiveSyncs := 0 + backend := &fakeE2BBackend{} + backend.timeoutFn = func(ctx context.Context, _ string, timeout int) error { + timeoutMu.Lock() + timeouts = append(timeouts, timeout) + activeSyncs++ + if activeSyncs > maxActiveSyncs { + maxActiveSyncs = activeSyncs + } + call := len(timeouts) + timeoutMu.Unlock() + if call == 1 { + close(firstSyncStarted) + select { + case <-ctx.Done(): + return ctx.Err() + case <-firstSyncRelease: + } + } + timeoutMu.Lock() + activeSyncs-- + timeoutMu.Unlock() + return nil + } + manager := newTestTerminalManager(backend, 2) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + + type leaseOutcome struct { + result terminalExecRunResult + err error + } + outcomes := make(chan leaseOutcome, 2) + shortLease := 20 + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "short", + SessionID: seed.SessionID, + LeaseTTLSec: &shortLease, + }) + outcomes <- leaseOutcome{result: result, err: err} + }() + <-firstSyncStarted + + longLease := 40 + go func() { + result, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "long", + SessionID: seed.SessionID, + LeaseTTLSec: &longLease, + }) + outcomes <- leaseOutcome{result: result, err: err} + }() + waitForCondition(t, time.Second, func() bool { + manager.mu.Lock() + defer manager.mu.Unlock() + session := manager.sessions[seed.SessionID] + return session != nil && time.Until(session.desiredLeaseExpiresAt) > 35*time.Second + }, "longer lease was not recorded while the first timeout update was in flight") + close(firstSyncRelease) + + for range 2 { + outcome := <-outcomes + if outcome.err != nil { + t.Fatal(outcome.err) + } + } + timeoutMu.Lock() + defer timeoutMu.Unlock() + if len(timeouts) != 2 || timeouts[1] <= timeouts[0] { + t.Fatalf("lease updates were not increasing: %v", timeouts) + } + if maxActiveSyncs != 1 { + t.Fatalf("SetTimeout calls overlapped: max_active=%d", maxActiveSyncs) + } +} + +func TestFailedLeaseUpdateDoesNotAdvanceConfirmedExpiry(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + manager := newTestTerminalManager(backend, 1) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + + backend.timeoutFn = func(context.Context, string, int) error { + return errors.New("timeout update failed") + } + longLease := 20 + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "fails", + SessionID: seed.SessionID, + LeaseTTLSec: &longLease, + }); err == nil { + t.Fatal("expected lease update failure") + } + manager.mu.Lock() + session := manager.sessions[seed.SessionID] + confirmedAfterFailure := session.confirmedLeaseExpiresAt + desiredAfterFailure := session.desiredLeaseExpiresAt + manager.mu.Unlock() + if confirmedAfterFailure.UnixMilli() != seed.LeaseExpiresUnixMS { + t.Fatalf("failed update advanced confirmed lease: before=%d after=%d", seed.LeaseExpiresUnixMS, confirmedAfterFailure.UnixMilli()) + } + if !desiredAfterFailure.After(confirmedAfterFailure) { + t.Fatalf("expected the requested lease to remain pending: desired=%s confirmed=%s", desiredAfterFailure, confirmedAfterFailure) + } + + backend.timeoutFn = nil + retried, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "retry", + SessionID: seed.SessionID, + LeaseTTLSec: &longLease, + }) + if err != nil { + t.Fatal(err) + } + if retried.LeaseExpiresUnixMS <= seed.LeaseExpiresUnixMS { + t.Fatalf("retry did not confirm the pending lease: before=%d after=%d", seed.LeaseExpiresUnixMS, retried.LeaseExpiresUnixMS) + } +} + func TestTerminalSessionRejectsCommandsPastPerSessionLimit(t *testing.T) { t.Parallel() backend := &fakeE2BBackend{ @@ -270,7 +404,7 @@ func TestTerminalSessionJanitorAutomaticallyKillsExpiredSandbox(t *testing.T) { t.Fatal(err) } manager.mu.Lock() - manager.sessions[created.SessionID].leaseExpiresAt = time.Now().Add(-time.Second) + manager.sessions[created.SessionID].confirmedLeaseExpiresAt = time.Now().Add(-time.Second) manager.mu.Unlock() waitForCondition(t, time.Second, func() bool { From 99ec500fa97c2b634e2e1cb9cc82294d046a78cc Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 14:50:21 +0800 Subject: [PATCH 05/12] docs(worker): clarify E2B request timeout --- worker/worker-bridge-e2b/README/config-file.md | 4 +++- worker/worker-bridge-e2b/config.example.toml | 1 + worker/worker-bridge-e2b/internal/e2b/client.go | 2 ++ .../internal/e2b/client_test.go | 17 +++++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/worker/worker-bridge-e2b/README/config-file.md b/worker/worker-bridge-e2b/README/config-file.md index 1958220..1d2913d 100644 --- a/worker/worker-bridge-e2b/README/config-file.md +++ b/worker/worker-bridge-e2b/README/config-file.md @@ -62,11 +62,13 @@ owner = "team-a" | `WORKER_E2B_SANDBOX_URL` | `E2B_SANDBOX_URL` | `e2b_sandbox_url` | 空 | 覆盖 envd 沙箱基础 URL,用于自托管、调试或集成测试 | | `WORKER_E2B_PYTHON_TEMPLATE` | `E2B_PYTHON_EXEC_TEMPLATE` | `e2b_python_template` | 必填 | `pythonExec` 使用的模板 ID 或别名 | | `WORKER_E2B_TERMINAL_TEMPLATE` | `E2B_TERMINAL_EXEC_TEMPLATE` | `e2b_terminal_template` | 必填 | `terminalExec` 使用的模板 ID 或别名 | -| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | — | `e2b_request_timeout_sec` | `60` | 正整数;E2B Control API 和 envd 请求超时秒数 | +| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | — | `e2b_request_timeout_sec` | `60` | 正整数;E2B Control API 请求超时秒数 | | `WORKER_E2B_PYTHON_TIMEOUT_SEC` | `E2B_SANDBOX_TIMEOUT_SEC` | `e2b_python_timeout_sec` | `300` | 正整数;一次性 Python 沙箱的 E2B timeout 秒数 | `WORKER_*` 变量始终优先于同一行的 E2B 别名。API URL 和 sandbox URL 末尾的 `/` 会被移除。 +envd 的命令执行与文件流不使用该请求超时,而是遵循 console 下发的 command deadline,避免长时间运行的命令或文件导出被 Control API 的默认超时提前中断。 + ## 终端 session 与文件 | 环境变量 | `config.toml` 键 | 默认值 | 约束与说明 | diff --git a/worker/worker-bridge-e2b/config.example.toml b/worker/worker-bridge-e2b/config.example.toml index 3b12659..d2428f5 100644 --- a/worker/worker-bridge-e2b/config.example.toml +++ b/worker/worker-bridge-e2b/config.example.toml @@ -22,6 +22,7 @@ e2b_api_url = "https://api.e2b.app" e2b_domain = "e2b.app" e2b_python_template = "" e2b_terminal_template = "" +# Control API request timeout; envd operations use the console command deadline. e2b_request_timeout_sec = 60 e2b_python_timeout_sec = 300 # Optional override for self-hosted E2B or integration tests. diff --git a/worker/worker-bridge-e2b/internal/e2b/client.go b/worker/worker-bridge-e2b/internal/e2b/client.go index 5a09a86..b981065 100644 --- a/worker/worker-bridge-e2b/internal/e2b/client.go +++ b/worker/worker-bridge-e2b/internal/e2b/client.go @@ -108,6 +108,8 @@ func NewClient(cfg Config) (*Client, error) { domain: domain, sandboxURL: strings.TrimRight(strings.TrimSpace(cfg.SandboxURL), "/"), controlHTTP: &http.Client{Transport: transport.Clone(), Timeout: requestTimeout}, + // envd commands and file streams use the console dispatch context as + // their deadline because they may outlive a Control API request. sandboxHTTP: &http.Client{Transport: transport}, }, nil } diff --git a/worker/worker-bridge-e2b/internal/e2b/client_test.go b/worker/worker-bridge-e2b/internal/e2b/client_test.go index 2d30955..04ac86a 100644 --- a/worker/worker-bridge-e2b/internal/e2b/client_test.go +++ b/worker/worker-bridge-e2b/internal/e2b/client_test.go @@ -21,6 +21,23 @@ type processTestHandler struct { t *testing.T } +func TestRequestTimeoutAppliesOnlyToControlAPI(t *testing.T) { + t.Parallel() + client, err := NewClient(Config{ + APIKey: "test-key", + RequestTimeout: 250 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if client.controlHTTP.Timeout != 250*time.Millisecond { + t.Fatalf("unexpected Control API timeout: %s", client.controlHTTP.Timeout) + } + if client.sandboxHTTP.Timeout != 0 { + t.Fatalf("envd must use the command context deadline, got client timeout %s", client.sandboxHTTP.Timeout) + } +} + func (h processTestHandler) Start( _ context.Context, req *connect.Request[processv1.StartRequest], From b10488b009f21153c369dca9f14dc4459bc2ae67 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 15:36:14 +0800 Subject: [PATCH 06/12] fix(worker-e2b): harden session and export lifecycle --- .../worker-bridge-e2b/README/config-file.md | 4 +- worker/worker-bridge-e2b/config.example.toml | 3 +- .../worker-bridge-e2b/internal/e2b/client.go | 14 +- .../internal/e2b/client_test.go | 71 +++++++- .../internal/runner/console_session_test.go | 5 +- .../internal/runner/runner.go | 5 +- .../internal/runner/session_client.go | 21 ++- .../internal/runner/terminal_exec.go | 40 ++++- .../internal/runner/terminal_resource.go | 88 +++++++++- .../internal/runner/terminal_session_test.go | 161 ++++++++++++++++++ 10 files changed, 382 insertions(+), 30 deletions(-) diff --git a/worker/worker-bridge-e2b/README/config-file.md b/worker/worker-bridge-e2b/README/config-file.md index 1d2913d..9b7120c 100644 --- a/worker/worker-bridge-e2b/README/config-file.md +++ b/worker/worker-bridge-e2b/README/config-file.md @@ -62,12 +62,12 @@ owner = "team-a" | `WORKER_E2B_SANDBOX_URL` | `E2B_SANDBOX_URL` | `e2b_sandbox_url` | 空 | 覆盖 envd 沙箱基础 URL,用于自托管、调试或集成测试 | | `WORKER_E2B_PYTHON_TEMPLATE` | `E2B_PYTHON_EXEC_TEMPLATE` | `e2b_python_template` | 必填 | `pythonExec` 使用的模板 ID 或别名 | | `WORKER_E2B_TERMINAL_TEMPLATE` | `E2B_TERMINAL_EXEC_TEMPLATE` | `e2b_terminal_template` | 必填 | `terminalExec` 使用的模板 ID 或别名 | -| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | — | `e2b_request_timeout_sec` | `60` | 正整数;E2B Control API 请求超时秒数 | +| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | — | `e2b_request_timeout_sec` | `60` | 正整数;E2B Control API 请求以及 envd 建连、TLS 握手和响应头等待的超时秒数 | | `WORKER_E2B_PYTHON_TIMEOUT_SEC` | `E2B_SANDBOX_TIMEOUT_SEC` | `e2b_python_timeout_sec` | `300` | 正整数;一次性 Python 沙箱的 E2B timeout 秒数 | `WORKER_*` 变量始终优先于同一行的 E2B 别名。API URL 和 sandbox URL 末尾的 `/` 会被移除。 -envd 的命令执行与文件流不使用该请求超时,而是遵循 console 下发的 command deadline,避免长时间运行的命令或文件导出被 Control API 的默认超时提前中断。 +envd 成功返回响应头后,命令执行与文件流遵循 console 下发的 command deadline,避免长时间运行的命令或文件导出被建连超时提前中断。 ## 终端 session 与文件 diff --git a/worker/worker-bridge-e2b/config.example.toml b/worker/worker-bridge-e2b/config.example.toml index d2428f5..b2a279b 100644 --- a/worker/worker-bridge-e2b/config.example.toml +++ b/worker/worker-bridge-e2b/config.example.toml @@ -22,7 +22,8 @@ e2b_api_url = "https://api.e2b.app" e2b_domain = "e2b.app" e2b_python_template = "" e2b_terminal_template = "" -# Control API request timeout; envd operations use the console command deadline. +# Control API and envd connection/response-header timeout. +# Established envd streams use the console command deadline. e2b_request_timeout_sec = 60 e2b_python_timeout_sec = 300 # Optional override for self-hosted E2B or integration tests. diff --git a/worker/worker-bridge-e2b/internal/e2b/client.go b/worker/worker-bridge-e2b/internal/e2b/client.go index b981065..49bc60b 100644 --- a/worker/worker-bridge-e2b/internal/e2b/client.go +++ b/worker/worker-bridge-e2b/internal/e2b/client.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "mime" + "net" "net/http" "net/url" "path/filepath" @@ -102,15 +103,22 @@ func NewClient(cfg Config) (*Client, error) { } transport := http.DefaultTransport.(*http.Transport).Clone() transport.ForceAttemptHTTP2 = true + sandboxTransport := transport.Clone() + sandboxTransport.DialContext = (&net.Dialer{ + Timeout: requestTimeout, + KeepAlive: 30 * time.Second, + }).DialContext + sandboxTransport.TLSHandshakeTimeout = requestTimeout + sandboxTransport.ResponseHeaderTimeout = requestTimeout return &Client{ apiKey: strings.TrimSpace(cfg.APIKey), apiURL: apiURL, domain: domain, sandboxURL: strings.TrimRight(strings.TrimSpace(cfg.SandboxURL), "/"), controlHTTP: &http.Client{Transport: transport.Clone(), Timeout: requestTimeout}, - // envd commands and file streams use the console dispatch context as - // their deadline because they may outlive a Control API request. - sandboxHTTP: &http.Client{Transport: transport}, + // Envd connection setup and response headers use the configured request + // timeout. Once headers arrive, streams use the console dispatch context. + sandboxHTTP: &http.Client{Transport: sandboxTransport}, }, nil } diff --git a/worker/worker-bridge-e2b/internal/e2b/client_test.go b/worker/worker-bridge-e2b/internal/e2b/client_test.go index 04ac86a..82d1d70 100644 --- a/worker/worker-bridge-e2b/internal/e2b/client_test.go +++ b/worker/worker-bridge-e2b/internal/e2b/client_test.go @@ -21,7 +21,7 @@ type processTestHandler struct { t *testing.T } -func TestRequestTimeoutAppliesOnlyToControlAPI(t *testing.T) { +func TestRequestTimeoutBoundsEnvdSetupWithoutBoundingStreams(t *testing.T) { t.Parallel() client, err := NewClient(Config{ APIKey: "test-key", @@ -36,6 +36,18 @@ func TestRequestTimeoutAppliesOnlyToControlAPI(t *testing.T) { if client.sandboxHTTP.Timeout != 0 { t.Fatalf("envd must use the command context deadline, got client timeout %s", client.sandboxHTTP.Timeout) } + transport, ok := client.sandboxHTTP.Transport.(*http.Transport) + if !ok { + t.Fatalf("unexpected envd transport %T", client.sandboxHTTP.Transport) + } + if transport.TLSHandshakeTimeout != 250*time.Millisecond || + transport.ResponseHeaderTimeout != 250*time.Millisecond { + t.Fatalf( + "unexpected envd setup timeouts: tls=%s response_headers=%s", + transport.TLSHandshakeTimeout, + transport.ResponseHeaderTimeout, + ) + } } func (h processTestHandler) Start( @@ -186,6 +198,63 @@ func TestReadFileUsesSandboxRoutingHeadersAndLimit(t *testing.T) { } } +func TestEnvdResponseHeaderTimeout(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(150 * time.Millisecond) + _, _ = io.WriteString(w, "late") + })) + defer server.Close() + client, err := NewClient(Config{ + APIKey: "test-key", + SandboxURL: server.URL, + RequestTimeout: 30 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + startedAt := time.Now() + _, err = client.ReadFile(ctx, &Sandbox{ID: "slow-headers"}, "/tmp/file", 1024) + if err == nil { + t.Fatal("expected envd response-header timeout") + } + if elapsed := time.Since(startedAt); elapsed > 120*time.Millisecond { + t.Fatalf("envd response-header timeout took too long: %s", elapsed) + } +} + +func TestEnvdStreamCanOutliveResponseHeaderTimeout(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Length", "5") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + time.Sleep(100 * time.Millisecond) + _, _ = io.WriteString(w, "hello") + })) + defer server.Close() + client, err := NewClient(Config{ + APIKey: "test-key", + SandboxURL: server.URL, + RequestTimeout: 30 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + file, err := client.ReadFile(ctx, &Sandbox{ID: "slow-body"}, "/tmp/file", 1024) + if err != nil { + t.Fatal(err) + } + if string(file.Content) != "hello" { + t.Fatalf("unexpected streamed content %q", file.Content) + } +} + func TestCreateSurfacesAPIErrorWithoutLeakingKey(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/worker/worker-bridge-e2b/internal/runner/console_session_test.go b/worker/worker-bridge-e2b/internal/runner/console_session_test.go index bba9f37..0529978 100644 --- a/worker/worker-bridge-e2b/internal/runner/console_session_test.go +++ b/worker/worker-bridge-e2b/internal/runner/console_session_test.go @@ -39,7 +39,10 @@ func TestConsoleSessionContract(t *testing.T) { cfg.ConsoleGRPCTarget = listener.Addr().String() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - err = runSession(ctx, cfg) + connected, err := runSessionWithStatus(ctx, cfg) + if !connected { + t.Fatal("expected the session handshake to complete") + } if status.Code(err) != codes.FailedPrecondition { t.Fatalf("expected FailedPrecondition, got %v", err) } diff --git a/worker/worker-bridge-e2b/internal/runner/runner.go b/worker/worker-bridge-e2b/internal/runner/runner.go index 66435b5..9273b49 100644 --- a/worker/worker-bridge-e2b/internal/runner/runner.go +++ b/worker/worker-bridge-e2b/internal/runner/runner.go @@ -109,10 +109,13 @@ func Run(ctx context.Context, cfg config.Config) error { return err } - err := runSession(ctx, cfg) + connected, err := runSessionWithStatus(ctx, cfg) if err == nil { return nil } + if connected { + reconnectDelay = initialReconnectDelay + } if errCtx := ctx.Err(); errCtx != nil { return errCtx diff --git a/worker/worker-bridge-e2b/internal/runner/session_client.go b/worker/worker-bridge-e2b/internal/runner/session_client.go index 25f2643..716aad2 100644 --- a/worker/worker-bridge-e2b/internal/runner/session_client.go +++ b/worker/worker-bridge-e2b/internal/runner/session_client.go @@ -22,41 +22,46 @@ import ( ) func runSession(ctx context.Context, cfg config.Config) error { + _, err := runSessionWithStatus(ctx, cfg) + return err +} + +func runSessionWithStatus(ctx context.Context, cfg config.Config) (bool, error) { conn, err := dial(ctx, cfg) if err != nil { - return fmt.Errorf("dial console: %w", err) + return false, fmt.Errorf("dial console: %w", err) } defer conn.Close() client := registryv1.NewWorkerRegistryServiceClient(conn) stream, err := client.Connect(ctx) if err != nil { - return fmt.Errorf("open connect stream: %w", err) + return false, fmt.Errorf("open connect stream: %w", err) } defer stream.CloseSend() hello, err := buildHello(cfg) if err != nil { - return fmt.Errorf("build hello: %w", err) + return false, fmt.Errorf("build hello: %w", err) } if err := stream.Send(®istryv1.ConnectRequest{ Payload: ®istryv1.ConnectRequest_Hello{Hello: hello}, }); err != nil { - return fmt.Errorf("send hello: %w", err) + return false, fmt.Errorf("send hello: %w", err) } resp, err := recvWithTimeout(ctx, cfg.CallTimeout, stream.Recv) if err != nil { - return fmt.Errorf("recv connect_ack: %w", err) + return false, fmt.Errorf("recv connect_ack: %w", err) } ack := resp.GetConnectAck() if ack == nil { - return fmt.Errorf("unexpected first response frame") + return false, fmt.Errorf("unexpected first response frame") } sessionID := strings.TrimSpace(ack.GetSessionId()) if sessionID == "" { - return fmt.Errorf("connect_ack.session_id is required") + return false, fmt.Errorf("connect_ack.session_id is required") } heartbeatInterval := durationFromServer(ack.GetHeartbeatIntervalSec(), cfg.HeartbeatInterval) @@ -72,7 +77,7 @@ func runSession(ctx context.Context, cfg config.Config) error { go senderLoop(sessionCtx, stream, outbound, sessionErrCh) go receiverLoop(sessionCtx, stream, outbound, heartbeatAckCh, sessionErrCh) - return heartbeatLoop(sessionCtx, outbound, heartbeatAckCh, sessionErrCh, cfg, sessionID, heartbeatInterval) + return true, heartbeatLoop(sessionCtx, outbound, heartbeatAckCh, sessionErrCh, cfg, sessionID, heartbeatInterval) } func dial(ctx context.Context, cfg config.Config) (*grpc.ClientConn, error) { diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go index a3e9fe1..26b4eb7 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go @@ -87,6 +87,7 @@ type terminalSession struct { sandbox *e2b.Sandbox desiredLeaseExpiresAt time.Time confirmedLeaseExpiresAt time.Time + remoteTimeoutExpiresAt time.Time leaseSyncMu sync.Mutex inflight int destroying bool @@ -112,6 +113,8 @@ type terminalSessionManagerConfig struct { type terminalSessionManager struct { mu sync.Mutex sessions map[string]*terminalSession + closed bool + createWG sync.WaitGroup backend e2bBackend template string @@ -279,6 +282,9 @@ func (m *terminalSessionManager) resolveLeaseDuration(value *int) (time.Duration func (m *terminalSessionManager) claimSession(sessionID string, leaseTarget time.Time, createIfMissing bool) (*terminalSession, bool, error) { m.mu.Lock() defer m.mu.Unlock() + if m.closed { + return nil, false, newTerminalExecError("execution_failed", terminalExecNotReadyMessage) + } if sessionID == "" { return m.newSessionLocked(uuid.NewString(), leaseTarget), true, nil } @@ -300,6 +306,7 @@ func (m *terminalSessionManager) claimSession(sessionID string, leaseTarget time } func (m *terminalSessionManager) newSessionLocked(sessionID string, leaseTarget time.Time) *terminalSession { + m.createWG.Add(1) session := &terminalSession{ sessionID: sessionID, desiredLeaseExpiresAt: leaseTarget, @@ -312,6 +319,7 @@ func (m *terminalSessionManager) newSessionLocked(sessionID string, leaseTarget func (m *terminalSessionManager) awaitSessionReady(ctx context.Context, session *terminalSession, created bool) error { if created { + defer m.createWG.Done() m.mu.Lock() leaseExpiresAt := session.desiredLeaseExpiresAt m.mu.Unlock() @@ -322,6 +330,7 @@ func (m *terminalSessionManager) awaitSessionReady(ctx context.Context, session session.initErr = err if err == nil { session.confirmedLeaseExpiresAt = leaseExpiresAt + session.remoteTimeoutExpiresAt = leaseExpiresAt } m.mu.Unlock() close(session.ready) @@ -349,22 +358,33 @@ func (m *terminalSessionManager) syncSandboxTimeout(ctx context.Context, session defer session.leaseSyncMu.Unlock() m.mu.Lock() - expires := session.desiredLeaseExpiresAt - confirmedExpires := session.confirmedLeaseExpiresAt + leaseExpires := session.desiredLeaseExpiresAt + targetExpires := leaseExpires + if deadline, ok := ctx.Deadline(); ok && deadline.After(targetExpires) { + targetExpires = deadline + } + remoteExpires := session.remoteTimeoutExpiresAt sandbox := session.sandbox + if !remoteExpires.Before(targetExpires) { + if session.confirmedLeaseExpiresAt.Before(leaseExpires) { + session.confirmedLeaseExpiresAt = leaseExpires + } + m.mu.Unlock() + return nil + } m.mu.Unlock() if sandbox == nil { return errors.New("E2B sandbox is unavailable") } - if !confirmedExpires.Before(expires) { - return nil - } - if err := m.backend.SetTimeout(ctx, sandbox.ID, secondsUntil(expires)); err != nil { + if err := m.backend.SetTimeout(ctx, sandbox.ID, secondsUntil(targetExpires)); err != nil { return err } m.mu.Lock() - if session.confirmedLeaseExpiresAt.Before(expires) { - session.confirmedLeaseExpiresAt = expires + if session.remoteTimeoutExpiresAt.Before(targetExpires) { + session.remoteTimeoutExpiresAt = targetExpires + } + if session.confirmedLeaseExpiresAt.Before(leaseExpires) { + session.confirmedLeaseExpiresAt = leaseExpires } m.mu.Unlock() return nil @@ -461,8 +481,12 @@ func (m *terminalSessionManager) Close() { return } m.closeOnce.Do(func() { + m.mu.Lock() + m.closed = true + m.mu.Unlock() close(m.stopCh) <-m.doneCh + m.createWG.Wait() m.mu.Lock() var sandboxes []*e2b.Sandbox for _, session := range m.sessions { diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go index c8bfbe5..7802528 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_resource.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_resource.go @@ -25,6 +25,7 @@ const ( terminalResourceCodeFileNotFound = "file_not_found" terminalResourceCodePathIsDir = "path_is_directory" terminalResourceCodeFileTooLarge = "file_too_large" + terminalExportLimitMarker = "ONLYBOXES_EXPORT_FILE_TOO_LARGE" ) const sandboxExportScript = `import base64 @@ -44,12 +45,35 @@ target = url.path or "/" if url.query: target += "?" + url.query headers = dict(config.get("headers") or {}) -headers["Content-Length"] = str(os.path.getsize(file_path)) +max_bytes = int(config.get("max_bytes") or 0) +size = os.path.getsize(file_path) +if max_bytes > 0 and size > max_bytes: + raise RuntimeError("ONLYBOXES_EXPORT_FILE_TOO_LARGE") +headers["Content-Length"] = str(size) connection_type = http.client.HTTPSConnection if url.scheme == "https" else http.client.HTTPConnection connection = connection_type(url.hostname, url.port) + +class LimitedReader: + def __init__(self, source, limit): + self.source = source + self.remaining = limit + + def read(self, size=-1): + if self.remaining == 0: + if self.source.read(1): + raise RuntimeError("ONLYBOXES_EXPORT_FILE_TOO_LARGE") + return b"" + if self.remaining > 0 and (size < 0 or size > self.remaining): + size = self.remaining + chunk = self.source.read(size) + if self.remaining > 0: + self.remaining -= len(chunk) + return chunk + try: with open(file_path, "rb") as source: - connection.request("PUT", target, body=source, headers=headers) + body = LimitedReader(source, max_bytes) if max_bytes > 0 else source + connection.request("PUT", target, body=body, headers=headers) response = connection.getresponse() response.read(1024) if response.status < 200 or response.status >= 300: @@ -113,6 +137,18 @@ func (m *terminalSessionManager) ResolveResource(ctx context.Context, req termin if err := m.awaitSessionReady(ctx, session, false); err != nil { return terminalResourceRunResult{}, err } + if err := m.syncSandboxTimeout(ctx, session); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + m.releaseAndDestroySession(sessionID) + return terminalResourceRunResult{}, err + } + if errors.Is(err, e2b.ErrSandboxNotFound) { + m.releaseAndDestroySession(sessionID) + return terminalResourceRunResult{}, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + m.releaseSession(sessionID) + return terminalResourceRunResult{}, fmt.Errorf("extend E2B sandbox timeout: %w", err) + } result, err := m.resolveResource(ctx, session, filePath, action, req.SignedURL, req.Headers) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { @@ -237,7 +273,14 @@ func (m *terminalSessionManager) exportResourceThroughWorker( if m.exportMaxBytes > 0 && source.Size > int64(m.exportMaxBytes) { return newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit") } - req, err := http.NewRequestWithContext(ctx, http.MethodPut, signedURL, source.Body) + var uploadBody io.Reader = source.Body + if m.exportMaxBytes > 0 { + uploadBody = &exportLimitReader{ + source: source.Body, + remaining: int64(m.exportMaxBytes), + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, signedURL, uploadBody) if err != nil { return fmt.Errorf("build export request: %w", err) } @@ -251,6 +294,9 @@ func (m *terminalSessionManager) exportResourceThroughWorker( } resp, err := http.DefaultClient.Do(req) if err != nil { + if errors.Is(err, errTerminalExportTooLarge) { + return newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit") + } return fmt.Errorf("upload export file: %w", err) } defer resp.Body.Close() @@ -271,7 +317,7 @@ func (m *terminalSessionManager) exportResourceFromSandbox( filePath, signedURL string, headers map[string]string, ) error { - command, err := buildSandboxExportCommand(filePath, signedURL, headers) + command, err := buildSandboxExportCommand(filePath, signedURL, headers, m.exportMaxBytes) if err != nil { return err } @@ -287,20 +333,25 @@ func (m *terminalSessionManager) exportResourceFromSandbox( if message == "" { message = "upload command failed" } + if strings.Contains(message, terminalExportLimitMarker) { + return newTerminalExecError(terminalResourceCodeFileTooLarge, "file exceeds export limit") + } return fmt.Errorf("sandbox export failed: exit_code=%d: %s", output.ExitCode, message) } return nil } -func buildSandboxExportCommand(filePath, signedURL string, headers map[string]string) (string, error) { +func buildSandboxExportCommand(filePath, signedURL string, headers map[string]string, maxBytes int) (string, error) { payload, err := json.Marshal(struct { FilePath string `json:"file_path"` SignedURL string `json:"signed_url"` Headers map[string]string `json:"headers,omitempty"` + MaxBytes int `json:"max_bytes,omitempty"` }{ FilePath: filePath, SignedURL: signedURL, Headers: headers, + MaxBytes: maxBytes, }) if err != nil { return "", fmt.Errorf("encode sandbox export request: %w", err) @@ -310,6 +361,33 @@ func buildSandboxExportCommand(filePath, signedURL string, headers map[string]st return "python3 -c 'exec(__import__(\"base64\").b64decode(\"" + encodedScript + "\"))' " + encodedPayload, nil } +var errTerminalExportTooLarge = errors.New("terminal export exceeds configured limit") + +type exportLimitReader struct { + source io.Reader + remaining int64 +} + +func (r *exportLimitReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if r.remaining > 0 { + if int64(len(p)) > r.remaining { + p = p[:r.remaining] + } + n, err := r.source.Read(p) + r.remaining -= int64(n) + return n, err + } + var probe [1]byte + n, err := r.source.Read(probe[:]) + if n > 0 { + return 0, errTerminalExportTooLarge + } + return 0, err +} + func normalizeTerminalExportMode(mode string) string { if strings.EqualFold(strings.TrimSpace(mode), terminalExportModeWorker) { return terminalExportModeWorker diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go index 58b6de0..5b60ca8 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go @@ -423,6 +423,77 @@ func TestTerminalSessionJanitorAutomaticallyKillsExpiredSandbox(t *testing.T) { } } +func TestTerminalManagerCloseWaitsForSandboxCreation(t *testing.T) { + t.Parallel() + createStarted := make(chan struct{}) + createRelease := make(chan struct{}) + backend := &fakeE2BBackend{} + backend.createFn = func(context.Context, string, int) (*e2b.Sandbox, error) { + close(createStarted) + <-createRelease + return &e2b.Sandbox{ID: "created-during-close"}, nil + } + manager := newTestTerminalManager(backend, 1) + execDone := make(chan error, 1) + go func() { + _, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + execDone <- err + }() + <-createStarted + + closeDone := make(chan struct{}) + go func() { + manager.Close() + close(closeDone) + }() + select { + case <-closeDone: + t.Fatal("manager closed before sandbox creation completed") + case <-time.After(30 * time.Millisecond): + } + close(createRelease) + select { + case <-closeDone: + case <-time.After(time.Second): + t.Fatal("manager did not close after sandbox creation completed") + } + <-execDone + backend.mu.Lock() + defer backend.mu.Unlock() + if backend.killed != 1 || len(backend.killedIDs) != 1 || backend.killedIDs[0] != "created-during-close" { + t.Fatalf("created sandbox was not cleaned up: killed=%d ids=%v", backend.killed, backend.killedIDs) + } +} + +func TestCommandDeadlineExtendsRemoteTimeoutWithoutExtendingLease(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + manager := newTestTerminalManager(backend, 1) + defer manager.Close() + + lease := 2 + startedAt := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + result, err := manager.Execute(ctx, terminalExecRequest{ + Command: "long-command", + LeaseTTLSec: &lease, + }) + if err != nil { + t.Fatal(err) + } + backend.mu.Lock() + timeouts := append([]int(nil), backend.timeouts...) + backend.mu.Unlock() + if len(timeouts) != 1 || timeouts[0] < 19 { + t.Fatalf("command deadline did not protect the remote sandbox: %v", timeouts) + } + leaseDuration := time.UnixMilli(result.LeaseExpiresUnixMS).Sub(startedAt) + if leaseDuration < time.Second || leaseDuration > 4*time.Second { + t.Fatalf("command deadline changed the user lease: %s", leaseDuration) + } +} + func TestTerminalSessionCreationGateSharesOneSandbox(t *testing.T) { t.Parallel() createStarted := make(chan struct{}) @@ -744,6 +815,66 @@ func TestTerminalResourceSandboxExportUsesSandboxCommand(t *testing.T) { } } +func TestWorkerExportEnforcesLimitOnUnknownLengthStream(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + backend.runFn = func(_ context.Context, _ *e2b.Sandbox, command string, _ int) (e2b.CommandResult, error) { + if command == "seed" { + return e2b.CommandResult{}, nil + } + return e2b.CommandResult{Stdout: `{"mime_type":"text/plain","size_bytes":5}`}, nil + } + backend.openFn = func(context.Context, *e2b.Sandbox, string) (e2b.FileReader, error) { + return e2b.FileReader{ + Body: io.NopCloser(strings.NewReader("123456")), + MIMEType: "text/plain", + Size: -1, + }, nil + } + manager := newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: "terminal-template", + LeaseMinSec: 1, + LeaseMaxSec: 60, + LeaseDefaultSec: 10, + OutputLimitBytes: 1024, + ExportMaxBytes: 5, + ExportMode: terminalExportModeWorker, + SessionMaxInflight: 1, + }) + defer manager.Close() + seed, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + uploadedCh := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + uploaded, _ := io.ReadAll(req.Body) + uploadedCh <- uploaded + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + _, err = manager.ResolveResource(context.Background(), terminalResourceRequest{ + SessionID: seed.SessionID, + FilePath: "/tmp/growing.txt", + Action: terminalResourceActionExport, + SignedURL: server.URL, + }) + if terminalErrorCode(err) != terminalResourceCodeFileTooLarge { + t.Fatalf("expected file_too_large, got %v", err) + } + var uploaded []byte + select { + case uploaded = <-uploadedCh: + case <-time.After(time.Second): + t.Fatal("timed out waiting for export upload") + } + if len(uploaded) > 5 { + t.Fatalf("export uploaded %d bytes past the configured limit", len(uploaded)) + } +} + func TestSandboxExportCommandStreamsFile(t *testing.T) { t.Parallel() if _, err := exec.LookPath("python3"); err != nil { @@ -766,6 +897,7 @@ func TestSandboxExportCommandStreamsFile(t *testing.T) { filePath, server.URL+"/upload?signature=secret", map[string]string{"X-Test-Export": "forwarded"}, + 0, ) if err != nil { t.Fatal(err) @@ -779,6 +911,35 @@ func TestSandboxExportCommandStreamsFile(t *testing.T) { } } +func TestSandboxExportCommandEnforcesLimit(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is required") + } + filePath := filepath.Join(t.TempDir(), "export-too-large.txt") + if err := os.WriteFile(filePath, []byte("123456"), 0o600); err != nil { + t.Fatal(err) + } + var uploaded []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + uploaded, _ = io.ReadAll(req.Body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + command, err := buildSandboxExportCommand(filePath, server.URL, nil, 5) + if err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/bash", "-l", "-c", command).CombinedOutput() + if err == nil || !strings.Contains(string(output), terminalExportLimitMarker) { + t.Fatalf("expected sandbox export limit failure, err=%v output=%s", err, output) + } + if len(uploaded) > 5 { + t.Fatalf("sandbox export uploaded %d bytes past the configured limit", len(uploaded)) + } +} + func TestTerminalResourceSharesSessionConcurrencyWithExec(t *testing.T) { t.Parallel() execStarted := make(chan struct{}) From 0946390701b3f8cf550d16631f522036387bab4f Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 15:36:25 +0800 Subject: [PATCH 07/12] ci: build and release E2B worker --- .github/workflows/ci.yml | 29 ++++++++ .github/workflows/package-release.yml | 100 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f9786..742c511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: worker-docker: ${{ steps.filter.outputs.worker-docker }} worker-sys: ${{ steps.filter.outputs.worker-sys }} worker-boxlite: ${{ steps.filter.outputs.worker-boxlite }} + worker-bridge-e2b: ${{ steps.filter.outputs.worker-bridge-e2b }} web: ${{ steps.filter.outputs.web }} website: ${{ steps.filter.outputs.website }} steps: @@ -56,6 +57,9 @@ jobs: worker-boxlite: - *shared - 'worker/worker-boxlite/**' + worker-bridge-e2b: + - *shared + - 'worker/worker-bridge-e2b/**' web: - '.github/workflows/ci.yml' - 'web/**' @@ -174,6 +178,30 @@ jobs: - name: Unit tests run: cargo test + worker-bridge-e2b: + name: Check worker-bridge-e2b + needs: changes + if: needs.changes.outputs.worker-bridge-e2b == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./worker/worker-bridge-e2b + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: worker/worker-bridge-e2b/go.mod + cache-dependency-path: worker/worker-bridge-e2b/go.sum + + - name: Vet + run: go vet ./... + + - name: Unit tests + run: go test -race ./... + web: name: Check web needs: changes @@ -274,6 +302,7 @@ jobs: - worker-docker - worker-sys - worker-boxlite + - worker-bridge-e2b - web - website runs-on: ubuntu-latest diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index ba8a8fe..4fd4008 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -370,6 +370,105 @@ jobs: ) gh release upload "${VERSION_RAW}" "build/${WORKER_BIN}.zip" --clobber + build-worker-bridge-e2b: + name: worker-bridge-e2b (${{ matrix.os_name }}/${{ matrix.goarch }}) + runs-on: ubuntu-24.04 + needs: + - prepare-release + - create-draft-release + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + os_name: linux + goos: linux + goarch: amd64 + - arch: arm64 + os_name: linux + goos: linux + goarch: arm64 + - arch: x64 + os_name: macos + goos: darwin + goarch: amd64 + - arch: arm64 + os_name: macos + goos: darwin + goarch: arm64 + env: + VERSION_RAW: ${{ needs.prepare-release.outputs.version_raw }} + VERSION_SAFE: ${{ needs.prepare-release.outputs.version_safe }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + WORKER_BIN: onlyboxes-worker-bridge-e2b_${{ needs.prepare-release.outputs.version_safe }}_${{ matrix.os_name }}_${{ matrix.goarch }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: worker/worker-bridge-e2b/go.mod + cache-dependency-path: worker/worker-bridge-e2b/go.sum + + - name: Build worker binary + run: | + set -euo pipefail + mkdir -p build + ( + cd worker/worker-bridge-e2b + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -trimpath \ + -ldflags "-X github.com/onlyboxes/onlyboxes/worker/worker-bridge-e2b/internal/buildinfo.Version=${VERSION_RAW}" \ + -o "../../build/${WORKER_BIN}" \ + ./cmd/worker-bridge-e2b + ) + + - name: Validate worker binary architecture + run: | + set -euo pipefail + worker_info="$(file "build/${WORKER_BIN}")" + echo "${worker_info}" + case "${GOOS}/${GOARCH}" in + linux/amd64) + grep -Eq "ELF 64-bit.*x86-64" <<< "${worker_info}" + ;; + linux/arm64) + grep -Eq "ELF 64-bit.*ARM aarch64" <<< "${worker_info}" + ;; + darwin/amd64) + grep -Eq "Mach-O 64-bit.*x86_64" <<< "${worker_info}" + ;; + darwin/arm64) + grep -Eq "Mach-O 64-bit.*arm64" <<< "${worker_info}" + ;; + *) + echo "unsupported target: ${GOOS}/${GOARCH}" >&2 + exit 1 + ;; + esac + + - name: Upload worker workflow artifact + uses: actions/upload-artifact@v7 + with: + name: onlyboxes-worker-bridge-e2b-${{ matrix.os_name }}-${{ matrix.arch }}-${{ env.VERSION_SAFE }}.zip + path: build/${{ env.WORKER_BIN }} + if-no-files-found: error + + - name: Upload worker release asset + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + ( + cd build + zip -q -j "${WORKER_BIN}.zip" "${WORKER_BIN}" + ) + gh release upload "${VERSION_RAW}" "build/${WORKER_BIN}.zip" --clobber + build-worker-sys: name: worker-sys (${{ matrix.os_name }}/${{ matrix.goarch }}) runs-on: ubuntu-24.04 @@ -802,6 +901,7 @@ jobs: - prepare-release - build-console - build-worker-docker + - build-worker-bridge-e2b - build-worker-sys - build-worker-boxlite - push-console-image From d2ef5e99830e44fd641b4e221b3b08a9f5e26173 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 15:57:12 +0800 Subject: [PATCH 08/12] fix(worker-e2b): reject expired sessions before cleanup --- .../internal/runner/terminal_exec.go | 26 +++++- .../internal/runner/terminal_session_test.go | 86 +++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go index 26b4eb7..3186541 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_exec.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_exec.go @@ -281,28 +281,48 @@ func (m *terminalSessionManager) resolveLeaseDuration(value *int) (time.Duration func (m *terminalSessionManager) claimSession(sessionID string, leaseTarget time.Time, createIfMissing bool) (*terminalSession, bool, error) { m.mu.Lock() - defer m.mu.Unlock() if m.closed { + m.mu.Unlock() return nil, false, newTerminalExecError("execution_failed", terminalExecNotReadyMessage) } if sessionID == "" { - return m.newSessionLocked(uuid.NewString(), leaseTarget), true, nil + session := m.newSessionLocked(uuid.NewString(), leaseTarget) + m.mu.Unlock() + return session, true, nil } if existing, ok := m.sessions[sessionID]; ok && existing != nil && !existing.destroying { + if existing.inflight == 0 && !existing.confirmedLeaseExpiresAt.After(time.Now()) { + delete(m.sessions, sessionID) + expiredSandbox := existing.sandbox + if !createIfMissing { + m.mu.Unlock() + m.killSandbox(expiredSandbox) + return nil, false, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) + } + session := m.newSessionLocked(sessionID, leaseTarget) + m.mu.Unlock() + m.killSandbox(expiredSandbox) + return session, true, nil + } if existing.inflight >= m.sessionMaxInflight { + m.mu.Unlock() return nil, false, newTerminalExecError(terminalExecCodeSessionBusy, terminalExecBusyMessage) } existing.inflight++ if existing.desiredLeaseExpiresAt.Before(leaseTarget) { existing.desiredLeaseExpiresAt = leaseTarget } + m.mu.Unlock() return existing, false, nil } existing, exists := m.sessions[sessionID] if !createIfMissing || (exists && existing != nil && existing.destroying) { + m.mu.Unlock() return nil, false, newTerminalExecError(terminalExecCodeSessionNotFound, terminalExecNoSessionMessage) } - return m.newSessionLocked(sessionID, leaseTarget), true, nil + session := m.newSessionLocked(sessionID, leaseTarget) + m.mu.Unlock() + return session, true, nil } func (m *terminalSessionManager) newSessionLocked(sessionID string, leaseTarget time.Time) *terminalSession { diff --git a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go index 5b60ca8..bfa4e39 100644 --- a/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go +++ b/worker/worker-bridge-e2b/internal/runner/terminal_session_test.go @@ -423,6 +423,92 @@ func TestTerminalSessionJanitorAutomaticallyKillsExpiredSandbox(t *testing.T) { } } +func TestTerminalSessionRejectsExpiredIdleSessionBeforeJanitorCleanup(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + manager := newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: "terminal-template", + LeaseMinSec: 1, + LeaseMaxSec: 60, + LeaseDefaultSec: 10, + OutputLimitBytes: 1024, + SessionMaxInflight: 1, + JanitorInterval: time.Hour, + }) + defer manager.Close() + + created, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + manager.mu.Lock() + session := manager.sessions[created.SessionID] + session.desiredLeaseExpiresAt = time.Now().Add(-time.Second) + session.confirmedLeaseExpiresAt = session.desiredLeaseExpiresAt + manager.mu.Unlock() + + if _, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "after-expiry", + SessionID: created.SessionID, + }); terminalErrorCode(err) != terminalExecCodeSessionNotFound { + t.Fatalf("expected session_not_found before janitor cleanup, got %v", err) + } + backend.mu.Lock() + defer backend.mu.Unlock() + if backend.killed != 1 || len(backend.killedIDs) != 1 || backend.killedIDs[0] != "sandbox-1" { + t.Fatalf("expired sandbox was not cleaned up: killed=%d ids=%v", backend.killed, backend.killedIDs) + } +} + +func TestTerminalSessionReplacesExpiredIdleSessionWhenRequested(t *testing.T) { + t.Parallel() + backend := &fakeE2BBackend{} + manager := newTerminalSessionManager(terminalSessionManagerConfig{ + Backend: backend, + Template: "terminal-template", + LeaseMinSec: 1, + LeaseMaxSec: 60, + LeaseDefaultSec: 10, + OutputLimitBytes: 1024, + SessionMaxInflight: 1, + JanitorInterval: time.Hour, + }) + defer manager.Close() + + created, err := manager.Execute(context.Background(), terminalExecRequest{Command: "seed"}) + if err != nil { + t.Fatal(err) + } + manager.mu.Lock() + session := manager.sessions[created.SessionID] + session.desiredLeaseExpiresAt = time.Now().Add(-time.Second) + session.confirmedLeaseExpiresAt = session.desiredLeaseExpiresAt + manager.mu.Unlock() + + replacement, err := manager.Execute(context.Background(), terminalExecRequest{ + Command: "after-expiry", + SessionID: created.SessionID, + CreateIfMissing: true, + }) + if err != nil { + t.Fatal(err) + } + if !replacement.Created || replacement.SessionID != created.SessionID { + t.Fatalf("unexpected replacement result: %#v", replacement) + } + backend.mu.Lock() + defer backend.mu.Unlock() + if backend.created != 2 || backend.killed != 1 || len(backend.killedIDs) != 1 || backend.killedIDs[0] != "sandbox-1" { + t.Fatalf( + "expired sandbox was not replaced: created=%d killed=%d ids=%v", + backend.created, + backend.killed, + backend.killedIDs, + ) + } +} + func TestTerminalManagerCloseWaitsForSandboxCreation(t *testing.T) { t.Parallel() createStarted := make(chan struct{}) From d2d2cb2c706cc1ba3be102b6f72109db72f815ce Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 20:49:34 +0800 Subject: [PATCH 09/12] docs(website): add E2B bridge worker guide --- website/src/docs/en/architecture.mdx | 1 + website/src/docs/en/overview.mdx | 3 +- website/src/docs/en/worker-bridge-e2b.mdx | 134 ++++++++++++++++++ website/src/docs/zh-CN/architecture.mdx | 1 + website/src/docs/zh-CN/overview.mdx | 3 +- website/src/docs/zh-CN/worker-bridge-e2b.mdx | 134 ++++++++++++++++++ .../docs/__tests__/docs-router.spec.tsx | 1 + 7 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 website/src/docs/en/worker-bridge-e2b.mdx create mode 100644 website/src/docs/zh-CN/worker-bridge-e2b.mdx diff --git a/website/src/docs/en/architecture.mdx b/website/src/docs/en/architecture.mdx index e2375bf..c49cf5f 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 | Remote E2B sandboxes | Managed remote sandbox execution | | `worker-sys` | - | Host OS processes | Real-device or direct host control | ## Deployment pattern diff --git a/website/src/docs/en/overview.mdx b/website/src/docs/en/overview.mdx index 92224b7..1d15185 100644 --- a/website/src/docs/en/overview.mdx +++ b/website/src/docs/en/overview.mdx @@ -37,7 +37,7 @@ For admin users, OnlyBoxes additionally provides: | --- | --- | | Deployment | No external dependencies, control plane and execution plane can be deployed independently | | Scalability | Multiple heterogeneous workers can be added horizontally | -| Runtime model | Docker, boxlite, and OS-process based execution backends | +| Runtime model | Docker, boxlite, E2B, and OS-process based execution backends | | Interfaces | REST API, MCP endpoint | | Management | Dashboard, Open API | @@ -47,6 +47,7 @@ For admin users, OnlyBoxes additionally provides: - `worker-*`: execution nodes, responsible for task execution. - `worker-docker`: a worker implementation backed by Docker containers. - `worker-boxlite`: a worker implementation backed by boxlite. + - `worker-bridge-e2b`: a worker implementation backed by remote E2B sandboxes. - `worker-sys`: a worker implementation backed by host OS processes for direct device control scenarios. ## Typical usage diff --git a/website/src/docs/en/worker-bridge-e2b.mdx b/website/src/docs/en/worker-bridge-e2b.mdx new file mode 100644 index 0000000..0a4f20d --- /dev/null +++ b/website/src/docs/en/worker-bridge-e2b.mdx @@ -0,0 +1,134 @@ +export const meta = { + title: 'E2B Bridge', + description: 'Run OnlyBoxes executions in remote E2B sandboxes.', + section: 'Runtimes', + order: 45, + slug: 'worker-bridge-e2b', +} + +# E2B Bridge Runtime + +`worker-bridge-e2b` connects an OnlyBoxes control node to remote E2B sandboxes. The worker manages sessions and task dispatch locally, while E2B provides the execution environment. + +## When to use + +- You want managed remote sandboxes without running Docker or KVM on the worker host +- You need E2B templates tailored to different Python and terminal workloads +- You accept the network latency and external service dependency of a remote execution backend + +## How it works + +1. The worker connects to the OnlyBoxes control node over gRPC. +2. It creates, extends, and destroys sandboxes through the E2B Control API. +3. It executes commands and reads files through envd inside each sandbox. +4. `pythonExec` uses a new sandbox per request; terminal sessions reuse a sandbox until their lease expires. + +## Prerequisites + +- An E2B API key +- An E2B Python template with Python and `uv` +- An E2B terminal template with `/bin/bash` and `python3` +- Network access to the OnlyBoxes control node and E2B endpoints + +## Starting the worker + +Download the matching `worker-bridge-e2b` binary from the [GitHub releases page](https://github.com/Coooolfan/onlyboxes/releases/latest), then run: + +```bash +WORKER_CONSOLE_GRPC_TARGET= \ +WORKER_ID= \ +WORKER_SECRET= \ +WORKER_E2B_API_KEY= \ +WORKER_E2B_PYTHON_TEMPLATE= \ +WORKER_E2B_TERMINAL_TEMPLATE= \ +./onlyboxes-worker-bridge-e2b +``` + +Workers require TLS by default. Set `WORKER_CONSOLE_INSECURE=true` only for a trusted plaintext development connection. + +## Configuration file + +Set `WORKER_CONFIG_FILE` to use a specific `config.toml`. Otherwise, the worker looks next to its binary and then in the current working directory. + +Configuration precedence is: `WORKER_*` environment variable, E2B-compatible environment variable alias, `config.toml`, then built-in default. + +```toml +id = "wk_..." +secret = "..." +console_grpc_target = "console.internal:50051" +e2b_api_key = "..." +e2b_python_template = "python-template" +e2b_terminal_template = "terminal-template" + +[labels] +region = "us" +``` + +See the [annotated configuration template](https://github.com/Coooolfan/onlyboxes/blob/main/worker/worker-bridge-e2b/config.example.toml) for all available keys. + +## Environment variables + +### Connection and identity + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_ID` | Yes | | Worker ID from the dashboard | +| `WORKER_SECRET` | Yes | | One-time secret from worker creation | +| `WORKER_CONSOLE_GRPC_TARGET` | No | `127.0.0.1:50051` | Control node gRPC address | +| `WORKER_CONSOLE_INSECURE` | No | `false` | Set `true` only to allow a trusted non-TLS connection | +| `WORKER_NODE_NAME` | No | generated | Human-readable node name | +| `WORKER_LABELS` | No | | JSON or comma-separated `key=value` labels | + +### E2B + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_E2B_API_KEY` | Yes | | E2B Control API key; alias: `E2B_API_KEY` | +| `WORKER_E2B_PYTHON_TEMPLATE` | Yes | | Template used by `pythonExec`; alias: `E2B_PYTHON_EXEC_TEMPLATE` | +| `WORKER_E2B_TERMINAL_TEMPLATE` | Yes | | Template used by terminal sessions; alias: `E2B_TERMINAL_EXEC_TEMPLATE` | +| `WORKER_E2B_API_URL` | No | `https://api.e2b.app` | Control API base URL; alias: `E2B_API_URL` | +| `WORKER_E2B_DOMAIN` | No | `e2b.app` | Sandbox domain suffix; alias: `E2B_DOMAIN` | +| `WORKER_E2B_SANDBOX_URL` | No | | Override the envd base URL; alias: `E2B_SANDBOX_URL` | +| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | No | `60` | Control API and envd connection timeout | +| `WORKER_E2B_PYTHON_TIMEOUT_SEC` | No | `300` | Lifetime of a one-shot Python sandbox; alias: `E2B_SANDBOX_TIMEOUT_SEC` | + +### Terminal sessions and files + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_TERMINAL_LEASE_MIN_SEC` | No | `60` | Minimum requested session lease | +| `WORKER_TERMINAL_LEASE_MAX_SEC` | No | `1800` | Maximum requested session lease | +| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | No | `300` | Default session lease | +| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | No | `1048576` | Limit for each output stream and file reads | +| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | No | `0` (unlimited) | Maximum exported file size | +| `WORKER_TERMINAL_EXPORT_MODE` | No | `sandbox` | `sandbox` uploads directly from E2B; `worker` proxies file bytes | +| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | No | `128` | Concurrent operations allowed in one session | + +Terminal commands share the sandbox filesystem but run in separate `/bin/bash -l -c` processes. They do not preserve the current directory, shell variables, or process environment between calls. + +### Capability concurrency + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_ECHO_MAX_INFLIGHT` | No | `128` | Concurrent `echo` calls | +| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | No | `32` | Concurrent `pythonExec` calls | +| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | No | `64` | Concurrent `terminalExec` calls | +| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | No | `128` | Concurrent `terminalResource` calls | + +### Heartbeat and logging + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `WORKER_HEARTBEAT_INTERVAL_SEC` | No | `5` | Heartbeat interval in seconds | +| `WORKER_HEARTBEAT_JITTER_PCT` | No | `20` | Heartbeat jitter percentage | +| `WORKER_CALL_TIMEOUT_SEC` | No | `ceil(2.5 * interval)` | Timeout for console hello and heartbeat acknowledgements | +| `WORKER_LOG_LEVEL` | No | `info` | `debug` / `info` / `warn` / `error` | +| `WORKER_LOG_FORMAT` | No | `json` | `json` / `text` | +| `WORKER_LOG_ADD_SOURCE` | No | `false` | Include source file and line | + +## Related docs + +- [Architecture](/en/docs/architecture) +- [Docker Runtime](/en/docs/worker-docker) +- [Boxlite Runtime](/en/docs/worker-boxlite) +- [System Process Runtime](/en/docs/worker-sys) diff --git a/website/src/docs/zh-CN/architecture.mdx b/website/src/docs/zh-CN/architecture.mdx index 9d13500..48ee6b4 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..3bf9365 100644 --- a/website/src/docs/zh-CN/overview.mdx +++ b/website/src/docs/zh-CN/overview.mdx @@ -37,7 +37,7 @@ OnlyBoxes 是一个面向个人与小型团队的自托管代码执行沙箱平 | --- | --- | | 部署形态 | 无外部依赖,控制面与执行面可独立部署 | | 扩展方式 | 支持横向增加多个异构 worker | -| 运行时模型 | Docker、boxlite、操作系统进程 | +| 运行时模型 | Docker、boxlite、E2B、操作系统进程 | | 接口形式 | REST API、MCP endpoint | | 管理能力 | Dashboard、Open API | @@ -47,6 +47,7 @@ OnlyBoxes 是一个面向个人与小型团队的自托管代码执行沙箱平 - `worker-*`:执行节点,负责具体任务执行。 - `worker-docker`:基于 Docker 容器的 worker 实现。 - `worker-boxlite`:基于 boxlite 的 worker 实现。 + - `worker-bridge-e2b`:基于远程 E2B 沙箱的 worker 实现。 - `worker-sys`:基于宿主机进程的 worker 实现,适合真实设备或主机控制场景。 ## 典型使用流程 diff --git a/website/src/docs/zh-CN/worker-bridge-e2b.mdx b/website/src/docs/zh-CN/worker-bridge-e2b.mdx new file mode 100644 index 0000000..b78c694 --- /dev/null +++ b/website/src/docs/zh-CN/worker-bridge-e2b.mdx @@ -0,0 +1,134 @@ +export const meta = { + title: 'E2B Bridge', + description: '通过远程 E2B 沙箱运行 OnlyBoxes 执行任务。', + section: '运行时', + order: 45, + slug: 'worker-bridge-e2b', +} + +# E2B Bridge 运行时 + +`worker-bridge-e2b` 将 OnlyBoxes 控制节点连接到远程 E2B 沙箱。worker 在本地管理 session 与任务分发,由 E2B 提供实际执行环境。 + +## 适用场景 + +- 希望使用托管的远程沙箱,不在 worker 主机上运行 Docker 或 KVM +- 需要为 Python 与终端任务准备不同的 E2B 模板 +- 可以接受远程执行带来的网络延迟和外部服务依赖 + +## 工作原理 + +1. Worker 通过 gRPC 连接 OnlyBoxes 控制节点。 +2. Worker 通过 E2B Control API 创建、续期和销毁沙箱。 +3. 命令执行和文件读取通过沙箱内的 envd 完成。 +4. `pythonExec` 每次请求使用一个新沙箱;`terminalExec` 会话在租约到期前复用同一沙箱。 + +## 前置条件 + +- E2B API Key +- 提供 Python 和 `uv` 的 E2B Python 模板 +- 提供 `/bin/bash` 和 `python3` 的 E2B 终端模板 +- 能够访问 OnlyBoxes 控制节点与 E2B 端点 + +## 启动 worker + +从 [GitHub Releases 页面](https://github.com/Coooolfan/onlyboxes/releases/latest) 下载与系统架构匹配的 `worker-bridge-e2b` 二进制,然后运行: + +```bash +WORKER_CONSOLE_GRPC_TARGET= \ +WORKER_ID= \ +WORKER_SECRET= \ +WORKER_E2B_API_KEY= \ +WORKER_E2B_PYTHON_TEMPLATE= \ +WORKER_E2B_TERMINAL_TEMPLATE= \ +./onlyboxes-worker-bridge-e2b +``` + +worker 默认要求 TLS。只有在可信的明文开发链路中才应设置 `WORKER_CONSOLE_INSECURE=true`。 + +## 配置文件 + +设置 `WORKER_CONFIG_FILE` 可以显式指定 `config.toml`;未设置时,worker 会依次查找程序文件同目录和当前工作目录下的 `config.toml`。 + +配置优先级为:`WORKER_*` 环境变量、E2B 兼容环境变量别名、`config.toml`、内置默认值。 + +```toml +id = "wk_..." +secret = "..." +console_grpc_target = "console.internal:50051" +e2b_api_key = "..." +e2b_python_template = "python-template" +e2b_terminal_template = "terminal-template" + +[labels] +region = "cn" +``` + +全部配置项见[带注释的配置模板](https://github.com/Coooolfan/onlyboxes/blob/main/worker/worker-bridge-e2b/config.example.toml)。 + +## 环境变量 + +### 连接与身份 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_ID` | 是 | | Dashboard 中的 Worker ID | +| `WORKER_SECRET` | 是 | | 创建 worker 时的一次性密钥 | +| `WORKER_CONSOLE_GRPC_TARGET` | 否 | `127.0.0.1:50051` | 控制节点 gRPC 地址 | +| `WORKER_CONSOLE_INSECURE` | 否 | `false` | 仅在可信环境中设为 `true` 以允许非 TLS 连接 | +| `WORKER_NODE_NAME` | 否 | 自动生成 | 可读的节点名称 | +| `WORKER_LABELS` | 否 | | JSON 或逗号分隔的 `key=value` 标签 | + +### E2B + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_E2B_API_KEY` | 是 | | E2B Control API Key;别名:`E2B_API_KEY` | +| `WORKER_E2B_PYTHON_TEMPLATE` | 是 | | `pythonExec` 使用的模板;别名:`E2B_PYTHON_EXEC_TEMPLATE` | +| `WORKER_E2B_TERMINAL_TEMPLATE` | 是 | | 终端 session 使用的模板;别名:`E2B_TERMINAL_EXEC_TEMPLATE` | +| `WORKER_E2B_API_URL` | 否 | `https://api.e2b.app` | Control API 基础 URL;别名:`E2B_API_URL` | +| `WORKER_E2B_DOMAIN` | 否 | `e2b.app` | 沙箱域名后缀;别名:`E2B_DOMAIN` | +| `WORKER_E2B_SANDBOX_URL` | 否 | | 覆盖 envd 基础 URL;别名:`E2B_SANDBOX_URL` | +| `WORKER_E2B_REQUEST_TIMEOUT_SEC` | 否 | `60` | Control API 与 envd 建连超时 | +| `WORKER_E2B_PYTHON_TIMEOUT_SEC` | 否 | `300` | 一次性 Python 沙箱的生命周期;别名:`E2B_SANDBOX_TIMEOUT_SEC` | + +### 终端 session 与文件 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_TERMINAL_LEASE_MIN_SEC` | 否 | `60` | 请求可指定的最短 session 租约 | +| `WORKER_TERMINAL_LEASE_MAX_SEC` | 否 | `1800` | 请求可指定的最长 session 租约 | +| `WORKER_TERMINAL_LEASE_DEFAULT_SEC` | 否 | `300` | 默认 session 租约 | +| `WORKER_TERMINAL_OUTPUT_LIMIT_BYTES` | 否 | `1048576` | 每路输出流与文件读取的大小限制 | +| `WORKER_TERMINAL_EXPORT_MAX_BYTES` | 否 | `0`(无限制) | 导出文件大小上限 | +| `WORKER_TERMINAL_EXPORT_MODE` | 否 | `sandbox` | `sandbox` 由 E2B 直接上传;`worker` 由 worker 转发文件 | +| `WORKER_TERMINAL_SESSION_MAX_INFLIGHT` | 否 | `128` | 单个 session 允许的并发操作数 | + +终端命令共享沙箱文件系统,但每次调用都在独立的 `/bin/bash -l -c` 进程中执行,不会在调用之间保留当前目录、shell 变量或进程环境。 + +### 能力并发 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_ECHO_MAX_INFLIGHT` | 否 | `128` | `echo` 并发调用数 | +| `WORKER_PYTHON_EXEC_MAX_INFLIGHT` | 否 | `32` | `pythonExec` 并发调用数 | +| `WORKER_TERMINAL_EXEC_MAX_INFLIGHT` | 否 | `64` | `terminalExec` 并发调用数 | +| `WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT` | 否 | `128` | `terminalResource` 并发调用数 | + +### 心跳与日志 + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `WORKER_HEARTBEAT_INTERVAL_SEC` | 否 | `5` | 心跳间隔秒数 | +| `WORKER_HEARTBEAT_JITTER_PCT` | 否 | `20` | 心跳抖动百分比 | +| `WORKER_CALL_TIMEOUT_SEC` | 否 | `ceil(2.5 * interval)` | console hello 与心跳确认超时 | +| `WORKER_LOG_LEVEL` | 否 | `info` | `debug` / `info` / `warn` / `error` | +| `WORKER_LOG_FORMAT` | 否 | `json` | `json` / `text` | +| `WORKER_LOG_ADD_SOURCE` | 否 | `false` | 是否包含源文件和行号 | + +## 相关文档 + +- [架构](/zh-CN/docs/architecture) +- [Docker 运行时](/zh-CN/docs/worker-docker) +- [Boxlite 运行时](/zh-CN/docs/worker-boxlite) +- [系统进程运行时](/zh-CN/docs/worker-sys) diff --git a/website/src/features/docs/__tests__/docs-router.spec.tsx b/website/src/features/docs/__tests__/docs-router.spec.tsx index 2b5f8ed..fa947fb 100644 --- a/website/src/features/docs/__tests__/docs-router.spec.tsx +++ b/website/src/features/docs/__tests__/docs-router.spec.tsx @@ -50,6 +50,7 @@ describe('docs routing', () => { 'quick-start', 'worker-docker', 'worker-boxlite', + 'worker-bridge-e2b', 'worker-sys', 'console-api', 'mcp-tools', From 37ab01406ebe322c80823e360d74e81db319f149 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Thu, 30 Jul 2026 21:19:46 +0800 Subject: [PATCH 10/12] docs(website): shorten E2B terminal heading --- website/src/docs/en/worker-bridge-e2b.mdx | 2 +- website/src/docs/zh-CN/worker-bridge-e2b.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/docs/en/worker-bridge-e2b.mdx b/website/src/docs/en/worker-bridge-e2b.mdx index 0a4f20d..937f21d 100644 --- a/website/src/docs/en/worker-bridge-e2b.mdx +++ b/website/src/docs/en/worker-bridge-e2b.mdx @@ -92,7 +92,7 @@ See the [annotated configuration template](https://github.com/Coooolfan/onlyboxe | `WORKER_E2B_REQUEST_TIMEOUT_SEC` | No | `60` | Control API and envd connection timeout | | `WORKER_E2B_PYTHON_TIMEOUT_SEC` | No | `300` | Lifetime of a one-shot Python sandbox; alias: `E2B_SANDBOX_TIMEOUT_SEC` | -### Terminal sessions and files +### Terminal | Variable | Required | Default | Description | | --- | --- | --- | --- | diff --git a/website/src/docs/zh-CN/worker-bridge-e2b.mdx b/website/src/docs/zh-CN/worker-bridge-e2b.mdx index b78c694..5b53d7e 100644 --- a/website/src/docs/zh-CN/worker-bridge-e2b.mdx +++ b/website/src/docs/zh-CN/worker-bridge-e2b.mdx @@ -92,7 +92,7 @@ region = "cn" | `WORKER_E2B_REQUEST_TIMEOUT_SEC` | 否 | `60` | Control API 与 envd 建连超时 | | `WORKER_E2B_PYTHON_TIMEOUT_SEC` | 否 | `300` | 一次性 Python 沙箱的生命周期;别名:`E2B_SANDBOX_TIMEOUT_SEC` | -### 终端 session 与文件 +### 终端 | 变量 | 必填 | 默认值 | 说明 | | --- | --- | --- | --- | From 40d4703d68541dc89c91df74ea45cebe1cded3a4 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Fri, 31 Jul 2026 12:06:38 +0800 Subject: [PATCH 11/12] docs(website): centralize bridge architecture diagram --- static/architecture-dark.svg | 67 +++- static/architecture.svg | 67 +++- static/architecture.zh-CN-dark.svg | 67 +++- static/architecture.zh-CN.svg | 67 +++- website/public/docs-assets/architecture.svg | 264 -------------- .../public/docs-assets/architecture.zh-CN.svg | 264 -------------- website/src/docs/en/architecture.mdx | 4 +- website/src/docs/zh-CN/architecture.mdx | 4 +- .../src/features/site/ArchitectureDiagram.tsx | 39 ++ website/src/pages/HomePage.tsx | 343 +----------------- 10 files changed, 257 insertions(+), 929 deletions(-) delete mode 100644 website/public/docs-assets/architecture.svg delete mode 100644 website/public/docs-assets/architecture.zh-CN.svg create mode 100644 website/src/features/site/ArchitectureDiagram.tsx diff --git a/static/architecture-dark.svg b/static/architecture-dark.svg index 2d35806..6b8dfde 100644 --- a/static/architecture-dark.svg +++ b/static/architecture-dark.svg @@ -1,4 +1,4 @@ - + @@ -19,11 +19,13 @@ - - - - - + + + + + + + @@ -53,6 +55,8 @@ + + @@ -78,17 +82,23 @@ - - + + + + + + + + @@ -107,15 +117,18 @@ - + + - + + - + + @@ -197,7 +210,7 @@ - + @@ -207,7 +220,19 @@ WORKER NODE - + + + + + + + + + + BRIDGE WORKER + + + @@ -236,7 +261,7 @@ - + @@ -248,6 +273,18 @@ + + + + + + + + TARGET + E2B Sandboxes + + + @@ -259,4 +296,4 @@ OS Processes (WIP) - \ No newline at end of file + diff --git a/static/architecture.svg b/static/architecture.svg index 2141a7d..7f41b64 100644 --- a/static/architecture.svg +++ b/static/architecture.svg @@ -1,4 +1,4 @@ - + @@ -19,11 +19,13 @@ - - - - - + + + + + + + @@ -53,6 +55,8 @@ + + @@ -79,17 +83,23 @@ - - + + + + + + + + @@ -108,15 +118,18 @@ - + + - + + - + + @@ -199,7 +212,7 @@ - + @@ -209,7 +222,19 @@ WORKER NODE - + + + + + + + + + + BRIDGE WORKER + + + @@ -238,7 +263,7 @@ - + @@ -250,6 +275,18 @@ + + + + + + + + TARGET + E2B Sandboxes + + + @@ -261,4 +298,4 @@ OS Processes (WIP) - \ No newline at end of file + diff --git a/static/architecture.zh-CN-dark.svg b/static/architecture.zh-CN-dark.svg index e9e27f4..4fe1906 100644 --- a/static/architecture.zh-CN-dark.svg +++ b/static/architecture.zh-CN-dark.svg @@ -1,4 +1,4 @@ - + @@ -19,11 +19,13 @@ - - - - - + + + + + + + @@ -53,6 +55,8 @@ + + @@ -78,17 +82,23 @@ - - + + + + + + + + @@ -107,15 +117,18 @@ - + + - + + - + + @@ -197,7 +210,7 @@ - + @@ -207,7 +220,19 @@ 执行节点 - + + + + + + + + + + 桥接执行节点 + + + @@ -236,7 +261,7 @@ - + @@ -248,6 +273,18 @@ + + + + + + + + 执行环境 + E2B 沙箱 + + + @@ -259,4 +296,4 @@ 操作系统进程(WIP) - \ No newline at end of file + diff --git a/static/architecture.zh-CN.svg b/static/architecture.zh-CN.svg index 3f7839e..e18652f 100644 --- a/static/architecture.zh-CN.svg +++ b/static/architecture.zh-CN.svg @@ -1,4 +1,4 @@ - + @@ -19,11 +19,13 @@ - - - - - + + + + + + + @@ -53,6 +55,8 @@ + + @@ -79,17 +83,23 @@ - - + + + + + + + + @@ -108,15 +118,18 @@ - + + - + + - + + @@ -199,7 +212,7 @@ - + @@ -209,7 +222,19 @@ 执行节点 - + + + + + + + + + + 桥接执行节点 + + + @@ -238,7 +263,7 @@ - + @@ -250,6 +275,18 @@ + + + + + + + + 执行环境 + E2B 沙箱 + + + @@ -261,4 +298,4 @@ 操作系统进程(WIP) - \ No newline at end of file + diff --git a/website/public/docs-assets/architecture.svg b/website/public/docs-assets/architecture.svg deleted file mode 100644 index 2141a7d..0000000 --- a/website/public/docs-assets/architecture.svg +++ /dev/null @@ -1,264 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CLIENT - Developer - - - - - - - - - - - CLIENT - API Client - - - - - - - - - - - - - - CLIENT - MCP Client - - - - - - - - - - - - - - - - - - - Console - - - - - - - - - - - - - - WORKER NODE - - - - - - - - - - - WORKER NODE - - - - - - - - - - - - - WORKER NODE - - - - - - - - - - - - - - TARGET - Docker Containers - - - - - - - - - - - TARGET - Boxlite Envs - - - - - - - - - - - TARGET - OS Processes (WIP) - - - \ No newline at end of file diff --git a/website/public/docs-assets/architecture.zh-CN.svg b/website/public/docs-assets/architecture.zh-CN.svg deleted file mode 100644 index 3f7839e..0000000 --- a/website/public/docs-assets/architecture.zh-CN.svg +++ /dev/null @@ -1,264 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 客户端 - 开发者 - - - - - - - - - - - 客户端 - API 客户端 - - - - - - - - - - - - - - 客户端 - MCP 客户端 - - - - - - - - - - - - - - - - - - - 控制台 - - - - - - - - - - - - - - 执行节点 - - - - - - - - - - - 执行节点 - - - - - - - - - - - - - 执行节点 - - - - - - - - - - - - - - 执行环境 - Docker 容器 - - - - - - - - - - - 执行环境 - Boxlite 环境 - - - - - - - - - - - 执行环境 - 操作系统进程(WIP) - - - \ No newline at end of file diff --git a/website/src/docs/en/architecture.mdx b/website/src/docs/en/architecture.mdx index c49cf5f..bf94777 100644 --- a/website/src/docs/en/architecture.mdx +++ b/website/src/docs/en/architecture.mdx @@ -1,3 +1,5 @@ +import { ArchitectureDiagram } from '../../features/site/ArchitectureDiagram' + export const meta = { title: 'Architecture', description: 'How the control node and workers are connected.', @@ -10,7 +12,7 @@ export const meta = { OnlyBoxes uses a control-plane and execution-plane split. -![OnlyBoxes architecture](/docs-assets/architecture.svg) + ## Control node diff --git a/website/src/docs/zh-CN/architecture.mdx b/website/src/docs/zh-CN/architecture.mdx index 48ee6b4..01e342f 100644 --- a/website/src/docs/zh-CN/architecture.mdx +++ b/website/src/docs/zh-CN/architecture.mdx @@ -1,3 +1,5 @@ +import { ArchitectureDiagram } from '../../features/site/ArchitectureDiagram' + export const meta = { title: '架构', description: '控制节点与执行节点的连接方式。', @@ -10,7 +12,7 @@ export const meta = { OnlyBoxes 采用控制面与执行面分离的架构。 -![OnlyBoxes 架构图](/docs-assets/architecture.zh-CN.svg) + ## 控制节点 diff --git a/website/src/features/site/ArchitectureDiagram.tsx b/website/src/features/site/ArchitectureDiagram.tsx new file mode 100644 index 0000000..103b7f5 --- /dev/null +++ b/website/src/features/site/ArchitectureDiagram.tsx @@ -0,0 +1,39 @@ +import architectureDarkUrl from '../../../../static/architecture-dark.svg?url' +import architectureUrl from '../../../../static/architecture.svg?url' +import architectureZhCnDarkUrl from '../../../../static/architecture.zh-CN-dark.svg?url' +import architectureZhCnUrl from '../../../../static/architecture.zh-CN.svg?url' +import { useSiteContext } from './SiteContext' + +interface ArchitectureDiagramProps { + className?: string + eager?: boolean +} + +const architectureUrls = { + en: { + light: architectureUrl, + dark: architectureDarkUrl, + }, + 'zh-CN': { + light: architectureZhCnUrl, + dark: architectureZhCnDarkUrl, + }, +} as const + +export function ArchitectureDiagram({ className = '', eager = false }: ArchitectureDiagramProps) { + const { isDark, locale } = useSiteContext() + const src = architectureUrls[locale][isDark ? 'dark' : 'light'] + const alt = locale === 'zh-CN' ? 'OnlyBoxes 架构图' : 'OnlyBoxes architecture' + + return ( + {alt} + ) +} diff --git a/website/src/pages/HomePage.tsx b/website/src/pages/HomePage.tsx index d18bba9..cff81e1 100644 --- a/website/src/pages/HomePage.tsx +++ b/website/src/pages/HomePage.tsx @@ -1,23 +1,17 @@ -import { type ElementType, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { BookOpen, - Box, Check, Copy, - Cpu, Github, Languages, Moon, - Package, - Blocks, - Server, Sun, - Terminal, - User, } from 'lucide-react' import { useSiteContext } from '../features/site/SiteContext' -import { type DocsLocale, docsLocaleLabels, getDocsRootHref, getOtherDocsLocale } from '../features/docs/registry' +import { ArchitectureDiagram } from '../features/site/ArchitectureDiagram' +import { docsLocaleLabels, getDocsRootHref, getOtherDocsLocale } from '../features/docs/registry' const INSTALL_CMD = 'curl -fsSL https://onlybox.es/install.sh | bash' @@ -31,16 +25,6 @@ const homeCopy = { subtitle: 'A self-hosted code execution sandbox platform for individuals and small teams.', documentation: 'Documentation', github: 'GitHub', - architecture: 'Architecture', - developer: 'Developer', - apiClient: 'API Client', - mcpClient: 'MCP Client', - client: 'Client', - execution: 'Execution', - workerNode: 'Worker Node', - environment: 'Environment', - osProcess: 'OS Process', - console: 'Console', }, 'zh-CN': { rotatingTexts: [ @@ -51,21 +35,9 @@ const homeCopy = { subtitle: '面向个人与小型团队的自托管代码执行沙箱平台。', documentation: '文档', github: 'GitHub', - architecture: '架构', - developer: '开发者', - apiClient: 'API 客户端', - mcpClient: 'MCP 客户端', - client: '客户端', - execution: '执行层', - workerNode: '执行节点', - environment: '运行环境', - osProcess: '系统进程', - console: '控制节点', }, } as const -type HomeCopy = (typeof homeCopy)[DocsLocale] - const RotatingText = ({ texts }: { texts: readonly string[] }) => { const [index, setIndex] = useState(0) const [fade, setFade] = useState(true) @@ -95,313 +67,6 @@ const RotatingText = ({ texts }: { texts: readonly string[] }) => { ) } -const FlowLine = ({ d, isDark }: { d: string; isDark: boolean }) => ( - - - - - - -) - -interface SvgNodeProps { - x: number - y: number - width?: number - height?: number - title: string - subtitle?: string - icon?: ElementType - dashed?: boolean - active?: boolean - isDark: boolean -} - -const SvgNode = ({ - x, - y, - width = 220, - height = 56, - title, - subtitle, - icon: Icon, - dashed = false, - active = false, - isDark, -}: SvgNodeProps) => ( - - - - {Icon ? ( - - - - ) : null} - {subtitle ? ( - - {subtitle} - - ) : null} - - {title} - - -) - -const ArchitectureDiagram = ({ isDark, copy }: { isDark: boolean; copy: HomeCopy }) => { - const [isCompact, setIsCompact] = useState(false) - - useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return - } - - const mediaQuery = window.matchMedia('(max-width: 767px)') - setIsCompact(mediaQuery.matches) - const handler = (event: MediaQueryListEvent) => setIsCompact(event.matches) - mediaQuery.addEventListener('change', handler) - return () => mediaQuery.removeEventListener('change', handler) - }, []) - - const clientWidth = isCompact ? 160 : 220 - const clientRight = 20 + clientWidth - const consoleX = isCompact ? 300 : 320 - const consoleRight = consoleX + 120 - const clientMid = Math.round((clientRight + consoleX) / 2) - const environmentX = isCompact ? 540 : 820 - const environmentWidth = isCompact ? 160 : 220 - const nextX = isCompact ? environmentX : 520 - const consoleMid = Math.round((consoleRight + nextX) / 2) - - return ( -
-
- {copy.architecture} -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - {!isCompact ? ( - <> - - - - - ) : null} - - - - - - - - - - - - - {copy.console} - - - - {!isCompact ? ( - <> - - - - - ) : null} - - - - - - - - - - - - - - - {!isCompact ? ( - <> - - - - - - - - ) : null} - - -
- ) -} - const InstallCommand = ({ isDark }: { isDark: boolean }) => { const [copied, setCopied] = useState(false) @@ -527,7 +192,7 @@ function HomePage() { }`} />
- +
From 107f82c9c5de124371b9f6e1221658a07d630763 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Fri, 31 Jul 2026 12:26:09 +0800 Subject: [PATCH 12/12] feat(website): add syntax highlighting to docs --- website/package.json | 1 + .../docs/__tests__/docs-router.spec.tsx | 38 ++++ website/src/index.css | 37 +++- website/vite.config.ts | 20 +- website/yarn.lock | 197 +++++++++++++++++- 5 files changed, 281 insertions(+), 12 deletions(-) diff --git a/website/package.json b/website/package.json index df25ceb..2ebcc59 100644 --- a/website/package.json +++ b/website/package.json @@ -21,6 +21,7 @@ "tailwindcss": "^4.2.2" }, "devDependencies": { + "@shikijs/rehype": "^4.3.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/website/src/features/docs/__tests__/docs-router.spec.tsx b/website/src/features/docs/__tests__/docs-router.spec.tsx index fa947fb..4bdd74c 100644 --- a/website/src/features/docs/__tests__/docs-router.spec.tsx +++ b/website/src/features/docs/__tests__/docs-router.spec.tsx @@ -78,6 +78,44 @@ describe('docs routing', () => { expect(view.getByRole('link', { name: '简体中文' })).toHaveAttribute('href', '/zh-CN/docs/quick-start/') }) + it('renders build-time syntax highlighting without changing copied code', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + + const view = renderRoutes('/en/docs/worker-bridge-e2b') + + expect(await view.findByRole('heading', { name: 'E2B Bridge Runtime' })).toBeInTheDocument() + + const codeBlock = view.container.querySelector('pre.shiki') + const tokens = codeBlock?.querySelectorAll('.line > span') ?? [] + + expect(codeBlock).not.toBeNull() + expect(codeBlock).toHaveClass('github-light', 'github-dark') + expect(tokens.length).toBeGreaterThan(3) + expect(tokens[0]?.style.getPropertyValue('--shiki-light')).not.toBe('') + expect(tokens[0]?.style.getPropertyValue('--shiki-dark')).not.toBe('') + + const code = codeBlock?.textContent ?? '' + const copyButton = codeBlock?.parentElement?.querySelector('button[aria-label="Copy code"]') + + expect(copyButton).not.toBeNull() + fireEvent.click(copyButton!) + await waitFor(() => expect(writeText).toHaveBeenCalledWith(code)) + + fireEvent.click(view.getByRole('button', { name: 'Toggle theme' })) + expect(view.container.querySelector('[data-docs-theme="dark"]')).not.toBeNull() + + if (originalClipboard) { + Object.defineProperty(navigator, 'clipboard', originalClipboard) + } else { + Reflect.deleteProperty(navigator, 'clipboard') + } + }) + it('highlights every toc section whose content is currently visible', async () => { const rects = new WeakMap() const defaultRect = new DOMRect(0, 0, 0, 0) diff --git a/website/src/index.css b/website/src/index.css index 24b4a8b..5a8d816 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -24,7 +24,7 @@ button { /* Code block scrollbar */ pre { scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + scrollbar-color: var(--ob-pre-scrollbar) transparent; } pre::-webkit-scrollbar { @@ -36,18 +36,18 @@ pre::-webkit-scrollbar-track { } pre::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); + background: var(--ob-pre-scrollbar); border-radius: 3px; } pre::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.25); + background: var(--ob-pre-scrollbar-hover); } /* Code block selection */ pre ::selection { - background: rgba(255, 255, 255, 0.2); - color: #ffffff; + background: var(--ob-pre-selection); + color: inherit; } /* Docs theme tokens — light */ @@ -59,8 +59,11 @@ pre ::selection { --ob-line: #e5e5e5; --ob-code-bg: #f5f5f5; --ob-code-text: #171717; - --ob-pre-bg: #0a0a0a; - --ob-pre-text: #f5f5f5; + --ob-pre-bg: #f6f8fa; + --ob-pre-text: #24292f; + --ob-pre-scrollbar: rgba(31, 35, 40, 0.18); + --ob-pre-scrollbar-hover: rgba(31, 35, 40, 0.3); + --ob-pre-selection: rgba(9, 105, 218, 0.22); --ob-blockquote-bg: #fafafa; --ob-blockquote-border: #e5e5e5; --ob-blockquote-text: #525252; @@ -85,6 +88,9 @@ pre ::selection { --ob-code-text: #e5e5e5; --ob-pre-bg: #141414; --ob-pre-text: #e5e5e5; + --ob-pre-scrollbar: rgba(255, 255, 255, 0.15); + --ob-pre-scrollbar-hover: rgba(255, 255, 255, 0.25); + --ob-pre-selection: rgba(255, 255, 255, 0.2); --ob-blockquote-bg: #141414; --ob-blockquote-border: #262626; --ob-blockquote-text: #a3a3a3; @@ -92,3 +98,20 @@ pre ::selection { --ob-table-border: #262626; --ob-table-text: #a3a3a3; } + +/* Shiki emits both token palettes at build time; the docs theme selects one. */ +[data-docs-theme="light"] .shiki, +[data-docs-theme="light"] .shiki span { + color: var(--shiki-light); + font-style: var(--shiki-light-font-style); + font-weight: var(--shiki-light-font-weight); + text-decoration: var(--shiki-light-text-decoration); +} + +[data-docs-theme="dark"] .shiki, +[data-docs-theme="dark"] .shiki span { + color: var(--shiki-dark); + font-style: var(--shiki-dark-font-style); + font-weight: var(--shiki-dark-font-weight); + text-decoration: var(--shiki-dark-text-decoration); +} diff --git a/website/vite.config.ts b/website/vite.config.ts index c652ab8..73c0669 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -5,12 +5,30 @@ import mdx from '@mdx-js/rollup' import tailwindcss from '@tailwindcss/vite' import remarkGfm from 'remark-gfm' import rehypeSlug from 'rehype-slug' +import rehypeShiki from '@shikijs/rehype' import fs from 'node:fs' import path from 'node:path' const mdxPlugin = mdx({ remarkPlugins: [remarkGfm], - rehypePlugins: [rehypeSlug], + rehypePlugins: [ + rehypeSlug, + [ + rehypeShiki, + { + themes: { + light: 'github-light', + dark: 'github-dark', + }, + defaultColor: false, + defaultLanguage: 'text', + fallbackLanguage: 'text', + langAlias: { + env: 'dotenv', + }, + }, + ], + ], }) as Plugin mdxPlugin.enforce = 'pre' diff --git a/website/yarn.lock b/website/yarn.lock index 9f094c1..cfe985f 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -759,6 +759,100 @@ __metadata: languageName: node linkType: hard +"@shikijs/core@npm:4.3.1": + version: 4.3.1 + resolution: "@shikijs/core@npm:4.3.1" + dependencies: + "@shikijs/primitive": "npm:4.3.1" + "@shikijs/types": "npm:4.3.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.4" + hast-util-to-html: "npm:^9.0.5" + checksum: 10c0/75faa9aba4251e412764844d2329862350ef31ac21d0f07658b8a975029bd25f192ef069e757e6689a8bd9f1b19999e6f47b308ce1dee7c352543f435dd5d189 + languageName: node + linkType: hard + +"@shikijs/engine-javascript@npm:4.3.1": + version: 4.3.1 + resolution: "@shikijs/engine-javascript@npm:4.3.1" + dependencies: + "@shikijs/types": "npm:4.3.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" + oniguruma-to-es: "npm:^4.3.6" + checksum: 10c0/683312ded6648c6a1bfc003d1b628dda293128ad6eebbd2f4627c900ee3951cc606343fc4e87d79e6c4f32f1a51bc66b2d0998f7bcd75da803a7364c7272ffa1 + languageName: node + linkType: hard + +"@shikijs/engine-oniguruma@npm:4.3.1": + version: 4.3.1 + resolution: "@shikijs/engine-oniguruma@npm:4.3.1" + dependencies: + "@shikijs/types": "npm:4.3.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" + checksum: 10c0/a6522fc5a55966a811b20396bdf1678f89e4fcd8e41f07dc6532acf7b111a161fad0656eb0dc1855275fdf7107890099c373bd23eb200e2145fe725cc91e8544 + languageName: node + linkType: hard + +"@shikijs/langs@npm:4.3.1": + version: 4.3.1 + resolution: "@shikijs/langs@npm:4.3.1" + dependencies: + "@shikijs/types": "npm:4.3.1" + checksum: 10c0/89785ca417021780891598b1eacfbe89272fa068bd02215dbc6945553e5e7164a3825c2f09285d2809003e3421d577366a2fc19b7996e1a0621a2adfb73cad12 + languageName: node + linkType: hard + +"@shikijs/primitive@npm:4.3.1": + version: 4.3.1 + resolution: "@shikijs/primitive@npm:4.3.1" + dependencies: + "@shikijs/types": "npm:4.3.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.4" + checksum: 10c0/33397d0d60842d74ce07db588dec3818241713c4812de6fe76c40a477e464d319f20f50f3dfa40ebc2c10964754a256d9bb4ac6f8b49a09fca1bc206a7c7b46f + languageName: node + linkType: hard + +"@shikijs/rehype@npm:^4.3.1": + version: 4.3.1 + resolution: "@shikijs/rehype@npm:4.3.1" + dependencies: + "@shikijs/types": "npm:4.3.1" + "@types/hast": "npm:^3.0.4" + hast-util-to-string: "npm:^3.0.1" + shiki: "npm:4.3.1" + unified: "npm:^11.0.5" + unist-util-visit: "npm:^5.1.0" + checksum: 10c0/3221a74cf1dd0ea4283f39b082aa13d7b515e0c4b98c58b713c956fd50cb72267870355f92a6f5a22e9cf93c8a1b99ee98c0fe14a614c2cbd28808c259daa788 + languageName: node + linkType: hard + +"@shikijs/themes@npm:4.3.1": + version: 4.3.1 + resolution: "@shikijs/themes@npm:4.3.1" + dependencies: + "@shikijs/types": "npm:4.3.1" + checksum: 10c0/3d578a981630e880fa18bbab14ab81e61308163c10e1967fb40fcce9d6e321d340595c7ade66275a0e80a6f3e5417ac5785052aacd7ae19c9e67bbb06eb0f278 + languageName: node + linkType: hard + +"@shikijs/types@npm:4.3.1": + version: 4.3.1 + resolution: "@shikijs/types@npm:4.3.1" + dependencies: + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.4" + checksum: 10c0/e1586b7e8fc0610f9896c622307274c5876fe7f2a40919e08cde8947ef5022d545641c47763f29505eaad6e4b4f7c180890fd1c220146e5e75f69f384a4dc228 + languageName: node + linkType: hard + +"@shikijs/vscode-textmate@npm:^10.0.2": + version: 10.0.2 + resolution: "@shikijs/vscode-textmate@npm:10.0.2" + checksum: 10c0/36b682d691088ec244de292dc8f91b808f95c89466af421cf84cbab92230f03c8348649c14b3251991b10ce632b0c715e416e992dd5f28ff3221dc2693fd9462 + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -1047,6 +1141,15 @@ __metadata: languageName: node linkType: hard +"@types/hast@npm:^3.0.4": + version: 3.0.5 + resolution: "@types/hast@npm:3.0.5" + dependencies: + "@types/unist": "npm:*" + checksum: 10c0/f3af8594a6903a507ed191eda944af18099198d6708c29102ae17118c3e20779f6929e91ed37e033541fd28d58055d8a5910a4366dbdf976cf81b13a464741fb + languageName: node + linkType: hard + "@types/mdast@npm:^4.0.0": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4" @@ -1778,6 +1881,25 @@ __metadata: languageName: node linkType: hard +"hast-util-to-html@npm:^9.0.5": + version: 9.0.5 + resolution: "hast-util-to-html@npm:9.0.5" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + ccount: "npm:^2.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-whitespace: "npm:^3.0.0" + html-void-elements: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + property-information: "npm:^7.0.0" + space-separated-tokens: "npm:^2.0.0" + stringify-entities: "npm:^4.0.0" + zwitch: "npm:^2.0.4" + checksum: 10c0/b7a08c30bab4371fc9b4a620965c40b270e5ae7a8e94cf885f43b21705179e28c8e43b39c72885d1647965fb3738654e6962eb8b58b0c2a84271655b4d748836 + languageName: node + linkType: hard + "hast-util-to-jsx-runtime@npm:^2.0.0": version: 2.3.6 resolution: "hast-util-to-jsx-runtime@npm:2.3.6" @@ -1801,7 +1923,7 @@ __metadata: languageName: node linkType: hard -"hast-util-to-string@npm:^3.0.0": +"hast-util-to-string@npm:^3.0.0, hast-util-to-string@npm:^3.0.1": version: 3.0.1 resolution: "hast-util-to-string@npm:3.0.1" dependencies: @@ -1828,6 +1950,13 @@ __metadata: languageName: node linkType: hard +"html-void-elements@npm:^3.0.0": + version: 3.0.0 + resolution: "html-void-elements@npm:3.0.0" + checksum: 10c0/a8b9ec5db23b7c8053876dad73a0336183e6162bf6d2677376d8b38d654fdc59ba74fdd12f8812688f7db6fad451210c91b300e472afc0909224e0a44c8610d2 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -3002,11 +3131,30 @@ __metadata: languageName: node linkType: hard +"oniguruma-parser@npm:^0.12.2": + version: 0.12.2 + resolution: "oniguruma-parser@npm:0.12.2" + checksum: 10c0/fe5255d2cd5a6b845d5a0abe1725898ef40cea5522290dba6ccc08cc388891e9e8007af4baa5059942b786ad44b2b677ef25948039c9ba3f057bd35d2c52076a + languageName: node + linkType: hard + +"oniguruma-to-es@npm:^4.3.6": + version: 4.3.6 + resolution: "oniguruma-to-es@npm:4.3.6" + dependencies: + oniguruma-parser: "npm:^0.12.2" + regex: "npm:^6.1.0" + regex-recursion: "npm:^6.0.2" + checksum: 10c0/044e08b98e706987c2882ccf228de2a671de4aff33e3bd370da137ba599c0d520549625ad26ecc8898502445c991e19f106f584e94382262ba2af02c908ca3cf + languageName: node + linkType: hard + "onlyboxes-website@workspace:.": version: 0.0.0-use.local resolution: "onlyboxes-website@workspace:." dependencies: "@mdx-js/rollup": "npm:^3.1.1" + "@shikijs/rehype": "npm:^4.3.1" "@tailwindcss/vite": "npm:^4.2.2" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" @@ -3255,6 +3403,31 @@ __metadata: languageName: node linkType: hard +"regex-recursion@npm:^6.0.2": + version: 6.0.2 + resolution: "regex-recursion@npm:6.0.2" + dependencies: + regex-utilities: "npm:^2.3.0" + checksum: 10c0/68e8b6889680e904b75d7f26edaf70a1a4dc1087406bff53face4c2929d918fd77c72223843fe816ac8ed9964f96b4160650e8d5909e26a998c6e9de324dadb1 + languageName: node + linkType: hard + +"regex-utilities@npm:^2.3.0": + version: 2.3.0 + resolution: "regex-utilities@npm:2.3.0" + checksum: 10c0/78c550a80a0af75223244fff006743922591bd8f61d91fef7c86b9b56cf9bbf8ee5d7adb6d8991b5e304c57c90103fc4818cf1e357b11c6c669b782839bd7893 + languageName: node + linkType: hard + +"regex@npm:^6.1.0": + version: 6.1.0 + resolution: "regex@npm:6.1.0" + dependencies: + regex-utilities: "npm:^2.3.0" + checksum: 10c0/6e0ee2a1c17d5a66dc1120dfc51899dedf6677857e83a0df4d5a822ebb8645a54a079772efc1ade382b67aad35e4e22b5bd2d33c05ed28b0e000f8f57eb0aec1 + languageName: node + linkType: hard + "rehype-recma@npm:^1.0.0": version: 1.0.0 resolution: "rehype-recma@npm:1.0.0" @@ -3591,6 +3764,22 @@ __metadata: languageName: node linkType: hard +"shiki@npm:4.3.1": + version: 4.3.1 + resolution: "shiki@npm:4.3.1" + dependencies: + "@shikijs/core": "npm:4.3.1" + "@shikijs/engine-javascript": "npm:4.3.1" + "@shikijs/engine-oniguruma": "npm:4.3.1" + "@shikijs/langs": "npm:4.3.1" + "@shikijs/themes": "npm:4.3.1" + "@shikijs/types": "npm:4.3.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.4" + checksum: 10c0/b25fbd2fd65a69a680c9be4b38712e3864402dfa08d0343ce1f10de73cb930295d8dd768f7d6857b877f8240f42f1c6771d9c20301007b1eb4dcdb5fb082f6b7 + languageName: node + linkType: hard + "siginfo@npm:^2.0.0": version: 2.0.0 resolution: "siginfo@npm:2.0.0" @@ -3863,7 +4052,7 @@ __metadata: languageName: node linkType: hard -"unified@npm:^11.0.0": +"unified@npm:^11.0.0, unified@npm:^11.0.5": version: 11.0.5 resolution: "unified@npm:11.0.5" dependencies: @@ -3924,7 +4113,7 @@ __metadata: languageName: node linkType: hard -"unist-util-visit@npm:^5.0.0": +"unist-util-visit@npm:^5.0.0, unist-util-visit@npm:^5.1.0": version: 5.1.0 resolution: "unist-util-visit@npm:5.1.0" dependencies: @@ -4216,7 +4405,7 @@ __metadata: languageName: node linkType: hard -"zwitch@npm:^2.0.0": +"zwitch@npm:^2.0.0, zwitch@npm:^2.0.4": version: 2.0.4 resolution: "zwitch@npm:2.0.4" checksum: 10c0/3c7830cdd3378667e058ffdb4cf2bb78ac5711214e2725900873accb23f3dfe5f9e7e5a06dcdc5f29605da976fc45c26d9a13ca334d6eea2245a15e77b8fc06e