@@ -405,10 +519,10 @@ function Choice({ icon: Icon, title, badge, value, detail, selected, onClick }:
);
}
-function Field({ id, label, help, error, children }: { id: string; label: string; help?: string; error?: string | null; children: ReactNode }) {
+function Field({ id, label, help, afterHelp, error, children }: { id: string; label: string; help?: string; afterHelp?: ReactNode; error?: string | null; children: ReactNode }) {
return (
- {help ? {help} : null}
+ {help ? {help} : null}{afterHelp}
{children}
{error ? {error} : null}
diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css
index 67bbee327..a10af5622 100644
--- a/apps/web/src/features/sandbox/sandbox-wizard.css
+++ b/apps/web/src/features/sandbox/sandbox-wizard.css
@@ -164,6 +164,27 @@
width: 100%;
}
+.wizard-key-console {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: 6px;
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 16px;
+ text-decoration: none;
+}
+
+.wizard-key-console:hover {
+ text-decoration: underline;
+}
+
+.wizard-key-console:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
.wizard-group {
display: grid;
gap: 14px;
diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts
index 790eca059..3e6c0a7c2 100644
--- a/apps/web/src/lib/locale-strings.ts
+++ b/apps/web/src/lib/locale-strings.ts
@@ -1,5 +1,18 @@
import { consoleAuthChinese } from "./console-auth-strings";
export const chinese = {
+ "E2B provider": "E2B 服务商",
+ "Other E2B-compatible provider": "其他兼容 E2B 的服务商",
+ "Loading templates…": "正在读取模板…",
+ "Could not load templates. Check the key and provider connection.": "无法读取模板,请检查 Key 和服务商连接。",
+ "No templates are visible to this key.": "这个 Key 下没有可见模板。",
+ "Template": "模板",
+ "Select a template": "选择模板",
+ "Loading ready builds…": "正在读取可用构建…",
+ "Could not load builds.": "无法读取构建。",
+ "This template has no ready builds.": "这个模板没有 ready 构建。",
+ "Select a ready build": "选择 ready 构建",
+ "Core validates the exact ready build again when you save.": "保存时 Core 会再次校验所选的 ready 构建。",
+ "The selected provider supplies a default. You can edit it for a compatible endpoint.": "服务商已带出默认地址,也可以修改为兼容接口。",
...consoleAuthChinese,
"Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.": "托管 Session 将归档且无法恢复,部署配置与节点注册将清除。未保存的工作区内容可能丢失。",
"What reset affects": "重置影响范围",
@@ -312,6 +325,11 @@ export const chinese = {
"Own machines": "自有机器",
"Core creates E2B sandboxes directly. No node enrollment is needed.": "Core 直接创建 E2B 沙箱,无需注册节点。",
"E2B API key": "E2B API key",
+ "Console → API Keys": "控制台 → API Keys",
+ "Sandbox API URL": "沙箱 API 地址",
+ "Sandbox data-plane domain": "沙箱数据面域名",
+ "Leave both endpoint fields blank for official E2B. A compatible service needs its HTTPS API origin and data-plane domain.": "留空使用官方 E2B。兼容服务需同时填写 HTTPS API 地址和数据面域名。",
+ "Enter both a public HTTPS API origin and a domain.": "请同时填写公网 HTTPS API 地址和域名。",
"The key is write-only. It is cleared from this form after submission and never saved in browser storage.": "密钥仅供写入,提交后会从表单清除,不会保存在浏览器存储中。",
"Enter a template ID and build UUID separated by a colon.": "请输入模板 ID 和构建 UUID,并用冒号分隔。",
"Immutable Runtime template": "固定版本 Runtime 模板",
diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml
index 0dc04107e..daa3e87de 100644
--- a/contracts/agents-api/core.openapi.yaml
+++ b/contracts/agents-api/core.openapi.yaml
@@ -548,10 +548,34 @@ definitions:
required:
- expected_generation
type: object
+ api.SandboxE2BDiscoveryInput:
+ properties:
+ api_key:
+ type: string
+ api_url:
+ type: string
+ domain:
+ type: string
+ type: object
+ api.SandboxE2BDiscoveryResult:
+ properties:
+ builds:
+ items:
+ $ref: '#/definitions/e2b.ReadyBuild'
+ type: array
+ templates:
+ items:
+ $ref: '#/definitions/e2b.TemplateSummary'
+ type: array
+ type: object
api.SandboxE2BInput:
properties:
api_key:
type: string
+ api_url:
+ type: string
+ domain:
+ type: string
template:
type: string
type: object
@@ -866,6 +890,24 @@ definitions:
service:
$ref: '#/definitions/coremetrics.ServiceState'
type: object
+ e2b.ReadyBuild:
+ properties:
+ cpus:
+ type: integer
+ id:
+ type: string
+ memory_mib:
+ type: integer
+ type: object
+ e2b.TemplateSummary:
+ properties:
+ id:
+ type: string
+ names:
+ items:
+ type: string
+ type: array
+ type: object
sandbox.DeploymentSpec:
properties:
resources:
@@ -1344,8 +1386,12 @@ definitions:
type: object
store.SandboxE2BView:
properties:
+ api_url:
+ type: string
credential_configured:
type: boolean
+ domain:
+ type: string
template:
type: string
template_build:
@@ -6386,6 +6432,83 @@ paths:
summary: Start or escalate a durable sandbox deployment reset
tags:
- Sandbox Manager
+ /core/v1/sandbox/e2b/templates:
+ post:
+ consumes:
+ - application/json
+ description: Core key only. Uses a transient E2B credential and endpoint through the pinned SDK helper; returns safe template metadata. Does not save the credential or allocate compute.
+ parameters:
+ - description: Transient E2B connection
+ in: body
+ name: body
+ required: true
+ schema:
+ $ref: '#/definitions/api.SandboxE2BDiscoveryInput'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.SandboxE2BDiscoveryResult'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ "503":
+ description: Service Unavailable
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ security:
+ - DeploymentAdminAuth: []
+ summary: List templates visible to an E2B credential
+ tags:
+ - Sandbox Manager
+ /core/v1/sandbox/e2b/templates/{template_id}/builds:
+ post:
+ consumes:
+ - application/json
+ description: Core key only. Reads one template through the pinned SDK helper with a transient E2B credential. Returns ready builds only, without allocating compute.
+ parameters:
+ - description: Template ID
+ in: path
+ name: template_id
+ required: true
+ type: string
+ - description: Transient E2B connection
+ in: body
+ name: body
+ required: true
+ schema:
+ $ref: '#/definitions/api.SandboxE2BDiscoveryInput'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.SandboxE2BDiscoveryResult'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ "503":
+ description: Service Unavailable
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ security:
+ - DeploymentAdminAuth: []
+ summary: List ready builds for an E2B template
+ tags:
+ - Sandbox Manager
/core/v1/sandbox/enrollment-tokens:
post:
consumes:
diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md
index b22233ea9..ca2fd3c1d 100644
--- a/contracts/agents-api/sandbox-deployment.md
+++ b/contracts/agents-api/sandbox-deployment.md
@@ -24,8 +24,21 @@ for the operator workflow. Generated schemas cover the
| `PUT /core/v1/sandbox/deployment` | Core key | Advance the same-provider target online while retaining existing ownership |
| `POST /core/v1/sandbox/deployment/reset` | Core key | Start or escalate a durable hosted clear |
| `DELETE /core/v1/sandbox/deployment/reset?expected_generation=N` | Core key | Cancel the remaining clear without restoring archived work |
+| `POST /core/v1/sandbox/e2b/templates` | Core Web server or operator script with Core key | List up to 200 templates visible to a transient E2B credential |
+| `POST /core/v1/sandbox/e2b/templates/{template_id}/builds` | Core Web server or operator script with Core key | List up to 200 ready builds for one selected template |
| `GET /api/v1/sandbox-node/configuration` | Enrollment token or retained node credential | Read the active node installation configuration without consuming enrollment |
+Template discovery posts `{ "api_key": "...", "api_url": "https://sandbox.sandbase.ai", "domain": "sandbox.sandbase.ai" }`.
+The official E2B endpoint may omit both endpoint fields. The first response is
+`{ "templates": [{ "id": "...", "names": ["..."] }] }`;
+the second is `{ "builds": [{ "id": "build-uuid", "cpus": 2, "memory_mib": 2048 }] }`.
+Both lists may be empty. The Core key authenticates the caller; the E2B key is
+used only for this request, is never stored by discovery, and is never returned.
+Discovery uses the pinned SDK helper and its `GET /v2/templates` operation,
+makes no allocation, and is capped at 200
+results. A limit or provider failure returns 503 with a generic message. The
+deployment write separately validates the selected exact ready build.
+
The paired console injects the Core key server-side on every signed-in `/core/v1`
request. The browser never receives that key. Node configuration
and enrollment are machine connection routes under `/api/v1`, which the reverse
@@ -74,7 +87,7 @@ resource and same-selection conditions, including an identical old request body.
| `provider` | Exactly one of `docker`, `microsandbox`, `e2b` |
| `resources` | Per-sandbox resource limits described below; required for Docker/microsandbox, optional for E2B |
| `runtime` | Required immutable distribution identity for Docker/microsandbox; absent for E2B |
-| `e2b` | Required only for E2B: immutable `template` build selector; write-only `api_key` required on POST, optional on same-provider PUT |
+| `e2b` | Required only for E2B: immutable `template` build selector; write-only `api_key` required on POST, optional on same-provider PUT; optional paired `api_url` and `domain` selectors |
The request has no Core address. Core derives the deployment's `core_url` from the
installation public URL (`public_url` in `config.json`, `OAC_PUBLIC_URL` for
@@ -148,8 +161,15 @@ before registration and retains the exact local image identity it imports.
E2B instead uses `e2b.template` in `template-id:build-uuid` form. The build UUID must
be canonical and nonzero; a mutable template alias alone is insufficient. Omit
`runtime`. The API key is encrypted in PostgreSQL and never returned in a safe
-view, bootstrap configuration, command argument or log. Same-team key or build
+view, bootstrap configuration, command argument or log. Same-team key, build or endpoint
changes apply online while old sandboxes retain their original specification.
+By default Core uses `https://api.e2b.app` and `e2b.app`. For a compatible
+service, set both `e2b.api_url` (HTTPS API origin, with no path, port, query,
+fragment or credentials) and `e2b.domain` (sandbox data-plane DNS suffix).
+The API host must equal the data-plane domain or be its subdomain. Core rejects
+a sandbox response whose data-plane domain lies outside the selected suffix
+before sending daemon credentials or using envd. Existing sandboxes retain
+their original endpoint and credential across online changes.
## Safe response
@@ -157,8 +177,8 @@ GET and successful mutations return `installation_id`, `provider`, `core_url`
(read-only: the installation public URL, present before configuration), `mode`,
`generation`, `owner_epoch`, `reset`, `rollout`, `suspension` and resource
accounting. A configured deployment also returns `specification` and
-`specification_digest`. E2B returns only `e2b.template`,
-`e2b.credential_configured` and `e2b.template_build`; the `e2b` object is absent
+`specification_digest`. E2B returns `e2b.template`, `e2b.api_url`,
+`e2b.domain`, `e2b.credential_configured` and `e2b.template_build`; the `e2b` object is absent
for Docker and microsandbox.
`e2b.template_build` is `{status, resources: {cpus, memory_mib, root_disk_mib}}`:
@@ -222,7 +242,7 @@ Initial setup requires the selected template to appear in the credential's team-
template listing; public readability alone is insufficient. Before an online change,
Core verifies that the committed key owns the current template, then requires the
candidate key to own that exact template as a shared ownership anchor. It also reads
-the candidate and every retained build with the candidate key and confirms each
+the candidate and every retained build at its original endpoint with the candidate key and confirms each
settled live receipt in the installation-labelled sandbox listing.
A legacy public-template selection without this ownership anchor, or a committed key
@@ -245,11 +265,11 @@ revoke the previous key in E2B. Template/resource changes do not drain lifecycle
### Generation ownership and rollout
`runtime_deployment` owns the current specification. Superseded rows contain only
-immutable specification/build metadata, never another E2B credential. An E2B
+immutable specification/build/endpoint metadata, never another E2B credential. An E2B
allocation binds its generation at reservation. Node placements bind at Session
admission and allocations copy that generation, even after repeated updates.
Inspection, renewal, command execution and cleanup route the allocation's original
-specification with the current credential; a missing generation never falls back
+specification and endpoint with the current credential; a missing generation never falls back
to the current specification. Released historical generation identifiers remain.
Retain a generation while it is current, referenced by an unreleased allocation or
diff --git a/deploy/install/install.py b/deploy/install/install.py
index 5c38eb540..73aca17f7 100644
--- a/deploy/install/install.py
+++ b/deploy/install/install.py
@@ -144,6 +144,8 @@ def arguments(argv=None):
help="With --sandbox docker: accept its weaker isolation without asking")
parser.add_argument("--e2b-api-key-file", type=Path, help="With --sandbox e2b: private file containing the E2B API key")
parser.add_argument("--e2b-template", help="With --sandbox e2b: the ready template build, template-id:build-uuid")
+ parser.add_argument("--e2b-api-url", help="With --sandbox e2b: compatible service HTTPS API origin")
+ parser.add_argument("--e2b-domain", help="With --sandbox e2b: compatible service data-plane domain")
parser.add_argument("--sandbox-provider", nargs="?", const=True, help=argparse.SUPPRESS)
parser.add_argument("--provider", nargs="?", const=True, help=argparse.SUPPRESS)
parser.add_argument("--install-dir", type=Path)
@@ -205,10 +207,12 @@ def check_flags(args, document):
raise InstallError("--accept-docker-risks requires --sandbox docker")
if choice == "e2b" and not (args.e2b_api_key_file and args.e2b_template):
raise InstallError("--sandbox e2b requires --e2b-api-key-file and --e2b-template")
- if choice != "e2b" and (args.e2b_api_key_file or args.e2b_template):
- raise InstallError("--e2b-api-key-file and --e2b-template require --sandbox e2b")
+ if choice != "e2b" and (args.e2b_api_key_file or args.e2b_template or args.e2b_api_url or args.e2b_domain):
+ raise InstallError("E2B flags require --sandbox e2b")
if choice == "e2b" and not sandbox_setup.e2b_template(args.e2b_template):
raise InstallError("--e2b-template must name a template build as template-id:build-uuid")
+ if choice == "e2b" and not sandbox_setup.e2b_endpoint(args.e2b_api_url, args.e2b_domain):
+ raise InstallError("--e2b-api-url and --e2b-domain must both name public HTTPS compatible-service endpoints")
if mode == "web-only" and native:
raise InstallError("--web-only cannot install native Core")
if mode == "web-only" and not args.core_key_file:
@@ -622,7 +626,8 @@ def prepare_fresh(args):
if oac_cli.core_installation(config["web"]["core_url"], key)[0] == 404:
raise InstallError("The paired Core version is not supported; preserve its data and reinstall "
"the current release separately. Nothing was changed.")
- e2b = ({"api_key": read_private_file(args.e2b_api_key_file, "E2B API key file"), "template": args.e2b_template}
+ e2b = ({"api_key": read_private_file(args.e2b_api_key_file, "E2B API key file"), "template": args.e2b_template,
+ **({"api_url": args.e2b_api_url, "domain": args.e2b_domain} if args.e2b_api_url else {})}
if choice == "e2b" else None)
if choice == "docker":
confirm_docker(args.accept_docker_risks)
diff --git a/deploy/install/sandbox_setup.py b/deploy/install/sandbox_setup.py
index f417e67a8..9429567f8 100644
--- a/deploy/install/sandbox_setup.py
+++ b/deploy/install/sandbox_setup.py
@@ -7,8 +7,10 @@
"""
import json
import http.client
+import ipaddress
import re
import urllib.error
+import urllib.parse
import urllib.request
import uuid
@@ -87,6 +89,37 @@ def e2b_template(value):
and exact.int != 0 and str(exact) == build)
+def e2b_endpoint(api_url, domain):
+ """The two explicit SDK selectors, or neither for official E2B."""
+ if api_url is None and domain is None:
+ return True
+ if not api_url or not domain or len(api_url) > 512 or len(domain) > 253:
+ return False
+ def public_name(host):
+ labels = host.split(".")
+ try:
+ ipaddress.ip_address(host)
+ return False
+ except ValueError:
+ pass
+ return (len(host) <= 253 and len(labels) >= 2 and
+ not host.endswith((".localhost", ".local")) and
+ all(0 < len(label) <= 63 and
+ re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", label)
+ for label in labels))
+ if not public_name(domain):
+ return False
+ try:
+ parsed = urllib.parse.urlsplit(api_url)
+ return (parsed.scheme == "https" and parsed.hostname == parsed.netloc and
+ public_name(parsed.hostname or "") and
+ (parsed.hostname == domain or parsed.hostname.endswith("." + domain)) and
+ not parsed.path and not parsed.query and not parsed.fragment and
+ parsed.geturl() == api_url)
+ except ValueError:
+ return False
+
+
def selection(bundle, manifest, choice, e2b=None):
"""The request Web's setup sends for this backend."""
if choice == "e2b":
diff --git a/deploy/install/test_sandbox_setup.py b/deploy/install/test_sandbox_setup.py
index ad1e37bab..9d1d7b5b7 100644
--- a/deploy/install/test_sandbox_setup.py
+++ b/deploy/install/test_sandbox_setup.py
@@ -12,6 +12,18 @@
class SandboxSetupTests(unittest.TestCase):
+ def test_e2b_endpoint_pair(self):
+ for api_url, domain in ((None, None), ("https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai")):
+ self.assertTrue(sandbox_setup.e2b_endpoint(api_url, domain))
+ for api_url, domain in (("https://sandbox-test.sandbase.ai", None),
+ ("http://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai"),
+ ("https://sandbox-test.sandbase.ai/path", "sandbox-test.sandbase.ai"),
+ ("https://localhost", "sandbox-test.sandbase.ai"),
+ ("https://127.0.0.1", "sandbox-test.sandbase.ai"),
+ ("https://unrelated.example", "sandbox-test.sandbase.ai"),
+ ("https://good.example", "-bad.example")):
+ self.assertFalse(sandbox_setup.e2b_endpoint(api_url, domain))
+
def setUp(self):
base = Path.home() / ".oac/tests/sandbox-setup"
base.mkdir(parents=True, exist_ok=True)
diff --git a/docs/api/README.md b/docs/api/README.md
index 0c6ddd1d6..7df608a6c 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -53,7 +53,7 @@ with the Core key.
| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](../../contracts/agents-api/environment-executor-credentials.md) |
| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](../../contracts/agents-api/installation.md) |
| `metrics` | Core's own process metrics | [Core metrics](../../contracts/agents-api/core-metrics.md) |
-| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](../../contracts/agents-api/node-host-history.md) |
+| `sandbox/deployment[/reset]`, `sandbox/e2b/templates[/{template_id}/builds]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Core Web server or operator scripts with the Core key: sandbox deployment, read-only E2B template discovery, node enrollment tokens and nodes | [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](../../contracts/agents-api/node-host-history.md) |
| `harnesses`, `harnesses/{harness}/model-configuration` | Supported harnesses and each harness's deployment default model configuration (write-only provider key) | [Model execution](../../contracts/agents-api/model-execution.md#deployment-defaults) |
## Machine connection API
diff --git a/docs/api/web-management.md b/docs/api/web-management.md
index 1ad542b22..9b6759cf0 100644
--- a/docs/api/web-management.md
+++ b/docs/api/web-management.md
@@ -92,7 +92,11 @@ require the observed `expected_generation`, including zero at first setup. Reque
Response `resources.allocations` and `resources.pending` are cleanup counts, not
CPU, memory or disk settings. E2B accepts a write-only key and exact template build
instead of a node Runtime release, and provisions without a node installation. E2B
-may omit `resources` to adopt the validated build's CPU and memory; responses show
+may omit `resources` to adopt the validated build's CPU and memory. An E2B-compatible
+service may also supply paired `e2b.api_url` and `e2b.domain`; omitted selectors
+use official E2B. Responses expose these addresses but never the key, and changing
+them requires the same drained maintenance transition as changing the template.
+Responses show
the build as read at selection time in `e2b.template_build`. Microsandbox responses
return its idle `suspension` policy; other providers return null.
diff --git a/docs/configuration.md b/docs/configuration.md
index bdeede903..6e98c1c58 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -157,7 +157,11 @@ file. The database owns the saved values.
E2B takes no `runtime`, and Web sends no resources for it: Core adopts the CPU and
memory of the ready template build `template-id:build-uuid`, and supplied values must
-match it. Docker has no separate disk quota.
+match it. For an E2B-compatible service, enter both its HTTPS API origin and
+sandbox data-plane domain in the setup wizard. Leaving both blank uses official
+E2B. The API host must be the data-plane domain or one of its subdomains.
+Changing either address requires draining the deployment in maintenance.
+Docker has no separate disk quota.
Changing backend type or E2B team requires an explicit reset and a new setup.
Same-team E2B template, resource and key changes apply online through the Core API:
diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md
index 0559cbef4..42e7a02ff 100644
--- a/docs/getting-started/install-options.md
+++ b/docs/getting-started/install-options.md
@@ -58,6 +58,8 @@ create another process configuration file.
| `--accept-docker-risks` | Accept Docker's weaker isolation without an interactive prompt |
| `--e2b-api-key-file FILE` | With E2B: absolute path to a private key file, no group/other access and at most 4 KiB |
| `--e2b-template ID:BUILD` | With E2B: ready template build as `template-id:build-uuid` |
+| `--e2b-api-url URL` | With E2B: compatible service HTTPS API origin; use together with `--e2b-domain` |
+| `--e2b-domain DOMAIN` | With E2B: sandbox data-plane DNS suffix; use together with `--e2b-api-url` |
| `--core-key-file FILE` | With Web-only: absolute path to a private file containing the existing Core key, at least 32 characters |
See [deployment modes](install.md#modes), [native Core](install.md#native-core) and
diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts
index a69e877ec..1c4e98186 100644
--- a/packages/agents-client/src/sandbox-client.test.ts
+++ b/packages/agents-client/src/sandbox-client.test.ts
@@ -51,7 +51,7 @@ const microsandbox = {
/** An E2B selection saved before Core recorded its template build. */
const e2bDeployment = {
...docker, provider: "e2b", rollout: unconfigured.rollout, mode: "direct", specification: { resources: { cpus: 2, memory_mib: 2048 } },
- e2b: { template: "runtime:00000000-0000-0000-0000-000000000001", credential_configured: true, template_build: { status: null, resources: { cpus: null, memory_mib: null, root_disk_mib: null } } },
+ e2b: { template: "runtime:00000000-0000-0000-0000-000000000001", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: { status: null, resources: { cpus: null, memory_mib: null, root_disk_mib: null } } },
};
const reads: Record
Promise> = {
nodes: (client) => client.listNodes(),
@@ -110,6 +110,26 @@ describe("strict sandbox administration projections", () => {
});
describe("Core sandbox credential boundaries", () => {
+ it("uses Core-key routes for transient template discovery and projects safe fields", async () => {
+ const fetch = vi.fn()
+ .mockResolvedValueOnce(response({ templates: [{ id: "tpl_123", names: ["runtime"] }] }))
+ .mockResolvedValueOnce(response({ builds: [{ id: "00000000-0000-0000-0000-000000000001", cpus: 2, memory_mib: 2048 }] }));
+ const client = new SandboxAdminClient({ fetch });
+ const input = { api_key: "private-test-key", api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" };
+ expect(await client.listE2BTemplates(input)).toHaveLength(1);
+ expect(await client.listE2BReadyBuilds("tpl_123", input)).toHaveLength(1);
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/core/v1/sandbox/e2b/templates", "/core/v1/sandbox/e2b/templates/tpl_123/builds"]);
+ expect(fetch.mock.calls.every(([, init]) => init?.method === "POST" && !new Headers(init?.headers).has("Authorization") && String(init?.body).includes("private-test-key"))).toBe(true);
+ });
+
+ it("rejects extra fields in credentialed discovery responses", async () => {
+ const input = { api_key: "private-test-key" };
+ const templates = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response({ templates: [], builds: [] })) });
+ const builds = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response({ builds: [], templates: [] })) });
+ await expect(templates.listE2BTemplates(input)).rejects.toMatchObject({ code: "invalid_admin_response" });
+ await expect(builds.listE2BReadyBuilds("tpl_123", input)).rejects.toMatchObject({ code: "invalid_admin_response" });
+ });
+
it("defaults to /core/v1/sandbox and is not a /v1 client", async () => {
const fetch = vi.fn().mockImplementation(async () => response({ data: [] }));
const client = new SandboxAdminClient({ fetch });
@@ -214,7 +234,7 @@ describe("hosted provider configuration", () => {
it("writes E2B configuration and generation without beta headers or browser credentials", async () => {
const deployment = {
...e2bDeployment, generation: 2, resources: { allocations: 0, pending: 0 },
- e2b: { template: e2b.template, credential_configured: true, template_build: { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 24063 } } },
+ e2b: { template: e2b.template, api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 24063 } } },
};
const fetch = vi.fn().mockImplementation(async () => response(deployment));
const client = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", fetch });
diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts
index 7118571a9..241a87138 100644
--- a/packages/agents-client/src/sandbox-client.ts
+++ b/packages/agents-client/src/sandbox-client.ts
@@ -30,11 +30,11 @@ export interface InitializeSandboxDeployment {
resources?: SandboxResources;
/** Required for Docker/microsandbox; E2B uses its fixed template build. */
runtime?: SandboxRuntimeRelease;
- e2b?: { api_key: string; template: string };
+ e2b?: { api_key: string; template: string; api_url?: string; domain?: string };
}
export interface UpdateSandboxDeployment extends Omit {
/** Omit api_key to preserve the current key. Supplying it, even unchanged, verifies and replaces it once. */
- e2b?: { api_key?: string; template: string };
+ e2b?: { api_key?: string; template: string; api_url?: string; domain?: string };
}
export interface SandboxRollout {
/** Poll at high frequency only while preparing, independently of old Session retention. */
@@ -74,7 +74,7 @@ export interface SandboxDeployment {
generation: number;
mode: "nodes" | "direct" | "";
resources: { allocations: number; pending: number };
- e2b?: { template: string; credential_configured: boolean; template_build: SandboxE2BTemplateBuild };
+ e2b?: { template: string; api_url: string; domain: string; credential_configured: boolean; template_build: SandboxE2BTemplateBuild };
/** Idle suspension policy; microsandbox only, otherwise null. */
suspension: { idle_seconds: number; retention_seconds: number } | null;
}
@@ -83,6 +83,9 @@ export interface SandboxE2BTemplateBuild {
status: string | null;
resources: { cpus: number | null; memory_mib: number | null; root_disk_mib: number | null };
}
+export interface SandboxE2BDiscoveryInput { api_key: string; api_url?: string; domain?: string }
+export interface SandboxE2BTemplate { id: string; names: string[] }
+export interface SandboxE2BReadyBuild { id: string; cpus: number; memory_mib: number }
export interface SandboxNode {
rollout: SandboxNodeRollout;
id: string;
@@ -196,13 +199,16 @@ function projectSpecification(value: unknown): SandboxSpecification {
}
/** The safe E2B view; it has no key member. */
function projectE2B(value: unknown): NonNullable {
- const e2b = members(value, ["template", "credential_configured", "template_build"]);
+ const e2b = members(value, ["template", "credential_configured", "template_build"], ["api_url", "domain"]);
const build = members(e2b.template_build, ["status", "resources"]);
const resources = members(build.resources, ["cpus", "memory_mib", "root_disk_mib"]);
- valid(typeof e2b.template === "string" && typeof e2b.credential_configured === "boolean" && (build.status === null || typeof build.status === "string") &&
+ valid(typeof e2b.template === "string" && typeof e2b.credential_configured === "boolean" &&
+ (!hasOwn(e2b, "api_url") || typeof e2b.api_url === "string") && (!hasOwn(e2b, "domain") || typeof e2b.domain === "string") &&
+ (hasOwn(e2b, "api_url") === hasOwn(e2b, "domain")) && (build.status === null || typeof build.status === "string") &&
Object.values(resources).every(nullable(isNonnegativeInteger)));
return {
- template: e2b.template as string, credential_configured: e2b.credential_configured as boolean,
+ template: e2b.template as string, api_url: (e2b.api_url as string | undefined) ?? "https://api.e2b.app",
+ domain: (e2b.domain as string | undefined) ?? "e2b.app", credential_configured: e2b.credential_configured as boolean,
template_build: { status: build.status as string | null, resources: { ...resources } as SandboxE2BTemplateBuild["resources"] },
};
}
@@ -316,6 +322,23 @@ export class SandboxAdminClient {
this.#core = new CoreRequester(options.baseUrl ?? "/core/v1/sandbox", options.token, options.fetch, invalidSandboxResponse);
}
+ async listE2BTemplates(input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise {
+ const value = await this.#core.json("/e2b/templates", options, "POST", input);
+ if (!isRecord(value) || !onlyFields(value, new Set(["templates"])) || !Array.isArray(value.templates) || value.templates.length > 200) invalidSandboxResponse();
+ return value.templates.map((item) => {
+ if (!isRecord(item) || !onlyFields(item, new Set(["id", "names"])) || typeof item.id !== "string" || !Array.isArray(item.names) || !item.names.every((name) => typeof name === "string")) invalidSandboxResponse();
+ return { id: item.id, names: item.names };
+ });
+ }
+ async listE2BReadyBuilds(templateId: string, input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise {
+ const value = await this.#core.json(`/e2b/templates/${encodeURIComponent(templateId)}/builds`, options, "POST", input);
+ if (!isRecord(value) || !onlyFields(value, new Set(["builds"])) || !Array.isArray(value.builds) || value.builds.length > 200) invalidSandboxResponse();
+ return value.builds.map((item) => {
+ if (!isRecord(item) || !onlyFields(item, new Set(["id", "cpus", "memory_mib"])) || typeof item.id !== "string" || !isNonnegativeInteger(item.cpus) || !isNonnegativeInteger(item.memory_mib)) invalidSandboxResponse();
+ return { id: item.id, cpus: item.cpus, memory_mib: item.memory_mib };
+ });
+ }
+
#json(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise {
return this.#core.json(path, options, method, body) as Promise;
}
diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go
index 8fbbad997..0f284dbdc 100644
--- a/services/agents-api/cmd/server/main.go
+++ b/services/agents-api/cmd/server/main.go
@@ -43,6 +43,7 @@ import (
historystoreresolver "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory/storeresolver"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs"
observationstoreresolver "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs/storeresolver"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -185,6 +186,17 @@ func run() error {
options := []api.Option{api.WithCoreMetrics(metrics), api.WithSubagents(executionStore), api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore), api.WithRuntimeObservations(observationService)}
if managedNodes != nil {
options = append(options, api.WithSandboxManager(executionStore, managedNodes.admin))
+ options = append(options, api.WithSandboxE2BDiscovery(func(ctx context.Context, input api.SandboxE2BDiscoveryInput, template string) (api.SandboxE2BDiscoveryResult, error) {
+ binary := os.Getenv("OAC_E2B_PROVIDER_BIN")
+ if binary == "" {
+ binary = "/opt/oac/e2b/oac-e2b-provider"
+ }
+ out, err := e2b.Discover(ctx, &e2b.ProcessCaller{}, binary, input.APIKey, input.APIURL, input.Domain, template)
+ if err != nil {
+ return api.SandboxE2BDiscoveryResult{}, err
+ }
+ return api.SandboxE2BDiscoveryResult{Templates: out.Templates, Builds: out.Builds}, nil
+ }))
}
var keyAdmin *api.DeploymentAuthenticator
if managedNodes != nil {
diff --git a/services/agents-api/cmd/server/managed_generations.go b/services/agents-api/cmd/server/managed_generations.go
index ff978435d..030584841 100644
--- a/services/agents-api/cmd/server/managed_generations.go
+++ b/services/agents-api/cmd/server/managed_generations.go
@@ -138,7 +138,7 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo
return err
}
withCandidateKey := func(value store.SandboxSetup, refs []sandbox.Reference) error {
- value.E2B = &store.SandboxE2BConfiguration{APIKey: setup.E2B.APIKey, Template: value.E2B.Template}
+ value.E2B = &store.SandboxE2BConfiguration{APIKey: setup.E2B.APIKey, Template: value.E2B.Template, APIURL: value.E2B.APIURL, Domain: value.E2B.Domain}
return verify(value, refs)
}
if err := withCandidateKey(current, nil); err != nil {
@@ -147,12 +147,14 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo
if err := verify(setup, nil); err != nil {
return err
}
+ generations := map[uint64]store.SandboxSetup{current.Generation: current}
for after := int64(-1); ; {
page, err := db.SandboxGenerationPage(ctx, after)
if err != nil {
return err
}
for _, g := range page {
+ generations[g.Generation] = g
if err := withCandidateKey(g, nil); err != nil {
return err
}
@@ -167,13 +169,17 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo
if err != nil {
return err
}
- refs := make([]sandbox.Reference, 0, len(page))
+ refsByGeneration := make(map[uint64][]sandbox.Reference)
for _, a := range page {
- refs = append(refs, sandbox.Reference{TenantID: a.TenantID, EnvironmentID: a.EnvironmentID, AllocationID: a.ID})
+ refsByGeneration[a.DeploymentGeneration] = append(refsByGeneration[a.DeploymentGeneration], sandbox.Reference{TenantID: a.TenantID, EnvironmentID: a.EnvironmentID, AllocationID: a.ID})
after = a.ID
}
- if len(refs) > 0 {
- if err := verify(setup, refs); err != nil {
+ for generation, refs := range refsByGeneration {
+ owner, ok := generations[generation]
+ if !ok {
+ return e2b.ErrRequestUnconfirmed
+ }
+ if err := withCandidateKey(owner, refs); err != nil {
return err
}
}
@@ -186,31 +192,8 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo
return candidate, nil
}
-// Metrics do not qualify a sandbox against the current build. The helper reads
-// only labelled receipts and provider metrics, so one batch can span generations.
-func (p *e2bGenerationRouter) ObserveBatch(ctx context.Context, targets []runtimeobs.Target) ([]runtimeobs.BatchResult, bool) {
- result := make([]runtimeobs.BatchResult, len(targets))
- failed := func(err error) ([]runtimeobs.BatchResult, bool) {
- for i := range result {
- result[i].Err = err
- }
- return result, true
- }
- if len(targets) > runtimeobs.MaxBatchTargets {
- return failed(sandbox.ErrInvalid)
- }
- release, err := p.setup.e2bCalls.Enter(ctx)
- if err != nil {
- return failed(err)
- }
- defer release()
- setup, err := p.store.GetSandboxSetup(ctx)
- if err != nil {
- return failed(err)
- }
- provider, err := p.setup.provider(setup)
- if err != nil {
- return failed(err)
- }
- return provider.(*e2b.Provider).ObserveBatch(ctx, targets)
+// A batch can contain allocations from different endpoint generations. Let the
+// observation worker route each allocation through its immutable generation.
+func (p *e2bGenerationRouter) ObserveBatch(_ context.Context, _ []runtimeobs.Target) ([]runtimeobs.BatchResult, bool) {
+ return nil, false
}
diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/agents-api/cmd/server/managed_setup.go
index 19b0399c6..940150bcf 100644
--- a/services/agents-api/cmd/server/managed_setup.go
+++ b/services/agents-api/cmd/server/managed_setup.go
@@ -198,7 +198,8 @@ func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.SandboxProvid
resources = &setup.Specification.Resources
}
provider, err := e2b.NewWithCaller(e2b.Config{Binary: binary, StateDir: os.Getenv("OAC_E2B_STATE_DIR"),
- Resources: resources, InstallationID: setup.InstallationID, APIKey: setup.E2B.APIKey, Template: setup.E2B.Template, TimeoutSeconds: 3600}, &e2b.ProcessCaller{Fence: &s.e2bCalls})
+ Resources: resources, InstallationID: setup.InstallationID, APIKey: setup.E2B.APIKey, Template: setup.E2B.Template,
+ APIURL: setup.E2B.APIURL, Domain: setup.E2B.Domain, TimeoutSeconds: 3600}, &e2b.ProcessCaller{Fence: &s.e2bCalls})
if err != nil {
return nil, errors.New("E2B provider cannot load; check the installed helper and private state directory")
}
diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go
index 8b59150de..25ffe127e 100644
--- a/services/agents-api/internal/api/handler.go
+++ b/services/agents-api/internal/api/handler.go
@@ -45,6 +45,7 @@ type Handler struct {
sandboxStore *store.Store
deploymentAuth *DeploymentAuthenticator
sandboxSetup func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error)
+ sandboxE2BDiscover func(context.Context, SandboxE2BDiscoveryInput, string) (SandboxE2BDiscoveryResult, error)
sandboxUpdate func(context.Context, store.SandboxDeploymentUpdateRequest) (store.RuntimeDeploymentView, error)
sandboxReset func(context.Context, store.SandboxResetRequest) (store.RuntimeDeploymentView, error)
sandboxResetCancel func(context.Context, uint64) (store.RuntimeDeploymentView, error)
diff --git a/services/agents-api/internal/api/sandbox_deployment_setup.go b/services/agents-api/internal/api/sandbox_deployment_setup.go
index f547d431e..ffa0a91e2 100644
--- a/services/agents-api/internal/api/sandbox_deployment_setup.go
+++ b/services/agents-api/internal/api/sandbox_deployment_setup.go
@@ -15,6 +15,8 @@ import (
// store's separate deployment view and never serialize this request.
type SandboxE2BInput struct {
APIKey *string `json:"api_key,omitempty"`
+ APIURL string `json:"api_url,omitempty"`
+ Domain string `json:"domain,omitempty"`
Template string `json:"template"`
}
@@ -35,7 +37,7 @@ type SandboxDeploymentChangeInput struct {
func (v SandboxDeploymentInput) request() store.SandboxDeploymentSetupRequest {
input := store.SandboxDeploymentSetupRequest{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}}
if v.E2B != nil {
- input.E2B = &store.SandboxE2BConfiguration{Template: v.E2B.Template}
+ input.E2B = &store.SandboxE2BConfiguration{Template: v.E2B.Template, APIURL: v.E2B.APIURL, Domain: v.E2B.Domain}
if v.E2B.APIKey != nil {
input.E2B.APIKey = *v.E2B.APIKey
input.E2B.ReplaceCredential = true
diff --git a/services/agents-api/internal/api/sandbox_e2b_discovery.go b/services/agents-api/internal/api/sandbox_e2b_discovery.go
new file mode 100644
index 000000000..577852b67
--- /dev/null
+++ b/services/agents-api/internal/api/sandbox_e2b_discovery.go
@@ -0,0 +1,85 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
+ "github.com/go-chi/chi/v5"
+)
+
+// Discovery credentials are transient and never included in the response.
+type SandboxE2BDiscoveryInput struct {
+ APIKey string `json:"api_key"`
+ APIURL string `json:"api_url,omitempty"`
+ Domain string `json:"domain,omitempty"`
+}
+type SandboxE2BDiscoveryResult struct {
+ Templates []e2b.TemplateSummary `json:"templates"`
+ Builds []e2b.ReadyBuild `json:"builds"`
+}
+
+func WithSandboxE2BDiscovery(discover func(context.Context, SandboxE2BDiscoveryInput, string) (SandboxE2BDiscoveryResult, error)) Option {
+ return func(h *Handler) { h.sandboxE2BDiscover = discover }
+}
+
+// @Summary List templates visible to an E2B credential
+// @Description Core key only. Uses a transient E2B credential and endpoint through the pinned SDK helper; returns safe template metadata. Does not save the credential or allocate compute.
+// @Tags Sandbox Manager
+// @Accept json
+// @Produce json
+// @Security DeploymentAdminAuth
+// @Param body body api.SandboxE2BDiscoveryInput true "Transient E2B connection"
+// @Success 200 {object} api.SandboxE2BDiscoveryResult
+// @Failure 400,401,503 {object} CoreErrorResponse
+// @Router /core/v1/sandbox/e2b/templates [post]
+func (h *Handler) discoverSandboxE2BTemplates(w http.ResponseWriter, r *http.Request) {
+ h.discoverSandboxE2B(w, r, "")
+}
+
+// @Summary List ready builds for an E2B template
+// @Description Core key only. Reads one template through the pinned SDK helper with a transient E2B credential. Returns ready builds only, without allocating compute.
+// @Tags Sandbox Manager
+// @Accept json
+// @Produce json
+// @Security DeploymentAdminAuth
+// @Param template_id path string true "Template ID"
+// @Param body body api.SandboxE2BDiscoveryInput true "Transient E2B connection"
+// @Success 200 {object} api.SandboxE2BDiscoveryResult
+// @Failure 400,401,503 {object} CoreErrorResponse
+// @Router /core/v1/sandbox/e2b/templates/{template_id}/builds [post]
+func (h *Handler) discoverSandboxE2BBuilds(w http.ResponseWriter, r *http.Request) {
+ h.discoverSandboxE2B(w, r, chi.URLParam(r, "template_id"))
+}
+
+func (h *Handler) discoverSandboxE2B(w http.ResponseWriter, r *http.Request, template string) {
+ raw, ok := readJSONBody(w, r)
+ if !ok {
+ return
+ }
+ var input SandboxE2BDiscoveryInput
+ if decodeInputObject(raw, &input, "api_key", "api_url", "domain") != nil || h.sandboxE2BDiscover == nil {
+ writeError(w, http.StatusBadRequest, "invalid_request_error", "Invalid E2B discovery request.", "")
+ return
+ }
+ result, err := h.sandboxE2BDiscover(r.Context(), input, template)
+ if err != nil {
+ if errors.Is(err, sandbox.ErrInvalid) {
+ writeError(w, http.StatusBadRequest, "invalid_request_error", "The E2B credential, endpoint, or template was rejected.", "")
+ } else {
+ writeError(w, http.StatusServiceUnavailable, "provider_unavailable", "E2B template discovery is unavailable. Check the credential, endpoint and provider connection, then retry.", "")
+ }
+ return
+ }
+ if template == "" {
+ writeJSON(w, http.StatusOK, struct {
+ Templates []e2b.TemplateSummary `json:"templates"`
+ }{Templates: result.Templates})
+ } else {
+ writeJSON(w, http.StatusOK, struct {
+ Builds []e2b.ReadyBuild `json:"builds"`
+ }{Builds: result.Builds})
+ }
+}
diff --git a/services/agents-api/internal/api/sandbox_e2b_discovery_test.go b/services/agents-api/internal/api/sandbox_e2b_discovery_test.go
new file mode 100644
index 000000000..6c2d1b2a7
--- /dev/null
+++ b/services/agents-api/internal/api/sandbox_e2b_discovery_test.go
@@ -0,0 +1,58 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
+)
+
+func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) {
+ project, _ := NewAuthenticator([]APIKey{callerBinding()})
+ admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")})
+ calls := 0
+ discover := func(_ context.Context, input SandboxE2BDiscoveryInput, template string) (SandboxE2BDiscoveryResult, error) {
+ calls++
+ if input.APIKey != "private-test-key" {
+ t.Fatal("wrong credential")
+ }
+ if template == "bad" {
+ return SandboxE2BDiscoveryResult{}, errors.New("private-test-key in provider error")
+ }
+ if template != "" {
+ return SandboxE2BDiscoveryResult{Builds: []e2b.ReadyBuild{}}, nil
+ }
+ return SandboxE2BDiscoveryResult{Templates: []e2b.TemplateSummary{}}, nil
+ }
+ h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin), WithSandboxE2BDiscovery(discover))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ token, path, body string
+ status int
+ }{
+ {"caller", "/core/v1/sandbox/e2b/templates", `{"api_key":"private-test-key"}`, 401},
+ {"administrator", "/core/v1/sandbox/e2b/templates", `{"api_key":"private-test-key","unexpected":true}`, 400},
+ {"administrator", "/core/v1/sandbox/e2b/templates", `{"api_key":"private-test-key"}`, 200},
+ {"administrator", "/core/v1/sandbox/e2b/templates/tpl_123/builds", `{"api_key":"private-test-key"}`, 200},
+ {"administrator", "/core/v1/sandbox/e2b/templates/bad/builds", `{"api_key":"private-test-key"}`, 503},
+ } {
+ req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
+ req.Header.Set("Authorization", "Bearer "+tc.token)
+ result := httptest.NewRecorder()
+ h.ServeHTTP(result, req)
+ if result.Code != tc.status || strings.Contains(result.Body.String(), "private-test-key") {
+ t.Fatal(result.Code, result.Body.String())
+ }
+ }
+ if calls != 3 {
+ t.Fatal(calls)
+ }
+}
diff --git a/services/agents-api/internal/api/sandbox_manager.go b/services/agents-api/internal/api/sandbox_manager.go
index 6a31eee6b..d88883ce7 100644
--- a/services/agents-api/internal/api/sandbox_manager.go
+++ b/services/agents-api/internal/api/sandbox_manager.go
@@ -53,6 +53,8 @@ func (h *Handler) registerSandboxManagerRoutes(r chi.Router) {
return
}
r.Get("/sandbox/deployment", h.sandboxDeployment)
+ r.Post("/sandbox/e2b/templates", h.discoverSandboxE2BTemplates)
+ r.Post("/sandbox/e2b/templates/{template_id}/builds", h.discoverSandboxE2BBuilds)
r.Post("/sandbox/deployment", h.initializeSandboxDeployment)
r.Put("/sandbox/deployment", h.updateSandboxDeployment)
r.Post("/sandbox/deployment/reset", h.startSandboxReset)
diff --git a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql
index 56ff85a27..aa1de4453 100644
--- a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql
+++ b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql
@@ -5,6 +5,7 @@ owner_epoch=owner_epoch+1, updated_at=clock_timestamp() WHERE singleton=true;
-- name: InitializeSandboxDeployment :exec
UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2,
idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, e2b_template=$7, e2b_credential=$8, specification=$9,
+e2b_api_url=sqlc.arg(e2b_api_url), e2b_domain=sqlc.arg(e2b_domain),
e2b_template_build_status=sqlc.narg(e2b_template_build_status), e2b_template_cpus=sqlc.narg(e2b_template_cpus),
e2b_template_memory_mib=sqlc.narg(e2b_template_memory_mib), e2b_template_root_disk_mib=sqlc.narg(e2b_template_root_disk_mib),
updated_at=clock_timestamp() WHERE singleton=true;
diff --git a/services/agents-api/internal/db/queries/sandbox_generations.sql b/services/agents-api/internal/db/queries/sandbox_generations.sql
index a4dc344ef..e0902423b 100644
--- a/services/agents-api/internal/db/queries/sandbox_generations.sql
+++ b/services/agents-api/internal/db/queries/sandbox_generations.sql
@@ -1,6 +1,6 @@
-- name: RetainSandboxGeneration :exec
-INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib)
-SELECT generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib
+INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain)
+SELECT generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain
FROM runtime_deployment d WHERE provider_kind <> '' AND (
EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.state <> 'released' AND a.deployment_generation=d.generation)
OR EXISTS(SELECT 1 FROM runtime_placements p WHERE p.released_at IS NULL AND p.deployment_generation=d.generation)
diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go
index aca44c658..67901e507 100644
--- a/services/agents-api/internal/db/sqlc/models.go
+++ b/services/agents-api/internal/db/sqlc/models.go
@@ -278,6 +278,8 @@ type RuntimeDeployment struct {
ResetDeadlineAt pgtype.Timestamptz `json:"reset_deadline_at"`
ResetForcedAt pgtype.Timestamptz `json:"reset_forced_at"`
ResetAudit []byte `json:"reset_audit"`
+ E2bApiUrl string `json:"e2b_api_url"`
+ E2bDomain string `json:"e2b_domain"`
}
type RuntimeDeploymentGeneration struct {
@@ -290,6 +292,8 @@ type RuntimeDeploymentGeneration struct {
E2bTemplateMemoryMib pgtype.Int4 `json:"e2b_template_memory_mib"`
E2bTemplateRootDiskMib pgtype.Int4 `json:"e2b_template_root_disk_mib"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
+ E2bApiUrl string `json:"e2b_api_url"`
+ E2bDomain string `json:"e2b_domain"`
}
type RuntimeDeviceAuthority struct {
diff --git a/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go b/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go
index 556579dd3..faf8de52c 100644
--- a/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go
+++ b/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go
@@ -55,7 +55,7 @@ func (q *Queries) CountRuntimeDeploymentResources(ctx context.Context) (CountRun
}
const lockRuntimeDeployment = `-- name: LockRuntimeDeployment :one
-SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit FROM runtime_deployment WHERE singleton = true FOR UPDATE
+SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, e2b_api_url, e2b_domain FROM runtime_deployment WHERE singleton = true FOR UPDATE
`
func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) {
@@ -87,6 +87,8 @@ func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment,
&i.ResetDeadlineAt,
&i.ResetForcedAt,
&i.ResetAudit,
+ &i.E2bApiUrl,
+ &i.E2bDomain,
)
return i, err
}
diff --git a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go
index bba7d1a89..238b38cb7 100644
--- a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go
+++ b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go
@@ -163,7 +163,7 @@ func (q *Queries) DisconnectRuntimeNode(ctx context.Context, arg DisconnectRunti
}
const getRuntimeDeployment = `-- name: GetRuntimeDeployment :one
-SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit FROM runtime_deployment WHERE singleton=true
+SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, e2b_api_url, e2b_domain FROM runtime_deployment WHERE singleton=true
`
func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) {
@@ -195,6 +195,8 @@ func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment,
&i.ResetDeadlineAt,
&i.ResetForcedAt,
&i.ResetAudit,
+ &i.E2bApiUrl,
+ &i.E2bDomain,
)
return i, err
}
diff --git a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go
index 82310a07e..cd4b350b9 100644
--- a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go
+++ b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go
@@ -33,8 +33,9 @@ func (q *Queries) ClaimWebSandboxDeployment(ctx context.Context, installationID
const initializeSandboxDeployment = `-- name: InitializeSandboxDeployment :exec
UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2,
idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, e2b_template=$7, e2b_credential=$8, specification=$9,
-e2b_template_build_status=$10, e2b_template_cpus=$11,
-e2b_template_memory_mib=$12, e2b_template_root_disk_mib=$13,
+e2b_api_url=$10, e2b_domain=$11,
+e2b_template_build_status=$12, e2b_template_cpus=$13,
+e2b_template_memory_mib=$14, e2b_template_root_disk_mib=$15,
updated_at=clock_timestamp() WHERE singleton=true
`
@@ -48,6 +49,8 @@ type InitializeSandboxDeploymentParams struct {
E2bTemplate string `json:"e2b_template"`
E2bCredential []byte `json:"e2b_credential"`
Specification []byte `json:"specification"`
+ E2bApiUrl string `json:"e2b_api_url"`
+ E2bDomain string `json:"e2b_domain"`
E2bTemplateBuildStatus pgtype.Text `json:"e2b_template_build_status"`
E2bTemplateCpus pgtype.Int4 `json:"e2b_template_cpus"`
E2bTemplateMemoryMib pgtype.Int4 `json:"e2b_template_memory_mib"`
@@ -65,6 +68,8 @@ func (q *Queries) InitializeSandboxDeployment(ctx context.Context, arg Initializ
arg.E2bTemplate,
arg.E2bCredential,
arg.Specification,
+ arg.E2bApiUrl,
+ arg.E2bDomain,
arg.E2bTemplateBuildStatus,
arg.E2bTemplateCpus,
arg.E2bTemplateMemoryMib,
diff --git a/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go b/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go
index 39ca8a14a..d4bb3ec85 100644
--- a/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go
+++ b/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go
@@ -31,7 +31,7 @@ func (q *Queries) CollectSandboxGenerations(ctx context.Context) error {
}
const getSandboxGeneration = `-- name: GetSandboxGeneration :one
-SELECT generation, provider_kind, specification, e2b_template, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, created_at FROM runtime_deployment_generations WHERE generation=$1
+SELECT generation, provider_kind, specification, e2b_template, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, created_at, e2b_api_url, e2b_domain FROM runtime_deployment_generations WHERE generation=$1
`
func (q *Queries) GetSandboxGeneration(ctx context.Context, generation int64) (RuntimeDeploymentGeneration, error) {
@@ -47,12 +47,14 @@ func (q *Queries) GetSandboxGeneration(ctx context.Context, generation int64) (R
&i.E2bTemplateMemoryMib,
&i.E2bTemplateRootDiskMib,
&i.CreatedAt,
+ &i.E2bApiUrl,
+ &i.E2bDomain,
)
return i, err
}
const listSandboxGenerations = `-- name: ListSandboxGenerations :many
-SELECT generation, provider_kind, specification, e2b_template, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, created_at FROM runtime_deployment_generations WHERE generation > $1 ORDER BY generation LIMIT 32
+SELECT generation, provider_kind, specification, e2b_template, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, created_at, e2b_api_url, e2b_domain FROM runtime_deployment_generations WHERE generation > $1 ORDER BY generation LIMIT 32
`
func (q *Queries) ListSandboxGenerations(ctx context.Context, generation int64) ([]RuntimeDeploymentGeneration, error) {
@@ -74,6 +76,8 @@ func (q *Queries) ListSandboxGenerations(ctx context.Context, generation int64)
&i.E2bTemplateMemoryMib,
&i.E2bTemplateRootDiskMib,
&i.CreatedAt,
+ &i.E2bApiUrl,
+ &i.E2bDomain,
); err != nil {
return nil, err
}
@@ -86,8 +90,8 @@ func (q *Queries) ListSandboxGenerations(ctx context.Context, generation int64)
}
const retainSandboxGeneration = `-- name: RetainSandboxGeneration :exec
-INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib)
-SELECT generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib
+INSERT INTO runtime_deployment_generations(generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain)
+SELECT generation,provider_kind,specification,e2b_template,e2b_template_build_status,e2b_template_cpus,e2b_template_memory_mib,e2b_template_root_disk_mib,e2b_api_url,e2b_domain
FROM runtime_deployment d WHERE provider_kind <> '' AND (
EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.state <> 'released' AND a.deployment_generation=d.generation)
OR EXISTS(SELECT 1 FROM runtime_placements p WHERE p.released_at IS NULL AND p.deployment_generation=d.generation)
diff --git a/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go b/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go
index 823e5ef91..fabff44d7 100644
--- a/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go
+++ b/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go
@@ -51,7 +51,7 @@ func (q *Queries) ForceSandboxReset(ctx context.Context) error {
}
const getSandboxDeploymentSnapshot = `-- name: GetSandboxDeploymentSnapshot :one
-WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit FROM runtime_deployment WHERE singleton = true LIMIT 1),
+WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, admission_paused, updated_at, provider_kind, local_node_id, owner_epoch, web_managed, idle_seconds, retention_seconds, generation, mode, e2b_template, e2b_credential, specification, e2b_template_build_status, e2b_template_cpus, e2b_template_memory_mib, e2b_template_root_disk_mib, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, e2b_api_url, e2b_domain FROM runtime_deployment WHERE singleton = true LIMIT 1),
observed AS MATERIALIZED (SELECT clock_timestamp() AS as_of),
held AS (
SELECT a.deployment_generation, a.node_id, s.id AS session_id, e.id AS environment_id, false AS pending,
@@ -89,7 +89,7 @@ held AS (
), offline AS (
SELECT node_id, name, count(*)::bigint AS resources FROM classified WHERE offline GROUP BY node_id, name
)
-SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.admission_paused, d.updated_at, d.provider_kind, d.local_node_id, d.owner_epoch, d.web_managed, d.idle_seconds, d.retention_seconds, d.generation, d.mode, d.e2b_template, d.e2b_credential, d.specification, d.e2b_template_build_status, d.e2b_template_cpus, d.e2b_template_memory_mib, d.e2b_template_root_disk_mib, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit,
+SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.admission_paused, d.updated_at, d.provider_kind, d.local_node_id, d.owner_epoch, d.web_managed, d.idle_seconds, d.retention_seconds, d.generation, d.mode, d.e2b_template, d.e2b_credential, d.specification, d.e2b_template_build_status, d.e2b_template_cpus, d.e2b_template_memory_mib, d.e2b_template_root_disk_mib, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.e2b_api_url, d.e2b_domain,
(SELECT count(*) FROM classified WHERE NOT pending)::bigint AS allocations,
(SELECT count(*) FROM classified WHERE pending)::bigint AS pending,
jsonb_build_object(
@@ -150,6 +150,8 @@ func (q *Queries) GetSandboxDeploymentSnapshot(ctx context.Context) (GetSandboxD
&i.RuntimeDeployment.ResetDeadlineAt,
&i.RuntimeDeployment.ResetForcedAt,
&i.RuntimeDeployment.ResetAudit,
+ &i.RuntimeDeployment.E2bApiUrl,
+ &i.RuntimeDeployment.E2bDomain,
&i.Allocations,
&i.Pending,
&i.Remaining,
diff --git a/services/agents-api/internal/sandbox/e2b/discovery_test.go b/services/agents-api/internal/sandbox/e2b/discovery_test.go
new file mode 100644
index 000000000..fd9474cde
--- /dev/null
+++ b/services/agents-api/internal/sandbox/e2b/discovery_test.go
@@ -0,0 +1,36 @@
+package e2b
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "github.com/google/uuid"
+)
+
+func TestDiscoveryUsesTransientConnectionAndChecksHelper(t *testing.T) {
+ caller := &fakeCaller{response: Response{Version: ProtocolVersion, Templates: []TemplateSummary{{ID: "tpl_123", Names: []string{"test"}}}}}
+ _, err := Discover(t.Context(), caller, "/tmp/helper", "private-key", "https://sandbox.sandbase.ai", "sandbox.sandbase.ai", "")
+ if err != nil || len(caller.requests) != 1 || caller.requests[0].Operation != "list_templates" || caller.requests[0].Config.APIKey != "private-key" {
+ t.Fatal(err, caller.requests)
+ }
+ buildID := uuid.NewString()
+ caller.response = Response{Version: ProtocolVersion, Builds: []ReadyBuild{{ID: buildID, CPUs: 2, MemoryMiB: 2048}}}
+ _, err = Discover(t.Context(), caller, "/tmp/helper", "private-key", "", "", "tpl_123")
+ if err != nil || caller.requests[1].Operation != "list_builds" || caller.requests[1].Config.Template != "tpl_123" {
+ t.Fatal(err)
+ }
+ for _, input := range []struct{ key, url, domain, template string }{
+ {"key\nsecret", "", "", ""},
+ {"key", "http://localhost", "localhost", ""},
+ {"key", "", "", "../template"},
+ } {
+ if _, err := Discover(t.Context(), caller, "/tmp/helper", input.key, input.url, input.domain, input.template); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal(err)
+ }
+ }
+ caller.err = errors.New("private-key in provider diagnostic")
+ if _, err := Discover(t.Context(), caller, "/tmp/helper", "private-key", "", "", ""); !errors.Is(err, sandbox.ErrComputeUnconfirmed) || err.Error() == caller.err.Error() {
+ t.Fatal(err)
+ }
+}
diff --git a/services/agents-api/internal/sandbox/e2b/endpoint.go b/services/agents-api/internal/sandbox/e2b/endpoint.go
new file mode 100644
index 000000000..d5cb05903
--- /dev/null
+++ b/services/agents-api/internal/sandbox/e2b/endpoint.go
@@ -0,0 +1,52 @@
+package e2b
+
+import (
+ "errors"
+ "net"
+ "net/url"
+ "strings"
+)
+
+const OfficialAPIURL = "https://api.e2b.app"
+const OfficialDomain = "e2b.app"
+
+var ErrEndpoint = errors.New("invalid E2B endpoint")
+
+// NormalizeEndpoint returns the two SDK routing selectors. Both must be set
+// together for a compatible service; omission selects the official cloud.
+func NormalizeEndpoint(apiURL, domain string) (string, string, error) {
+ if apiURL == "" && domain == "" {
+ return OfficialAPIURL, OfficialDomain, nil
+ }
+ if apiURL == "" || domain == "" || len(apiURL) > 512 || len(domain) > 253 || !validDomain(domain) {
+ return "", "", ErrEndpoint
+ }
+ u, err := url.Parse(apiURL)
+ if err != nil || u.Scheme != "https" || u.Host != u.Hostname() || u.Host == "" || u.User != nil ||
+ u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" ||
+ u.RawFragment != "" || u.Opaque != "" || u.String() != apiURL || !validDomain(u.Hostname()) {
+ return "", "", ErrEndpoint
+ }
+ if u.Hostname() != domain && !strings.HasSuffix(u.Hostname(), "."+domain) {
+ return "", "", ErrEndpoint
+ }
+ return apiURL, domain, nil
+}
+
+func validDomain(host string) bool {
+ if len(host) > 253 || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") ||
+ net.ParseIP(host) != nil || host != strings.ToLower(host) || !strings.Contains(host, ".") {
+ return false
+ }
+ for _, label := range strings.Split(host, ".") {
+ if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+ return false
+ }
+ for _, c := range label {
+ if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
+ return false
+ }
+ }
+ }
+ return true
+}
diff --git a/services/agents-api/internal/sandbox/e2b/endpoint_test.go b/services/agents-api/internal/sandbox/e2b/endpoint_test.go
new file mode 100644
index 000000000..c7bd1e15f
--- /dev/null
+++ b/services/agents-api/internal/sandbox/e2b/endpoint_test.go
@@ -0,0 +1,37 @@
+package e2b
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestNormalizeEndpoint(t *testing.T) {
+ for _, tc := range []struct {
+ apiURL, domain string
+ valid bool
+ }{
+ {"", "", true},
+ {"https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai", true},
+ {"https://api.e2b.app", "e2b.app", true},
+ {"https://api.sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai", true},
+ {"https://unrelated.example", "sandbox-test.sandbase.ai", false},
+ {"https://sandbox-test.sandbase.ai", "", false},
+ {"", "sandbox-test.sandbase.ai", false},
+ {"http://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai", false},
+ {"https://sandbox-test.sandbase.ai/path", "sandbox-test.sandbase.ai", false},
+ {"https://sandbox-test.sandbase.ai:443", "sandbox-test.sandbase.ai", false},
+ {"https://127.0.0.1", "sandbox-test.sandbase.ai", false},
+ {"https://localhost", "sandbox-test.sandbase.ai", false},
+ {"https://-bad.example", "sandbox-test.sandbase.ai", false},
+ {"https://good.example", "bad..example", false},
+ {"https://" + strings.Repeat("a.", 125) + "example", "good.example", false},
+ } {
+ apiURL, domain, err := NormalizeEndpoint(tc.apiURL, tc.domain)
+ if (err == nil) != tc.valid {
+ t.Errorf("NormalizeEndpoint(%q, %q): unexpected error %v", tc.apiURL, tc.domain, err)
+ }
+ if tc.apiURL == "" && tc.domain == "" && (apiURL != OfficialAPIURL || domain != OfficialDomain) {
+ t.Errorf("official defaults: %q %q", apiURL, domain)
+ }
+ }
+}
diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go
index d6865e284..a3b611e32 100644
--- a/services/agents-api/internal/sandbox/e2b/provider.go
+++ b/services/agents-api/internal/sandbox/e2b/provider.go
@@ -25,9 +25,9 @@ const MaxResponseBytes = 16 * 1024 * 1024
// Config contains trusted deployment configuration; APIKey travels only on stdin.
type Config struct {
- Binary, StateDir, InstallationID, APIKey, Template string
- TimeoutSeconds int
- Resources *sandbox.Resources
+ Binary, StateDir, InstallationID, APIKey, Template, APIURL, Domain string
+ TimeoutSeconds int
+ Resources *sandbox.Resources
}
type Request struct {
@@ -46,9 +46,88 @@ type Response struct {
Info *sandbox.Info `json:",omitempty"`
Command *sandbox.CommandResult `json:",omitempty"`
ErrorCode string
- DeploymentValid bool `json:",omitempty"`
- TemplateBuild *TemplateBuild `json:",omitempty"`
- Observations []Observation `json:",omitempty"`
+ DeploymentValid bool `json:",omitempty"`
+ TemplateBuild *TemplateBuild `json:",omitempty"`
+ Templates []TemplateSummary `json:",omitempty"`
+ Builds []ReadyBuild `json:",omitempty"`
+ Observations []Observation `json:",omitempty"`
+}
+
+type TemplateSummary struct {
+ ID string `json:"id"`
+ Names []string `json:"names"`
+}
+type ReadyBuild struct {
+ ID string `json:"id"`
+ CPUs uint32 `json:"cpus"`
+ MemoryMiB uint32 `json:"memory_mib"`
+}
+
+// Discover uses the same pinned SDK helper without requiring a saved deployment.
+// Its credential is passed only to the helper on stdin.
+func Discover(ctx context.Context, caller Caller, binary, apiKey, apiURL, domain, template string) (Response, error) {
+ if caller == nil || !filepath.IsAbs(binary) || apiKey == "" || len(apiKey) > 4096 ||
+ strings.ContainsFunc(apiKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) {
+ return Response{}, sandbox.ErrInvalid
+ }
+ if _, _, err := NormalizeEndpoint(apiURL, domain); err != nil {
+ return Response{}, sandbox.ErrInvalid
+ }
+ operation := "list_templates"
+ if template != "" {
+ if len(template) > 128 {
+ return Response{}, sandbox.ErrInvalid
+ }
+ for _, r := range template {
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
+ return Response{}, sandbox.ErrInvalid
+ }
+ }
+ operation = "list_builds"
+ }
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ deadline, _ := ctx.Deadline()
+ out, err := caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: Config{Binary: binary, APIKey: apiKey, APIURL: apiURL, Domain: domain, Template: template}, Deadline: deadline})
+ if err != nil || out.Version != ProtocolVersion {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ if out.ErrorCode == "invalid" {
+ return Response{}, sandbox.ErrInvalid
+ }
+ if out.ErrorCode != "" || out.Info != nil || out.Command != nil || out.TemplateBuild != nil || out.Observations != nil {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ if operation == "list_templates" {
+ if out.Templates == nil || out.Builds != nil || len(out.Templates) > 200 {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ for _, item := range out.Templates {
+ if item.ID == "" || len(item.ID) > 128 || len(item.Names) > 20 {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ for _, r := range item.ID {
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ }
+ for _, name := range item.Names {
+ if len(name) > 128 {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ }
+ }
+ } else {
+ if out.Builds == nil || out.Templates != nil || len(out.Builds) > 200 {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ for _, item := range out.Builds {
+ if !validID(item.ID) || item.CPUs == 0 || item.MemoryMiB == 0 {
+ return Response{}, sandbox.ErrComputeUnconfirmed
+ }
+ }
+ }
+ return out, nil
}
// TemplateBuild is the fixed build as read by deployment validation.
@@ -84,6 +163,9 @@ func (c Config) Validate() error {
if !ok || template == "" || !validID(build) || !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 || c.APIKey == "" || len(c.APIKey) > 4096 || strings.ContainsFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) {
return sandbox.ErrInvalid
}
+ if _, _, err := NormalizeEndpoint(c.APIURL, c.Domain); err != nil {
+ return sandbox.ErrInvalid
+ }
for _, r := range template {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
return sandbox.ErrInvalid
diff --git a/services/agents-api/internal/store/runtime_node_generations_test.go b/services/agents-api/internal/store/runtime_node_generations_test.go
index b78ce2086..378c580b6 100644
--- a/services/agents-api/internal/store/runtime_node_generations_test.go
+++ b/services/agents-api/internal/store/runtime_node_generations_test.go
@@ -197,6 +197,12 @@ func TestNodeGenerationDowngradePreservesServingProtocol(t *testing.T) {
if err == nil {
t.Fatal("downgrade discarded required node protocol")
}
+ // DownTo may have removed later, reversible migrations before the
+ // node protocol migration refused the downgrade. Restore the current
+ // schema before using this version of the Store to verify recovery.
+ if _, err = migration.Up(t.Context()); err != nil {
+ t.Fatal("refused downgrade could not restore current schema", err)
+ }
if _, err = s.RuntimeNodeGenerationConfiguration(t.Context(), node.NodeID, node.Credential, 1); err != nil {
t.Fatal("refused downgrade damaged retained recovery", err)
}
diff --git a/services/agents-api/internal/store/runtime_node_types.go b/services/agents-api/internal/store/runtime_node_types.go
index 2f90dc9fc..6830dc5f8 100644
--- a/services/agents-api/internal/store/runtime_node_types.go
+++ b/services/agents-api/internal/store/runtime_node_types.go
@@ -98,6 +98,8 @@ type SandboxDeploymentResources struct {
// SandboxE2BView is the safe E2B selection. It never includes the API key.
type SandboxE2BView struct {
Template string `json:"template"`
+ APIURL string `json:"api_url"`
+ Domain string `json:"domain"`
CredentialConfigured bool `json:"credential_configured"`
// The fixed template build as Core read it when this selection was saved.
TemplateBuild SandboxE2BTemplateBuildView `json:"template_build"`
diff --git a/services/agents-api/internal/store/sandbox_deployment_mutations.go b/services/agents-api/internal/store/sandbox_deployment_mutations.go
index d9a07bbe9..2d60b8729 100644
--- a/services/agents-api/internal/store/sandbox_deployment_mutations.go
+++ b/services/agents-api/internal/store/sandbox_deployment_mutations.go
@@ -12,6 +12,7 @@ import (
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -24,6 +25,8 @@ var ErrSandboxCredentialUnavailable = errors.New("sandbox credential encryption
type SandboxE2BConfiguration struct {
ReplaceCredential bool `json:"-"`
APIKey string `json:"-"`
+ APIURL string `json:"api_url,omitempty"`
+ Domain string `json:"domain,omitempty"`
Template string `json:"template"`
TemplateBuild *SandboxE2BTemplateBuild `json:"-"`
}
@@ -63,6 +66,9 @@ func validateSandboxSelection(input SandboxDeploymentSetupRequest) error {
if input.E2B == nil || input.E2B.APIKey == "" || len(input.E2B.APIKey) > 4096 || strings.IndexFunc(input.E2B.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 {
return ErrInvalidInput
}
+ if _, _, err := e2b.NormalizeEndpoint(input.E2B.APIURL, input.E2B.Domain); err != nil {
+ return ErrInvalidInput
+ }
template, build, ok := strings.Cut(input.E2B.Template, ":")
id, err := uuid.Parse(build)
if !ok || template == "" || len(template) > 128 || err != nil || id == uuid.Nil || id.String() != build {
@@ -96,6 +102,11 @@ func (s *Store) sandboxSelectionEqual(d sqlc.RuntimeDeployment, input SandboxDep
if d.E2bTemplate != input.E2B.Template {
return false, nil
}
+ savedAPI, savedDomain, savedErr := e2b.NormalizeEndpoint(d.E2bApiUrl, d.E2bDomain)
+ inputAPI, inputDomain, inputErr := e2b.NormalizeEndpoint(input.E2B.APIURL, input.E2B.Domain)
+ if savedErr != nil || inputErr != nil || savedAPI != inputAPI || savedDomain != inputDomain {
+ return false, nil
+ }
key, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, runtimeUUID(d.InstallationID), uint64(d.Generation))
if err != nil {
return false, ErrSandboxCredentialUnavailable
@@ -130,6 +141,7 @@ func (s *Store) saveSandboxSelection(ctx context.Context, q *sqlc.Queries, d sql
return ErrSandboxCredentialUnavailable
}
params.E2bCredential, params.E2bTemplate = encrypted, input.E2B.Template
+ params.E2bApiUrl, params.E2bDomain = input.E2B.APIURL, input.E2B.Domain
build := templateBuildColumns(input.E2B.TemplateBuild)
params.E2bTemplateBuildStatus, params.E2bTemplateCpus = build.E2bTemplateBuildStatus, build.E2bTemplateCpus
params.E2bTemplateMemoryMib, params.E2bTemplateRootDiskMib = build.E2bTemplateMemoryMib, build.E2bTemplateRootDiskMib
diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go
index 5c458f81c..74aa89ce1 100644
--- a/services/agents-api/internal/store/sandbox_deployment_setup.go
+++ b/services/agents-api/internal/store/sandbox_deployment_setup.go
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -64,7 +65,11 @@ func (s *Store) sandboxSetup(d sqlc.RuntimeDeployment) (SandboxSetup, error) {
if err != nil {
return SandboxSetup{}, ErrSandboxCredentialUnavailable
}
- result.E2B = &SandboxE2BConfiguration{APIKey: string(credential), Template: d.E2bTemplate}
+ apiURL, domain, err := e2b.NormalizeEndpoint(d.E2bApiUrl, d.E2bDomain)
+ if err != nil {
+ return SandboxSetup{}, ErrSandboxDeploymentConflict
+ }
+ result.E2B = &SandboxE2BConfiguration{APIKey: string(credential), Template: d.E2bTemplate, APIURL: apiURL, Domain: domain}
}
return result, nil
}
@@ -165,7 +170,8 @@ func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDe
}
}
if d.ProviderKind == "e2b" {
- result.E2B = &SandboxE2BView{Template: d.E2bTemplate, CredentialConfigured: len(d.E2bCredential) > 0,
+ apiURL, domain, _ := e2b.NormalizeEndpoint(d.E2bApiUrl, d.E2bDomain)
+ result.E2B = &SandboxE2BView{Template: d.E2bTemplate, APIURL: apiURL, Domain: domain, CredentialConfigured: len(d.E2bCredential) > 0,
TemplateBuild: SandboxE2BTemplateBuildView{Resources: SandboxTemplateResources{
CPUs: optionalInt32(d.E2bTemplateCpus), MemoryMiB: optionalInt32(d.E2bTemplateMemoryMib), RootDiskMiB: optionalInt32(d.E2bTemplateRootDiskMib)}}}
if d.E2bTemplateBuildStatus.Valid {
diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_test.go
index 07efa16fc..e17bb2e29 100644
--- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go
+++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go
@@ -16,6 +16,37 @@ import (
func e2bSelection() SandboxDeploymentSetupRequest {
return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &SandboxE2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}}
}
+
+func TestSandboxE2BEndpointPersistenceAndOnlineSwitch(t *testing.T) {
+ _, pool := newManagedTestStore(t)
+ cipher, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := NewWithCredentialCipher(pool, cipher)
+ w := executionLease(t, s).Store()
+ id := uuid.NewString()
+ if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ input := e2bSelection()
+ input.E2B.APIURL, input.E2B.Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai"
+ view, err := w.InitializeSandboxDeployment(t.Context(), id, input)
+ if err != nil || view.E2B == nil || view.E2B.APIURL != input.E2B.APIURL || view.E2B.Domain != input.E2B.Domain {
+ t.Fatal("custom endpoint was not returned", view, err)
+ }
+ setup, err := s.GetSandboxSetup(t.Context())
+ if err != nil || setup.E2B == nil || setup.E2B.APIURL != input.E2B.APIURL || setup.E2B.Domain != input.E2B.Domain {
+ t.Fatal("custom endpoint was not persisted", setup, err)
+ }
+ change := e2bSelection()
+ change.E2B.Template = input.E2B.Template
+ update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: change, ExpectedGeneration: view.Generation}
+ changed, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, update)
+ if err != nil || changed.Generation != view.Generation+1 || changed.E2B == nil || changed.E2B.APIURL != "https://api.e2b.app" || changed.E2B.Domain != "e2b.app" {
+ t.Fatal("online endpoint switch failed", changed, err)
+ }
+}
func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) {
_, pool := newManagedTestStore(t)
cipher, err := credentialcrypto.New(bytes.Repeat([]byte{4}, 32))
diff --git a/services/agents-api/internal/store/sandbox_generations.go b/services/agents-api/internal/store/sandbox_generations.go
index afd81b7d0..3851b2690 100644
--- a/services/agents-api/internal/store/sandbox_generations.go
+++ b/services/agents-api/internal/store/sandbox_generations.go
@@ -23,6 +23,10 @@ func (s *Store) ClassifySandboxDeploymentChange(ctx context.Context, installatio
copy := *input.E2B
copy.ReplaceCredential = copy.ReplaceCredential || copy.APIKey != ""
input.E2B = ©
+ // Omitted endpoint selectors retain the committed E2B connection.
+ if copy.APIURL == "" && copy.Domain == "" {
+ input.E2B.APIURL, input.E2B.Domain = d.E2bApiUrl, d.E2bDomain
+ }
if copy.APIKey == "" && !copy.ReplaceCredential {
key, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, installation, uint64(d.Generation))
if err != nil {
@@ -97,6 +101,7 @@ func (s *Store) GetSandboxAllocationSetup(ctx context.Context, ref sandbox.Refer
}
if result.E2B != nil {
result.E2B.Template = g.E2bTemplate
+ result.E2B.APIURL, result.E2B.Domain = g.E2bApiUrl, g.E2bDomain
}
}
return result, tx.Commit(ctx)
@@ -115,7 +120,7 @@ func (s *Store) SandboxGenerationPage(ctx context.Context, after int64) ([]Sandb
return nil, err
}
if r.ProviderKind == "e2b" {
- v.E2B = &SandboxE2BConfiguration{Template: r.E2bTemplate}
+ v.E2B = &SandboxE2BConfiguration{Template: r.E2bTemplate, APIURL: r.E2bApiUrl, Domain: r.E2bDomain}
}
result = append(result, v)
}
diff --git a/services/agents-api/internal/store/sandbox_generations_test.go b/services/agents-api/internal/store/sandbox_generations_test.go
index ab9165d6c..e61d951a2 100644
--- a/services/agents-api/internal/store/sandbox_generations_test.go
+++ b/services/agents-api/internal/store/sandbox_generations_test.go
@@ -21,6 +21,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) {
ctx := SandboxResetTestContext(t.Context())
oldTemplate := input.E2B.Template
input.E2B.Template = "next:" + uuid.NewString()
+ input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com"
input.Resources.CPUs++
changed, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1})
if err != nil || changed.Generation != 2 || changed.OwnerEpoch != view.OwnerEpoch || changed.Rollout.PreviousGenerationSandboxes != 1 || changed.Rollout.State != "settled" {
@@ -29,7 +30,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) {
assertSandboxSnapshotEquivalent(t, s.pool)
ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID}
retained, err := s.GetSandboxAllocationSetup(t.Context(), ref)
- if err != nil || retained.Generation != 1 || retained.E2B.Template != oldTemplate || retained.Specification.Resources.CPUs == input.Resources.CPUs {
+ if err != nil || retained.Generation != 1 || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "" || retained.Specification.Resources.CPUs == input.Resources.CPUs {
t.Fatal(retained, err)
}
input.E2B.APIKey = "replacement-secret"
@@ -39,7 +40,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) {
t.Fatal(changed, err)
}
retained, err = s.GetSandboxAllocationSetup(t.Context(), ref)
- if err != nil || retained.Generation != 1 || retained.E2B.APIKey != input.E2B.APIKey || retained.E2B.Template != oldTemplate {
+ if err != nil || retained.Generation != 1 || retained.E2B.APIKey != input.E2B.APIKey || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "" {
t.Fatal("old generation did not use committed key", err)
}
if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_allocations SET deployment_generation=3 WHERE id=$1`, owner.ID); err == nil {
@@ -49,7 +50,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) {
t.Fatal(err)
}
generations, err := s.SandboxGenerationPage(t.Context(), -1)
- if err != nil || len(generations) != 1 || generations[0].Generation != 1 {
+ if err != nil || len(generations) != 1 || generations[0].Generation != 1 || generations[0].E2B.APIURL != "" {
t.Fatal(generations, err)
}
if _, err = w.RequestRuntimeCleanup(t.Context(), owner); err != nil {
@@ -74,6 +75,32 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) {
}
}
+func TestE2BRetainedCustomEndpointAfterOnlineSwitch(t *testing.T) {
+ s, w, view, input := webSpecificationFixture(t, "e2b")
+ ctx := SandboxResetTestContext(t.Context())
+ input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com"
+ custom, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: view.Generation})
+ if err != nil || custom.Generation != 2 {
+ t.Fatal(custom, err)
+ }
+ tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString()))
+ owner := archiveAllocation(t, w, tenant, session, view.InstallationID)
+ input.E2B.APIURL, input.E2B.Domain = "", ""
+ current, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: custom.Generation})
+ if err != nil || current.Generation != 3 || current.E2B.APIURL != "https://api.e2b.app" {
+ t.Fatal(current, err)
+ }
+ ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID}
+ retained, err := s.GetSandboxAllocationSetup(t.Context(), ref)
+ if err != nil || retained.Generation != 2 || retained.E2B.APIURL != "https://sandbox.example.com" || retained.E2B.Domain != "sandbox.example.com" {
+ t.Fatal(retained, err)
+ }
+ generations, err := s.SandboxGenerationPage(t.Context(), -1)
+ if err != nil || len(generations) != 1 || generations[0].E2B.APIURL != "https://sandbox.example.com" {
+ t.Fatal(generations, err)
+ }
+}
+
func TestE2BChangeClassifierOmittedKeyAndExplicitSameKey(t *testing.T) {
_, w, view, input := webSpecificationFixture(t, "e2b")
key := input.E2B.APIKey
diff --git a/services/agents-api/migrations/000086_e2b_custom_endpoint.sql b/services/agents-api/migrations/000086_e2b_custom_endpoint.sql
new file mode 100644
index 000000000..9644ce087
--- /dev/null
+++ b/services/agents-api/migrations/000086_e2b_custom_endpoint.sql
@@ -0,0 +1,26 @@
+-- +goose Up
+ALTER TABLE runtime_deployment ADD COLUMN IF NOT EXISTS e2b_api_url text NOT NULL DEFAULT '';
+ALTER TABLE runtime_deployment ADD COLUMN IF NOT EXISTS e2b_domain text NOT NULL DEFAULT '';
+-- +goose StatementBegin
+DO $$ BEGIN
+IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'runtime_deployment_e2b_endpoint_check') THEN
+ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_endpoint_check CHECK (
+ (provider_kind = 'e2b' AND ((e2b_api_url = '' AND e2b_domain = '') OR
+ (e2b_api_url <> '' AND e2b_domain <> ''))) OR
+ (provider_kind <> 'e2b' AND e2b_api_url = '' AND e2b_domain = '')
+);
+END IF;
+END $$;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DO $$ BEGIN
+ IF EXISTS (SELECT 1 FROM runtime_deployment WHERE e2b_api_url <> '' OR e2b_domain <> '') THEN
+ RAISE EXCEPTION 'cannot remove E2B endpoint columns while a custom endpoint is configured';
+ END IF;
+END $$;
+-- +goose StatementEnd
+ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_endpoint_check;
+ALTER TABLE runtime_deployment DROP COLUMN e2b_domain;
+ALTER TABLE runtime_deployment DROP COLUMN e2b_api_url;
diff --git a/services/agents-api/migrations/000087_e2b_generation_endpoints.sql b/services/agents-api/migrations/000087_e2b_generation_endpoints.sql
new file mode 100644
index 000000000..4f0c5d888
--- /dev/null
+++ b/services/agents-api/migrations/000087_e2b_generation_endpoints.sql
@@ -0,0 +1,21 @@
+-- +goose Up
+-- Earlier generation rows predate custom endpoints and use the official defaults.
+ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_api_url text NOT NULL DEFAULT '';
+ALTER TABLE runtime_deployment_generations ADD COLUMN e2b_domain text NOT NULL DEFAULT '';
+ALTER TABLE runtime_deployment_generations ADD CONSTRAINT runtime_deployment_generation_e2b_endpoint_check CHECK (
+ (provider_kind = 'e2b' AND ((e2b_api_url = '' AND e2b_domain = '') OR
+ (e2b_api_url <> '' AND e2b_domain <> ''))) OR
+ (provider_kind <> 'e2b' AND e2b_api_url = '' AND e2b_domain = '')
+);
+
+-- +goose Down
+-- +goose StatementBegin
+DO $$ BEGIN
+ IF EXISTS (SELECT 1 FROM runtime_deployment_generations WHERE e2b_api_url <> '' OR e2b_domain <> '') THEN
+ RAISE EXCEPTION 'cannot remove E2B generation endpoints while a custom endpoint is retained';
+ END IF;
+END $$;
+-- +goose StatementEnd
+ALTER TABLE runtime_deployment_generations DROP CONSTRAINT runtime_deployment_generation_e2b_endpoint_check;
+ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_domain;
+ALTER TABLE runtime_deployment_generations DROP COLUMN e2b_api_url;
diff --git a/services/agents-api/tools/e2b-provider/README.md b/services/agents-api/tools/e2b-provider/README.md
index ff5659d25..41868477a 100644
--- a/services/agents-api/tools/e2b-provider/README.md
+++ b/services/agents-api/tools/e2b-provider/README.md
@@ -12,6 +12,18 @@ selection without resources adopts the ready build's CPU and memory. It returns
the build's status, CPU, memory and reported disk size for Core to record with
the selection, and creates neither compute nor allocation receipts.
+The console's Core-key-only setup flow uses two more read-only helper requests.
+`list_templates` pages the credential's visible templates through the official
+`GET /v2/templates` SDK operation; `list_builds`
+pages one selected template and returns ready exact build IDs and resources.
+Both operations use the same explicit endpoint selectors and a transient API
+key. They cap results at 200, never write a receipt, and cannot replace the
+deployment write's exact-build validation.
+Compatible endpoints must return the E2B SDK 2.51.0 template-list and
+template-build response models. The helper does not adapt provider-specific
+catalog shapes.
+
+Runtime observation uses a third read-only request, `observe`, for at most 100
Runtime observation uses a separate read-only request, `observe`, for at most 100
allocations. It reads each allocation's sandbox ID from its receipt without the
allocation lock, then runs one `GET /sandboxes/metrics` request and one labelled
@@ -42,7 +54,12 @@ The private JSON boundary has version 1. Requests and credentials enter stdin;
stdout contains one bounded response with sanitized error codes. API keys never
enter arguments, inherited environment or receipts. The process retains its
allocation lock when the Core caller times out, until the bounded SDK operation
-returns. Core tracks actual child exit even after caller timeout. Credential fencing
+returns. Core must serialize lifecycle requests and never replay Create.
+The request carries the deployment's explicit API origin and sandbox domain.
+Every SDK call uses these selectors after ambient `E2B_*` variables are removed.
+Receipts bind an allocation to those selectors; receipts written before this
+feature belong to official E2B. Endpoint changes retain earlier generations on their original API and data-plane domain. The candidate credential must verify all retained ownership before an online switch.
+Core tracks actual child exit even after caller timeout. Credential fencing
waits for those children without killing them; child exit itself never proves remote
Create settled. Core must serialize lifecycle requests and never replay Create.
diff --git a/services/agents-api/tools/e2b-provider/credential_test.py b/services/agents-api/tools/e2b-provider/credential_test.py
index d35ec54d1..10eee9fde 100644
--- a/services/agents-api/tools/e2b-provider/credential_test.py
+++ b/services/agents-api/tools/e2b-provider/credential_test.py
@@ -26,6 +26,18 @@ def test_paginated_owned_template_and_public_readability_are_distinct(self, list
verify_team_template(config, lambda: 5)
self.assertEqual(error.exception.code, 'team_mismatch')
+ @patch('sdk.get_api_client')
+ @patch('e2b.api.client.api.templates.get_v2_templates.sync_detailed')
+ def test_team_listing_uses_custom_endpoint(self, listing, client):
+ template = object.__new__(Template)
+ template.template_id = 'owned'
+ listing.return_value = SimpleNamespace(status_code=200, parsed=[template], headers={})
+ verify_team_template({'Template': 'owned:build', 'APIKey': 'synthetic',
+ 'APIURL': 'https://sandbox.example.com', 'Domain': 'sandbox.example.com'}, lambda: 5)
+ connection = client.call_args.args[0]
+ self.assertEqual(connection.api_url, 'https://sandbox.example.com')
+ self.assertEqual(connection.domain, 'sandbox.example.com')
+
@patch('sdk.get_api_client')
@patch('e2b.api.client.api.templates.get_v2_templates.sync_detailed')
def test_unauthorized_and_repeated_cursor_fail_closed(self, listing, client):
diff --git a/services/agents-api/tools/e2b-provider/deployment_test.py b/services/agents-api/tools/e2b-provider/deployment_test.py
index 11f12ed9e..4008a4616 100644
--- a/services/agents-api/tools/e2b-provider/deployment_test.py
+++ b/services/agents-api/tools/e2b-provider/deployment_test.py
@@ -11,7 +11,7 @@
from e2b.api.client.models.template_with_builds import TemplateWithBuilds
from provider_test import ProviderTest
-from sdk import validate_deployment
+from sdk import read_metrics, sdk_options, validate_deployment
from state import Failure
from e2b.exceptions import SandboxNotFoundException
@@ -53,6 +53,24 @@ def test_candidate_validation_never_opens_allocation_receipt(self):
class BuildValidationTest(unittest.TestCase):
+ def test_custom_endpoint_is_explicit_in_sdk_options(self):
+ options = sdk_options({'APIKey': 'synthetic-key', 'APIURL': 'https://sandbox-test.sandbase.ai',
+ 'Domain': 'sandbox-test.sandbase.ai'}, lambda: 5)
+ self.assertEqual(options['api_url'], 'https://sandbox-test.sandbase.ai')
+ self.assertEqual(options['domain'], 'sandbox-test.sandbase.ai')
+ self.assertEqual(options['request_timeout'], 5)
+
+ @patch('sdk.get_api_client')
+ @patch('sdk.get_sandboxes_metrics.sync_detailed')
+ def test_custom_endpoint_reaches_metrics_client(self, metrics, client):
+ self.config.update(APIURL='https://sandbox-test.sandbase.ai', Domain='sandbox-test.sandbase.ai')
+ metrics.return_value = SimpleNamespace(status_code=503, parsed=None)
+ with self.assertRaises(Failure):
+ read_metrics(self.config, ['owned-id'], lambda: 5)
+ configuration = client.call_args.args[0]
+ self.assertEqual((configuration.api_url, configuration.domain),
+ (self.config['APIURL'], self.config['Domain']))
+
def setUp(self):
self.build_id = uuid4()
self.config = {'Template': 'test:' + str(self.build_id), 'APIKey': 'synthetic-key',
@@ -70,6 +88,7 @@ def response(self, builds, cursor=None):
@patch('sdk.get_api_client')
@patch('sdk.get_templates_template_id.sync_detailed')
def test_exact_build_on_later_page_and_bounded_options(self, get, client):
+ self.config.update(APIURL='https://sandbox-test.sandbase.ai', Domain='sandbox-test.sandbase.ai')
get.side_effect = [self.response([], 'next'), self.response([self.build])]
validate_deployment(self.config, lambda: 5)
self.assertEqual(get.call_count, 2)
@@ -77,6 +96,8 @@ def test_exact_build_on_later_page_and_bounded_options(self, get, client):
configuration = client.call_args.args[0]
self.assertEqual(configuration.retries, 0)
self.assertEqual(configuration.request_timeout, 5)
+ self.assertEqual((configuration.api_url, configuration.domain),
+ (self.config['APIURL'], self.config['Domain']))
@patch('sdk.get_api_client')
@patch('sdk.get_templates_template_id.sync_detailed')
diff --git a/services/agents-api/tools/e2b-provider/discovery_test.py b/services/agents-api/tools/e2b-provider/discovery_test.py
new file mode 100644
index 000000000..9ccb8b2bd
--- /dev/null
+++ b/services/agents-api/tools/e2b-provider/discovery_test.py
@@ -0,0 +1,85 @@
+"""Template discovery through the pinned generated SDK client."""
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import patch
+from uuid import uuid4
+import unittest
+
+from e2b.api.client.models.template import Template
+from e2b.api.client.models.template_build import TemplateBuild
+from e2b.api.client.models.template_build_status import TemplateBuildStatus
+from e2b.api.client.models.template_with_builds import TemplateWithBuilds
+
+from sdk import list_builds, list_templates
+from state import Failure
+
+
+class DiscoveryTest(unittest.TestCase):
+ def setUp(self):
+ self.config = {'APIKey': 'synthetic-key', 'APIURL': 'https://api.e2b.app',
+ 'Domain': 'e2b.app', 'Template': 'tpl_123'}
+ self.now = datetime.now(timezone.utc)
+
+ def template(self):
+ return Template(template_id='tpl_123', build_id=str(uuid4()), cpu_count=2,
+ memory_mb=2048, disk_size_mb=8192, public=False, aliases=[],
+ names=['runtime'], created_at=self.now, updated_at=self.now,
+ created_by=None, last_spawned_at=None, spawn_count=0,
+ build_count=2, envd_version='0.3', build_status=TemplateBuildStatus.READY)
+
+ def build(self, status):
+ return TemplateBuild(build_id=uuid4(), status=status, created_at=self.now,
+ updated_at=self.now, cpu_count=2, memory_mb=2048)
+
+ def page(self, builds, next_token=None):
+ body = TemplateWithBuilds(template_id='tpl_123', public=False, aliases=[],
+ names=['runtime'], created_at=self.now, updated_at=self.now,
+ last_spawned_at=None, spawn_count=0, builds=builds)
+ return SimpleNamespace(status_code=200, parsed=body,
+ headers={'x-next-token': next_token} if next_token else {})
+
+ @patch('sdk.get_api_client')
+ @patch('sdk.get_v2_templates.sync_detailed')
+ def test_visible_templates_and_empty_list(self, get, client):
+ get.return_value = SimpleNamespace(status_code=200, parsed=[self.template()], headers={})
+ self.assertEqual(list_templates(self.config, lambda: 5),
+ [{'id': 'tpl_123', 'names': ['runtime']}])
+ self.assertEqual(client.call_args.args[0].api_url, self.config['APIURL'])
+ self.assertEqual(get.call_args.kwargs['limit'], 100)
+ get.side_effect = [SimpleNamespace(status_code=200, parsed=[], headers={'x-next-token': 'cursor'}),
+ SimpleNamespace(status_code=200, parsed=[self.template()], headers={})]
+ self.assertEqual(len(list_templates(self.config, lambda: 5)), 1)
+ self.assertEqual(get.call_args.kwargs['next_token'], 'cursor')
+ get.side_effect = None
+ get.return_value = SimpleNamespace(status_code=200, parsed=[], headers={})
+ self.assertEqual(list_templates(self.config, lambda: 5), [])
+ get.return_value = SimpleNamespace(status_code=403, parsed=None)
+ with self.assertRaises(Failure) as failure:
+ list_templates(self.config, lambda: 5)
+ self.assertEqual(failure.exception.code, 'invalid')
+
+ @patch('sdk.get_api_client')
+ @patch('sdk.get_templates_template_id.sync_detailed')
+ def test_ready_builds_across_pages_and_empty_result(self, get, client):
+ ready, failed = self.build(TemplateBuildStatus.READY), self.build(TemplateBuildStatus.ERROR)
+ get.side_effect = [self.page([failed], 'cursor'), self.page([ready])]
+ self.assertEqual(list_builds(self.config, lambda: 5),
+ [{'id': str(ready.build_id), 'cpus': 2, 'memory_mib': 2048}])
+ self.assertEqual(get.call_args.kwargs['next_token'], 'cursor')
+ self.assertEqual(get.call_args.kwargs['limit'], 100)
+ self.assertEqual(client.call_args.args[0].domain, self.config['Domain'])
+ get.side_effect = None
+ get.return_value = self.page([])
+ self.assertEqual(list_builds(self.config, lambda: 5), [])
+
+ @patch('sdk.get_api_client')
+ @patch('sdk.get_templates_template_id.sync_detailed')
+ def test_repeated_cursor_and_provider_failure(self, get, _client):
+ get.return_value = self.page([], 'cursor')
+ with self.assertRaises(Failure) as failure:
+ list_builds(self.config, lambda: 5)
+ self.assertEqual(failure.exception.code, 'unconfirmed')
+ get.return_value = SimpleNamespace(status_code=503, parsed=None)
+ with self.assertRaises(Failure) as failure:
+ list_builds(self.config, lambda: 5)
+ self.assertEqual(failure.exception.code, 'unconfirmed')
diff --git a/services/agents-api/tools/e2b-provider/provider.py b/services/agents-api/tools/e2b-provider/provider.py
index 14202940b..99925d257 100644
--- a/services/agents-api/tools/e2b-provider/provider.py
+++ b/services/agents-api/tools/e2b-provider/provider.py
@@ -11,7 +11,7 @@
from e2b.api.client.models.sandbox_metric import SandboxMetric
from e2b.exceptions import AuthenticationException, FileNotFoundException, SandboxNotFoundException
-from sdk import connection_material, definitely_rejected, read_metrics, restore, run, validate_deployment, verify_team_template
+from sdk import connection_material, definitely_rejected, list_builds, list_templates, read_metrics, restore, run, sdk_options, validate_deployment, verify_team_template
from state import Failure, Receipt, private_root, read_receipt
PREFIX = 'oac_'
@@ -68,19 +68,20 @@ def __init__(self, request):
self.reference = request['Reference']
self.references = request.get('References') or []
if (request['Version'] != 1 or request['Operation'] not in
- ('create', 'inspect', 'renew', 'kill', 'command', 'validate_deployment', 'observe', 'verify_credential') or
- (request['Operation'] not in ('validate_deployment', 'observe', 'verify_credential') and
+ ('create', 'inspect', 'renew', 'kill', 'command', 'validate_deployment', 'observe', 'list_templates', 'list_builds', 'verify_credential') or
+ (request['Operation'] not in ('validate_deployment', 'observe', 'list_templates', 'list_builds', 'verify_credential') and
not valid_reference(self.reference)) or
(request['Operation'] == 'observe' and
(not 1 <= len(self.references) <= 100 or
not all(valid_reference(r) for r in self.references) or
len({tuple(sorted(r.items())) for r in self.references}) != len(self.references))) or
- not valid_id(self.config['InstallationID'])):
+ (request['Operation'] not in ('list_templates', 'list_builds') and
+ not valid_id(self.config['InstallationID']))):
raise Failure('invalid')
deadline = datetime.fromisoformat(request['Deadline'].replace('Z', '+00:00'))
self.deadline = time.monotonic() + (deadline - datetime.now(timezone.utc)).total_seconds()
self.metadata = {PREFIX + field.lower(): value for field, value in
- dict(self.reference, InstallationID=self.config['InstallationID']).items()}
+ dict(self.reference, InstallationID=self.config.get('InstallationID', '')).items()}
self.receipt = None
def remaining(self):
@@ -90,8 +91,7 @@ def remaining(self):
return remaining
def options(self):
- return {'api_key': self.config['APIKey'], 'retries': 0, 'debug': False,
- 'request_timeout': self.remaining()}
+ return sdk_options(self.config, self.remaining)
def info(self, cloud=None, absent=False):
record = self.receipt.data or {}
@@ -114,11 +114,21 @@ def owns(self, cloud):
raise Failure('ownership')
return cloud
+ def check_domain(self, cloud):
+ domain = self.options()['domain']
+ sandbox_domain = cloud.sandbox_domain
+ if not isinstance(sandbox_domain, str) or not (
+ sandbox_domain == domain or sandbox_domain.endswith('.' + domain)):
+ raise Failure('ownership')
+
def qualified(self, cloud):
+ self.check_domain(cloud)
+ template = self.config['Template'].split(':', 1)[0]
+ if cloud.template_id not in (template, self.config['Template']):
+ raise Failure('invalid')
resources = self.config.get('Resources')
if resources is not None:
- template = self.config['Template'].split(':', 1)[0]
- if (cloud.template_id != template or type(cloud.cpu_count) is not int or
+ if (type(cloud.cpu_count) is not int or
cloud.cpu_count != resources['cpus'] or type(cloud.memory_mb) is not int or
cloud.memory_mb != resources['memory_mib']):
raise Failure('invalid')
@@ -198,14 +208,17 @@ def create(self):
self.receipt.save(status='rejected', settled=True)
raise Failure('unconfirmed') from None
self.receipt.save(status='created', ids=[cloud.sandbox_id], connection=connection_material(cloud))
- # Creation responses do not include resources. Inspect before credentials
- # or bootstrap are written, retaining the allocation for owned cleanup.
- if self.config.get('Resources') is not None:
- try:
- self.qualified(self.owns(Sandbox.get_info(cloud.sandbox_id, **self.options())))
- except Failure:
- self.receipt.save(status='configuration_rejected', settled=True)
- raise
+ # A create response must not steer envd traffic to an unrelated host.
+ self.check_domain(cloud)
+ # SDK Create returns connection material, but no metadata or resources.
+ # Read its exact ID before writing credentials, even when Core adopts the
+ # template's resources and does not supply explicit limits.
+ try:
+ detail = self.owns(Sandbox.get_info(cloud.sandbox_id, **self.options()))
+ self.qualified(detail)
+ except Failure:
+ self.receipt.save(status='configuration_rejected', settled=True)
+ raise
# Refuse a pre-rename template before writing any executor credential.
check = run(cloud, {'Args': ['/usr/bin/python3', '-I', '-c',
"import os,sys; sys.exit(78 if not os.path.isfile('/opt/oac-e2b/managed_init.py') and os.path.isdir('/opt/parsar-e2b') else 0)"]},
@@ -358,8 +371,12 @@ def verify_credential(self):
raise Failure('unconfirmed')
def execute(self):
- if self.q['Operation'] in ('validate_deployment', 'observe', 'verify_credential'):
+ if self.q['Operation'] in ('validate_deployment', 'observe', 'verify_credential', 'list_templates', 'list_builds'):
try:
+ if self.q['Operation'] == 'list_templates':
+ return {'Version': 1, 'Templates': list_templates(self.config, self.remaining), 'ErrorCode': ''}
+ if self.q['Operation'] == 'list_builds':
+ return {'Version': 1, 'Builds': list_builds(self.config, self.remaining), 'ErrorCode': ''}
if self.q['Operation'] == 'verify_credential':
self.verify_credential()
return {'Version': 1, 'DeploymentValid': True, 'ErrorCode': ''}
diff --git a/services/agents-api/tools/e2b-provider/provider_test.py b/services/agents-api/tools/e2b-provider/provider_test.py
index fdd275ef3..ba4c6807f 100644
--- a/services/agents-api/tools/e2b-provider/provider_test.py
+++ b/services/agents-api/tools/e2b-provider/provider_test.py
@@ -32,6 +32,7 @@ def setUp(self):
self.cloud = Mock(sandbox_id='owned-id', sandbox_domain='e2b.app', _envd_version='0.5.0',
_envd_access_token='private-envd-secret', traffic_access_token=None, state='running')
self.cloud.metadata = Provider(self.request).metadata
+ self.cloud.template_id = self.config['Template']
self.identity = dict(self.reference, InstallationID=self.config['InstallationID'],
SessionID=self.request['Bootstrap']['SessionID'], DeviceID=self.request['Bootstrap']['DeviceID'])
self.ready = json.dumps({'identity': self.identity, 'status': 'daemon_started', 'daemon_pid': 123})
@@ -74,6 +75,67 @@ def test_create_recover_and_never_replay(self):
self.assertEqual(self.record()['connection']['envd_access_token'], 'private-envd-secret')
self.api.connect.assert_not_called()
+ def test_create_response_without_metadata_reads_exact_id_before_bootstrap(self):
+ created = SimpleNamespace(sandbox_id=self.cloud.sandbox_id,
+ sandbox_domain=self.cloud.sandbox_domain,
+ _envd_version=self.cloud._envd_version,
+ _envd_access_token=self.cloud._envd_access_token,
+ traffic_access_token=self.cloud.traffic_access_token,
+ files=self.cloud.files)
+ self.api.create.return_value = created
+ self.cloud.files.write.side_effect = lambda *args, **kwargs: self.assertEqual(
+ self.api.get_info.call_count, 1)
+ self.assertEqual(self.call('create')['ErrorCode'], '')
+ self.assertEqual(self.api.get_info.call_args.args, (created.sandbox_id,))
+ self.cloud.files.write.assert_called_once()
+
+ def test_create_without_resource_override_refuses_other_build_before_credentials(self):
+ self.cloud.template_id = self.config['Template'].split(':', 1)[0] + ':' + str(uuid4())
+ self.assertEqual(self.call('create')['ErrorCode'], 'invalid')
+ self.api.get_info.assert_called_once()
+ self.cloud.files.write.assert_not_called()
+
+ def test_create_refuses_other_owner_before_credentials(self):
+ self.cloud.metadata = {}
+ self.assertEqual(self.call('create')['ErrorCode'], 'ownership')
+ self.api.get_info.assert_called_once()
+ self.cloud.files.write.assert_not_called()
+
+ def test_qualified_gateway_template_id_accepts_only_selected_build(self):
+ self.config['Resources'] = {'cpus': 2, 'memory_mib': 2048}
+ self.cloud.template_id = self.config['Template']
+ self.cloud.cpu_count = 2
+ self.cloud.memory_mb = 2048
+ self.assertEqual(self.call('create')['ErrorCode'], '')
+ self.cloud.template_id = self.config['Template'].split(':', 1)[0] + ':' + str(uuid4())
+ self.assertEqual(self.call('inspect')['ErrorCode'], 'invalid')
+
+ def test_custom_endpoint_reaches_create_inspect_and_renew(self):
+ self.config.update(APIURL='https://sandbox-test.sandbase.ai', Domain='sandbox-test.sandbase.ai')
+ self.cloud.sandbox_domain = 'sandbox-test.sandbase.ai'
+ self.assertEqual(self.call('create')['ErrorCode'], '')
+ self.assertEqual(self.call('renew')['ErrorCode'], '')
+ for operation in (self.api.create, self.api.get_info, self.api.set_timeout):
+ self.assertEqual(operation.call_args.kwargs['api_url'], self.config['APIURL'])
+ self.assertEqual(operation.call_args.kwargs['domain'], self.config['Domain'])
+ self.assertEqual(self.record()['endpoint'], {'api_url': self.config['APIURL'],
+ 'domain': self.config['Domain']})
+
+ def test_custom_endpoint_reaches_unknown_create_discovery(self):
+ self.config.update(APIURL='https://sandbox-test.sandbase.ai', Domain='sandbox-test.sandbase.ai')
+ self.api.create.side_effect = TimeoutError('uncertain')
+ self.assertEqual(self.call('create')['ErrorCode'], 'unconfirmed')
+ self.call('inspect')
+ self.assertEqual(self.api.list.call_args.kwargs['api_url'], self.config['APIURL'])
+ self.assertEqual(self.api.list.call_args.kwargs['domain'], self.config['Domain'])
+
+ def test_create_refuses_foreign_data_plane_before_envd(self):
+ self.cloud.sandbox_domain = 'foreign.example'
+ self.assertEqual(self.call('create')['ErrorCode'], 'ownership')
+ self.assertEqual(self.record()['ids'], ['owned-id'])
+ self.cloud.files.write.assert_not_called()
+ self.cloud.commands.run.assert_not_called()
+
def test_legacy_template_refuses_before_credentials_and_retains_owned_cleanup(self):
with patch('provider.run', return_value={'ExitCode': 78, 'Stdout': '', 'Stderr': ''}):
result = self.call('create')
diff --git a/services/agents-api/tools/e2b-provider/sdk.py b/services/agents-api/tools/e2b-provider/sdk.py
index 9a3cb7ff3..b249133b3 100644
--- a/services/agents-api/tools/e2b-provider/sdk.py
+++ b/services/agents-api/tools/e2b-provider/sdk.py
@@ -10,7 +10,8 @@
from e2b.api.client_sync import get_api_client
from e2b.api.client.api.sandboxes import get_sandboxes_metrics
-from e2b.api.client.api.templates import get_templates_template_id
+from e2b.api.client.api.templates import get_v2_templates, get_templates_template_id
+from e2b.api.client.models.template import Template
from e2b.api.client.models.sandboxes_with_metrics import SandboxesWithMetrics
from e2b.api.client.models.template_with_builds import TemplateWithBuilds
from e2b.api.client.types import UNSET
@@ -21,6 +22,61 @@
MAX_OUTPUT = 1024 * 1024
+def list_templates(config, remaining):
+ cursor, seen, result = UNSET, set(), []
+ for _ in range(20):
+ client = get_api_client(ConnectionConfig(**sdk_options(config, remaining)))
+ response = get_v2_templates.sync_detailed(client=client, next_token=cursor, limit=100)
+ if response.status_code != 200 or not isinstance(response.parsed, list):
+ raise Failure('invalid' if response.status_code in (400, 401, 403, 422) else 'unconfirmed')
+ for item in response.parsed:
+ if not isinstance(item, Template) or not isinstance(item.template_id, str):
+ raise Failure('unconfirmed')
+ names = [name for name in item.names if isinstance(name, str) and len(name) <= 128][:20]
+ result.append({'id': item.template_id, 'names': names})
+ if len(result) > 200:
+ raise Failure('unconfirmed')
+ cursor = response.headers.get('x-next-token')
+ if not cursor:
+ return result
+ if len(cursor) > 4096 or cursor in seen:
+ raise Failure('unconfirmed')
+ seen.add(cursor)
+ raise Failure('unconfirmed')
+
+
+def list_builds(config, remaining):
+ template = config['Template']
+ cursor, seen, result = UNSET, set(), []
+ for _ in range(20):
+ client = get_api_client(ConnectionConfig(**sdk_options(config, remaining)))
+ response = get_templates_template_id.sync_detailed(template_id=template, client=client,
+ next_token=cursor, limit=100)
+ if response.status_code != 200 or not isinstance(response.parsed, TemplateWithBuilds):
+ raise Failure('invalid' if response.status_code in (400, 401, 403, 404, 422) else 'unconfirmed')
+ if response.parsed.template_id != template:
+ raise Failure('unconfirmed')
+ for build in response.parsed.builds:
+ if build.status.value == 'ready' and type(build.cpu_count) is int and build.cpu_count > 0 and type(build.memory_mb) is int and build.memory_mb > 0:
+ result.append({'id': str(build.build_id), 'cpus': build.cpu_count, 'memory_mib': build.memory_mb})
+ if len(result) > 200:
+ raise Failure('unconfirmed')
+ cursor = response.headers.get('x-next-token')
+ if not cursor:
+ return result
+ if len(cursor) > 4096 or cursor in seen:
+ raise Failure('unconfirmed')
+ seen.add(cursor)
+ raise Failure('unconfirmed')
+
+
+def sdk_options(config, remaining):
+ # Explicit selectors survive the helper's removal of ambient E2B_* values.
+ return {'api_key': config['APIKey'], 'api_url': config.get('APIURL') or 'https://api.e2b.app',
+ 'domain': config.get('Domain') or 'e2b.app', 'retries': 0, 'debug': False,
+ 'request_timeout': remaining()}
+
+
def validate_deployment(config, remaining):
"""Read the exact ready build through the pinned SDK, without allocating.
@@ -37,8 +93,7 @@ def validate_deployment(config, remaining):
template, build_id = config['Template'].split(':', 1)
cursor, seen = UNSET, set()
for _ in range(100):
- client = get_api_client(ConnectionConfig(api_key=config['APIKey'], retries=0,
- debug=False, request_timeout=remaining()))
+ client = get_api_client(ConnectionConfig(**sdk_options(config, remaining)))
response = get_templates_template_id.sync_detailed(template_id=template, client=client,
next_token=cursor, limit=100)
if response.status_code != 200 or not isinstance(response.parsed, TemplateWithBuilds):
@@ -70,8 +125,7 @@ def read_metrics(config, sandbox_ids, remaining):
"""Latest metrics point per sandbox from one batch request of at most 100 IDs."""
if not 1 <= len(sandbox_ids) <= 100:
raise Failure('invalid')
- client = get_api_client(ConnectionConfig(api_key=config['APIKey'], retries=0,
- debug=False, request_timeout=remaining()))
+ client = get_api_client(ConnectionConfig(**sdk_options(config, remaining)))
response = get_sandboxes_metrics.sync_detailed(client=client, sandbox_ids=sandbox_ids)
if (response.status_code != 200 or not isinstance(response.parsed, SandboxesWithMetrics) or
not isinstance(response.parsed.sandboxes, dict)):
@@ -151,8 +205,7 @@ def verify_team_template(config, remaining):
wanted = config['Template'].split(':', 1)[0]
cursor, seen = UNSET, set()
for _ in range(100):
- client = get_api_client(ConnectionConfig(api_key=config['APIKey'], retries=0,
- debug=False, request_timeout=remaining()))
+ client = get_api_client(ConnectionConfig(**sdk_options(config, remaining)))
response = get_v2_templates.sync_detailed(client=client, next_token=cursor, limit=100)
if response.status_code in (401, 403):
raise Failure('unauthorized')
diff --git a/services/agents-api/tools/e2b-provider/state.py b/services/agents-api/tools/e2b-provider/state.py
index b1b4a3e52..b7bb99db4 100644
--- a/services/agents-api/tools/e2b-provider/state.py
+++ b/services/agents-api/tools/e2b-provider/state.py
@@ -33,7 +33,12 @@ def receipt_digest(identity):
return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
-def load_receipt(path, identity):
+def endpoint_identity(config):
+ return {'api_url': config.get('APIURL') or 'https://api.e2b.app',
+ 'domain': config.get('Domain') or 'e2b.app'}
+
+
+def load_receipt(path, identity, config):
"""Return one committed receipt, or None; os.replace publishes whole versions."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
@@ -46,17 +51,21 @@ def load_receipt(path, identity):
data = json.load(source)
if data.get('identity') != identity or data.get('version') != 1:
raise Failure('ownership')
+ # Receipts from releases before custom endpoints belong to official E2B.
+ if data.get('endpoint', endpoint_identity({})) != endpoint_identity(config):
+ raise Failure('ownership')
return data
def read_receipt(config, root, reference):
"""Read without the allocation lock. Observation never waits for or writes receipts."""
identity = dict(reference, InstallationID=config['InstallationID'])
- return load_receipt(root / (receipt_digest(identity) + '.json'), identity)
+ return load_receipt(root / (receipt_digest(identity) + '.json'), identity, config)
class Receipt:
def __init__(self, request, remaining):
+ self.config = request['Config']
self.identity = dict(request['Reference'], InstallationID=request['Config']['InstallationID'])
self.root = private_root(request['Config'])
digest = receipt_digest(self.identity)
@@ -74,7 +83,7 @@ def __enter__(self):
break
except BlockingIOError:
time.sleep(min(.05, self.remaining()))
- self.data = load_receipt(self.path, self.identity)
+ self.data = load_receipt(self.path, self.identity, self.config)
return self
except BaseException:
os.close(self.lock)
@@ -85,6 +94,7 @@ def __exit__(self, *unused):
def save(self, **values):
data = dict(self.data or {'version': 1, 'identity': self.identity,
+ 'endpoint': endpoint_identity(self.config),
'ids': [], 'settled': False, 'bootstrap_complete': False})
data.update(values)
temporary = self.path.with_suffix('.tmp')
diff --git a/services/agents-api/tools/e2b-provider/state_test.py b/services/agents-api/tools/e2b-provider/state_test.py
index 9b01877aa..ae573370a 100644
--- a/services/agents-api/tools/e2b-provider/state_test.py
+++ b/services/agents-api/tools/e2b-provider/state_test.py
@@ -55,6 +55,25 @@ def test_receipt_symlink_is_never_followed(self):
pass
self.assertEqual(witness.read_text(), 'unchanged')
+ def test_receipt_is_bound_to_endpoint_and_legacy_is_official(self):
+ with Receipt(self.request, lambda: 1) as receipt:
+ receipt.save(status='create_pending')
+ path = receipt.path
+ custom = dict(self.request, Config=dict(self.request['Config'],
+ APIURL='https://sandbox-test.sandbase.ai',
+ Domain='sandbox-test.sandbase.ai'))
+ with self.assertRaises(Failure):
+ with Receipt(custom, lambda: 1):
+ pass
+ data = json.loads(path.read_text())
+ del data['endpoint']
+ path.write_text(json.dumps(data))
+ with Receipt(self.request, lambda: 1) as receipt:
+ self.assertEqual(receipt.data['status'], 'create_pending')
+ with self.assertRaises(Failure):
+ with Receipt(custom, lambda: 1):
+ pass
+
def test_foreign_identity_and_world_readable_receipt_rejected(self):
with Receipt(self.request, lambda: 1) as receipt:
receipt.save(status='create_pending')