diff --git a/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx b/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx index f05ee6363..5cb345694 100644 --- a/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx +++ b/apps/docs/content/docs/api-reference/core/sandbox-manager.mdx @@ -36,6 +36,14 @@ _openapi: Requires the current generation. Maintenance preserves execution, cleanup and history. Resume requires successfully activated provider configuration. + - content: >- + 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. + - content: >- + Core key only. Reads one template through the pinned SDK helper with a + transient E2B credential. Returns ready builds only, without + allocating compute. - content: >- Core key only. Does not grant project resource access. Responses contain only explicit safe fields. @@ -58,4 +66,4 @@ _openapi: {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - \ No newline at end of file + \ No newline at end of file diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 977e5fc76..2ac78b736 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -54,7 +54,7 @@ with the Core key. | `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/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](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/contracts/agents-api/installation.md) | | `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/maintenance]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, including E2B API endpoint and data-plane domain, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/contracts/agents-api/node-host-history.md) | +| `sandbox/deployment[/maintenance]`, `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 and read-only E2B template discovery, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/contracts/agents-api/node-host-history.md) | | `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/75bf4484ba957344ae374354858bbdfe104f5c4f/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 52367c9b3..d55b0d1d8 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -8,7 +8,7 @@ "docs/configuration.md": "d892fc8dda2ead6041b154e0f8104a7720e0df96d2058c161c86403f774f7752", "docs/web/core-connection.md": "46f86520e70bf41079708e2c398655aa9087ad9488c8e661d9a73035d11098c4", "docs/getting-started/quickstart.md": "5effeffcfb343f7939910f0c1758e1492d49e7bd66c409b1819cefe67af592d7", - "docs/api/README.md": "f6b22aec751a5e733e21dcbe8991747a032bcab0a9e7b5b7814711b47d5d664e", + "docs/api/README.md": "0ce9d3dc76e84cb6d768e23589ff48ae9d5cb7079b30994bcd895b77d17afbf0", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "18829b366bd2e49fe5beb76dc8a709bc1aa8e35d4fbfd2484a0c52b46cf75417", "contracts/agents-api/environments.md": "89ffc903ee44d00ee87c16950202a3ca4c8345160780dcdd4ed2a71b25b1c2dc", @@ -36,7 +36,7 @@ "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", "content/docs/quickstart.mdx": "92f62b5df213c363bd7148de0462b2ca66f2054f4458d790196e75daa678bb24", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "ae6e7279136625e9030603ae0399edd97690e905d55ee8787caedfea01306b7c", + "content/docs/public-api.mdx": "03007980296ed7bd4b601c26f3c992ea38f64ccb16a20306533b80011e869976", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", "content/docs/agents-and-tools.mdx": "78789f7e6a1d327ff514d872b9f8b55a88754ffbfc34b7b10e5f7c115f242e3c", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index 445c9cad1..24cfc9eef 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -3410,6 +3410,93 @@ paths: $ref: '#/components/schemas/store.SandboxMaintenanceRequest' description: Maintenance state required: true + /core/v1/sandbox/e2b/templates: + post: + 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. + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryResult' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List templates visible to an E2B credential + tags: + - Sandbox Manager + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryInput' + description: Transient E2B connection + required: true + /core/v1/sandbox/e2b/templates/{template_id}/builds: + post: + 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: + - schema: + type: string + description: Template ID + in: path + name: template_id + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryResult' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List ready builds for an E2B template + tags: + - Sandbox Manager + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/api.SandboxE2BDiscoveryInput' + description: Transient E2B connection + required: true /core/v1/sandbox/enrollment-tokens: post: description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. @@ -4384,6 +4471,26 @@ components: runtime: $ref: '#/components/schemas/sandbox.RuntimeRelease' type: object + api.SandboxE2BDiscoveryInput: + properties: + api_key: + type: string + api_url: + type: string + domain: + type: string + type: object + api.SandboxE2BDiscoveryResult: + properties: + builds: + items: + $ref: '#/components/schemas/e2b.ReadyBuild' + type: array + templates: + items: + $ref: '#/components/schemas/e2b.TemplateSummary' + type: array + type: object api.SandboxE2BInput: properties: api_key: @@ -4653,6 +4760,24 @@ components: service: $ref: '#/components/schemas/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: diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index 9eda88e73..480627266 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,7 +1,7 @@ { "sources": { "contracts/agents-api/openapi.yaml": "a8896e8b67d78ed0608c7cc5636077ec33f4b4895a04bd6cfb8b038afe941e01", - "contracts/agents-api/core.openapi.yaml": "9dda7145c814f41564459fc721eab5f8de4fbbde19377b8d393330aed5b62fbb", + "contracts/agents-api/core.openapi.yaml": "814e9e628f1388e5a409a8402441f8d26cacf8f12a08285bb05974f1ddba302d", "contracts/agents-api/runtime.openapi.yaml": "b3fb025b20a0bcf4635d60339479b2b07f6dbed80f51e72f40a63b31e42b5f4a" }, "outputs": { @@ -39,8 +39,8 @@ "content/docs/api-reference/core/skills.mdx": "ed899ade7b032b1732b3097d81419a8fdbb9ead0594fca9f2107da644b97031f", "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", - "content/docs/api-reference/core/sandbox-manager.mdx": "36f2895b9969d27be843d5cebf935bf14ca9400ad076e56074a25905e3df5d1b", - "openapi/core-api.yaml": "275df1b60daf38d35f81db0c183fffca8984bf552f8638d820e4e52d4281d6fe", + "content/docs/api-reference/core/sandbox-manager.mdx": "466e37449faedc71e7c17e7da14264973a066cf7f770c018be0ba932a66bd5e5", + "openapi/core-api.yaml": "e3b735dff5879d31f66ad7ba360211a169a54e7a3a0fa3df4c71116ed0aac36e", "content/docs/api-reference/core/meta.json": "588b163ed2de2100b0f8376688274f80698a6bf6ac037eef56d17c93fa8a5062", "content/docs/api-reference/core/index.mdx": "ddf0a8d87a6c21761beac703a32f2a40486a1c74abb341bd726f40c44a866405", "content/docs/api-reference/machine/sandbox-node.mdx": "5b367a08d43cea82686345d2ba1c75cc90627a7b2970fe42a0df9d77fb3e1e80", diff --git a/apps/docs/scripts/check-python.test.mjs b/apps/docs/scripts/check-python.test.mjs index e6bce3365..75b0a9518 100644 --- a/apps/docs/scripts/check-python.test.mjs +++ b/apps/docs/scripts/check-python.test.mjs @@ -21,7 +21,7 @@ test('route gate and Python regressions honor the selected interpreter without s cwd: appRoot, env: { ...process.env, OAC_TEST_OFFICIAL_SDK_PYTHON: selected }, encoding: 'utf8', }) assert.equal(result.status, 0, result.stdout + result.stderr) - if (mode === 'routes') assert.match(result.stdout, /Contract operations:\s+130/) + if (mode === 'routes') assert.match(result.stdout, /Contract operations:\s+132/) else assert.match(result.stderr, /Ran 2 tests/) } const invocations = fs.readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)) diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 91e00a8d5..d141b3f65 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -32,6 +32,13 @@ export async function openConsole(page: Page, request: APIRequestContext, hash = await page.goto(`/#${hash}`); } +/** Selects the ready build returned by the E2B discovery fixture. */ +export async function selectFixtureE2BBuild(page: Page) { + await page.getByLabel("E2B API key").fill("fixture-private-key"); + await page.getByLabel("Template", { exact: true }).selectOption("template"); + await page.getByLabel("Template build").selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe"); +} + /** Makes the next matching write fail once with the given status. */ export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string }) { await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index e2b25125b..b15ecef40 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -303,6 +303,18 @@ function nodeDetail(node) { } async function sandboxRoute(request, response, path) { + const e2bTemplates = ["/e2b/templates", "/e2b/templates/template/builds"]; + if (e2bTemplates.includes(path)) { + if (request.method !== "POST") return error(response, 405, "Method not allowed."); + const input = await body(request); + const knownEndpoint = [ + ["https://sandbox.sandbase.ai", "sandbox.sandbase.ai"], + ["https://api.e2b.app", "e2b.app"], + ].some(([apiURL, domain]) => input.api_url === apiURL && input.domain === domain); + if (input.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection."); + if (path === "/e2b/templates") return send(response, 200, { templates: [{ id: "template", names: ["fixture-runtime"] }] }); + return send(response, 200, { builds: [{ id: "94be54a1-138c-4f30-bc87-b13686272dbe", cpus: 2, memory_mib: 2048 }] }); + } if (path === "/runtime-observations" && request.method === "GET") { // Only E2B reports a sandbox's disk. const e2b = state.deployment?.provider === "e2b"; @@ -550,4 +562,3 @@ http.createServer(async (request, response) => { error(response, 500, String(caught)); } }).listen(port, "127.0.0.1", () => console.log(`Console acceptance fixture on http://127.0.0.1:${port}`)); - diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 031c20562..15155fb5d 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, failNext, openConsole, resetFixture, setNode, writes } from "./console"; +import { expectManagementBoundary, failNext, openConsole, resetFixture, selectFixtureE2BBuild, setNode, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -247,14 +247,56 @@ test("preselects microsandbox and asks once before switching to Docker", async ( }); test("saves E2B without opening Add node, as it has no machines", async ({ page, request }) => { + let submitted: Record | null = null; + page.on("request", (sent) => { + if (sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/deployment")) submitted = sent.postDataJSON() as Record; + }); await openConsole(page, request, "nodes", { sandbox: "none" }); await page.getByRole("button", { name: "E2B cloud" }).click(); - await page.getByLabel("E2B API key").fill("fixture-private-key"); - await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await expect(page.getByLabel("E2B provider")).toHaveValue("sandbase"); + await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://sandbox.sandbase.ai"); + await expect(page.getByLabel("Sandbox data-plane domain")).toHaveValue("sandbox.sandbase.ai"); + const keyConsole = page.getByRole("link", { name: "Console → API Keys" }); + await expect(keyConsole.locator("..")).toHaveClass(/field-label-row/); + await expect(keyConsole).toHaveCSS("font-size", "11px"); + await expect(keyConsole).toHaveAttribute("href", "https://www.sandbase.ai/console/keys"); + await expect(keyConsole).toHaveAttribute("target", "_blank"); + await expect(keyConsole).toHaveAttribute("rel", "noopener noreferrer"); + await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Save configuration" }).click(); await expect(page.getByRole("heading", { name: "Sandbox backend", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); + expect(submitted).toMatchObject({ provider: "e2b", e2b: { + api_key: "fixture-private-key", template: "template:94be54a1-138c-4f30-bc87-b13686272dbe", + api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai", + } }); + expect(await writes(request)).toEqual([ + "POST /core/v1/sandbox/e2b/templates", + "POST /core/v1/sandbox/e2b/templates/template/builds", + "POST /core/v1/sandbox/deployment", + ]); +}); + +test("uses the official E2B preset and clears a selected build when the key changes", async ({ page, request }) => { + await openConsole(page, request, "nodes", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud" }).click(); + await page.getByLabel("E2B provider").selectOption("official"); + await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://api.e2b.app"); + await expect(page.getByLabel("Sandbox data-plane domain")).toHaveValue("e2b.app"); + const keyConsole = page.getByRole("link", { name: "Console → API Keys" }); + await expect(keyConsole).toHaveAttribute("href", "https://e2b.dev/dashboard?tab=api-keys"); + await page.getByLabel("Sandbox API URL").fill("https://custom.e2b.app"); + await expect(keyConsole).toHaveCount(0); + await page.getByLabel("E2B provider").selectOption("custom"); + await expect(keyConsole).toHaveCount(0); + await page.getByLabel("E2B provider").selectOption("official"); + await selectFixtureE2BBuild(page); + await expect(page.getByRole("button", { name: "Next" })).toBeEnabled(); + await page.getByLabel("E2B API key").fill("changed-fixture-key"); + await expect(page.getByLabel("Template", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Template build")).toHaveValue(""); + await expect(page.getByRole("button", { name: "Next" })).toBeDisabled(); }); test("keeps the saved size and Runtime for the same backend, and starts another from its defaults", async ({ page, request }) => { @@ -300,8 +342,7 @@ test("keeps the saved size and Runtime for the same backend, and starts another await back.click(); await back.click(); await page.getByRole("button", { name: "E2B cloud" }).click(); - await page.getByLabel("E2B API key").fill("fixture-private-key"); - await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); await save.click(); await expect.poll(() => submitted?.provider).toBe("e2b"); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 156e4f4df..467eb1b74 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole, writes } from "./console"; +import { expectManagementBoundary, openConsole, selectFixtureE2BBuild, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -24,8 +24,7 @@ test("sets up sandboxes with Core's address read-only, never sending it", async test("explains an E2B rejection in the wizard, with the file to edit and the command to apply it", async ({ page, request }) => { await openConsole(page, request, "nodes", { sandbox: "none", installation: "local" }); await page.getByRole("button", { name: "E2B cloud" }).click(); - await page.getByLabel("E2B API key").fill("fixture-private-key"); - await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); const address = page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" }); await expect(address).toContainText("Only the Core machine can reach this address"); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 96e146e03..37ff78cdb 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,9 +1,9 @@ -import type { InitializeSandboxDeployment, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@agents-core-web/agents-client"; +import type { InitializeSandboxDeployment, SandboxE2BReadyBuild, SandboxE2BTemplate, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@agents-core-web/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; -import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; -import { useId, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; +import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -16,12 +16,27 @@ import type { MessageKey } from "../../lib/locale-strings"; import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; +import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; type Where = "nodes" | "direct"; type Step = "where" | "backend" | "e2b" | "size" | "review" | "advanced"; type Preset = "small" | "standard" | "large"; type Size = Preset | "current" | "custom"; +type E2BService = "sandbase" | "official" | "custom"; +const E2B_PRESETS = { + sandbase: { apiURL: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" }, + official: { apiURL: "https://api.e2b.app", domain: "e2b.app" }, +} as const; +const E2B_KEY_CONSOLES = { + sandbase: "https://www.sandbase.ai/console/keys", + official: "https://e2b.dev/dashboard?tab=api-keys", +} as const; +function e2bService(apiURL?: string): E2BService { + if (!apiURL) return "sandbase"; + if (apiURL === E2B_PRESETS.official.apiURL) return "official"; + return apiURL === E2B_PRESETS.sandbase.apiURL ? "sandbase" : "custom"; +} const MIB = 2 ** 20; const EASE = [0.16, 1, 0.3, 1] as const; @@ -109,8 +124,17 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); const [apiKey, setApiKey] = useState(""); const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); - const [apiURL, setAPIURL] = useState(current?.e2bAPIURL === "https://api.e2b.app" ? "" : current?.e2bAPIURL ?? ""); - const [domain, setDomain] = useState(current?.e2bDomain === "e2b.app" ? "" : current?.e2bDomain ?? ""); + const [service, setService] = useState(e2bService(current?.e2bAPIURL)); + const [apiURL, setAPIURL] = useState(current?.e2bAPIURL ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.apiURL ?? ""); + const [domain, setDomain] = useState(current?.e2bDomain ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.domain ?? ""); + const keyConsoleURL = service !== "custom" && apiURL === E2B_PRESETS[service].apiURL && domain === E2B_PRESETS[service].domain + ? E2B_KEY_CONSOLES[service] : null; + const [templates, setTemplates] = useState([]); + const [builds, setBuilds] = useState([]); + const [selectedTemplate, setSelectedTemplate] = useState(current?.e2bTemplate?.split(":")[0] ?? ""); + const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const [discoveryRetry, setDiscoveryRetry] = useState(0); const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker"); @@ -122,6 +146,35 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal const address = coreUrl || installation.data?.public_url || null; const configuration = installation.data?.configuration ?? null; + useEffect(() => { + setTemplates([]); setBuilds([]); setDiscovery("idle"); setBuildDiscovery("idle"); + if (provider !== "e2b" || !apiKey.trim() || !validEndpoint(apiURL.trim(), domain.trim())) return; + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setDiscovery("loading"); + void sandboxAdmin.listE2BTemplates({ api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) + .then((items) => { if (!controller.signal.aborted) { setTemplates(items); setDiscovery("ready"); } }) + .catch(() => { if (!controller.signal.aborted) setDiscovery("error"); }); + }, 500); + return () => { window.clearTimeout(timer); controller.abort(); }; + }, [provider, apiKey, apiURL, domain, discoveryRetry]); + + useEffect(() => { + setBuilds([]); setBuildDiscovery("idle"); + if (discovery !== "ready" || !selectedTemplate || !templates.some((item) => item.id === selectedTemplate)) return; + const controller = new AbortController(); + setBuildDiscovery("loading"); + void sandboxAdmin.listE2BReadyBuilds(selectedTemplate, { api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) + .then((items) => { if (!controller.signal.aborted) { setBuilds(items); setBuildDiscovery("ready"); } }) + .catch(() => { if (!controller.signal.aborted) setBuildDiscovery("error"); }); + return () => controller.abort(); + }, [discovery, selectedTemplate, templates, apiKey, apiURL, domain, discoveryRetry]); + + function changeConnection(key: string, url: string, dataDomain: string) { + setApiKey(key); setAPIURL(url); setDomain(dataDomain); + setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]); + } + // A release the administrator entered comes first, then the saved one of the same backend, // then the one this console distributes (the release its node installer verifies). const matched = useQuery({ queryKey: ["sandbox-runtime-release"], queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), staleTime: Infinity, retry: false }); @@ -209,17 +262,48 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal page = (
- - setApiKey(event.target.value)} /> + + + + + {t("Console → API Keys")} + {discovery === "loading" ?

{t("Loading templates…")}

: null} + {discovery === "error" ?

{t("Could not load templates. Check the key and provider connection.")}

: null} + {discovery === "ready" && templates.length === 0 ?

{t("No templates are visible to this key.")}

: null} + + - - setTemplate(event.target.value)} placeholder="oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b" autoComplete="off" spellCheck={false} aria-invalid={Boolean(template && !validTemplate(template.trim()))} /> + {buildDiscovery === "loading" ?

{t("Loading ready builds…")}

: null} + {buildDiscovery === "error" ?

{t("Could not load builds.")}

: null} + {buildDiscovery === "ready" && builds.length === 0 ?

{t("This template has no ready builds.")}

: null} + + - - setAPIURL(event.target.value)} placeholder="https://sandbox.example.com" autoComplete="off" spellCheck={false} /> + + changeConnection(apiKey, event.target.value, domain)} placeholder="https://sandbox.example.com" autoComplete="off" spellCheck={false} /> - setDomain(event.target.value)} placeholder="sandbox.example.com" autoComplete="off" spellCheck={false} /> + changeConnection(apiKey, apiURL, event.target.value)} placeholder="sandbox.example.com" autoComplete="off" spellCheck={false} />