From c27308fd9450ecf90b9b6523add3d75b496feb96 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 23:20:13 +0800 Subject: [PATCH] Simplify E2B template build onboarding --- apps/web/PRODUCT.md | 1 + apps/web/e2e/console.ts | 2 +- apps/web/e2e/e2b-template-import.spec.ts | 121 +++++++++ apps/web/e2e/nodes.spec.ts | 6 +- .../sandbox/SandboxSetupWizard.test.ts | 2 +- .../features/sandbox/SandboxSetupWizard.tsx | 106 ++++---- .../sandbox/e2b-template-manifest.test.ts | 28 +++ .../features/sandbox/e2b-template-manifest.ts | 52 ++++ apps/web/src/lib/locale-strings.ts | 12 +- services/core/deploy/e2b/README.md | 41 +++- services/core/deploy/e2b/build-template.py | 19 +- .../core/deploy/e2b/build_template_test.py | 27 ++- services/core/deploy/e2b/template_manifest.py | 60 +++++ .../core/deploy/e2b/template_manifest_test.py | 37 +++ .../e2b/testdata/template-manifests.json | 229 ++++++++++++++++++ 15 files changed, 682 insertions(+), 61 deletions(-) create mode 100644 apps/web/e2e/e2b-template-import.spec.ts create mode 100644 apps/web/src/features/sandbox/e2b-template-manifest.test.ts create mode 100644 apps/web/src/features/sandbox/e2b-template-manifest.ts create mode 100644 services/core/deploy/e2b/template_manifest.py create mode 100644 services/core/deploy/e2b/template_manifest_test.py create mode 100644 services/core/deploy/e2b/testdata/template-manifests.json diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index c9de6dcd6..55cf2d3e3 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -37,6 +37,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Sandbox reset** is an explicit administrator operation in System → Sandbox configuration. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment credentials. A new configuration is then a separate deliberate save. - **Online sandbox configuration** changes the same backend's resources, Runtime or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. +- **E2B template onboarding** accepts the builder's non-secret JSON through Import template build, or a discovered or manually entered exact template build. The [template builder guide](../../services/core/deploy/e2b/README.md#import-a-build-into-web) owns the file contract and operator steps. Import and endpoint edits clear the entered key; the operator reviews the endpoint before entering a key for it. Empty catalogs link to the builder guide. Template discovery, build discovery and save failures remain distinguishable. Import and discovery never imply a verified Runtime or bypass Core's admission checks. - **E2B deployments** have no machines: Nodes offers a link to System's sandbox configuration. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no node column or Add node action; a sandbox's dialog adds its disk use. - **microsandbox** suspends idle sandboxes into snapshots, so its nodes show how many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox metrics and Overview; a node's allocations show how long each has been suspended and about when Core reclaims it. Docker never suspends and shows none of it. - **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment whose template build is not reported as not ready), a default model provider on the default harness (on any enabled harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 78000a332..57c883a29 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -48,7 +48,7 @@ export async function openConsole(page: Page, request: APIRequestContext, hash = 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"); + await page.getByLabel("Template build", { exact: true }).selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe"); } /** Makes the next matching write fail once with the given status. */ diff --git a/apps/web/e2e/e2b-template-import.spec.ts b/apps/web/e2e/e2b-template-import.spec.ts new file mode 100644 index 000000000..02128fff6 --- /dev/null +++ b/apps/web/e2e/e2b-template-import.spec.ts @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; +import { expect, test, type Page } from "@playwright/test"; +import { expectManagementBoundary, openConsole, writes } from "./console"; + +const fixture = JSON.parse(readFileSync(new URL("../../../services/core/deploy/e2b/testdata/template-manifests.json", import.meta.url), "utf8"))[0].value; +const build = { ...fixture, api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai", template: "template:94be54a1-138c-4f30-bc87-b13686272dbe" }; +const discoveryPath = "/core/v1/sandbox/providers/e2b/discovery"; +async function upload(page: Page, value: unknown) { + await page.getByLabel("Import template build", { exact: true }).setInputFiles({ name: "build.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(value)) }); +} + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("imports a build locally, forgets the old key and saves only validated configuration fields", async ({ page, request }, info) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud", exact: true }).click(); + await page.getByLabel("E2B API key", { exact: true }).fill("old-fixture-key"); + await upload(page, build); + await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Sandbox API URL", { exact: true })).toHaveValue(build.api_url); + await expect(page.getByLabel("Template build", { exact: true })).toHaveValue(build.template); + await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); + // Give a pending discovery debounce time to fire; import must cancel it. + await page.waitForTimeout(650); + expect(await writes(request)).toEqual([]); + await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key"); + await expect(page.getByLabel("Template build", { exact: true })).toHaveValue(build.template); + await expect(page.getByRole("button", { name: "Next", exact: true })).toBeEnabled(); + await page.screenshot({ path: info.outputPath("import-en.png"), fullPage: true }); + await page.getByRole("button", { name: "Next", exact: true }).click(); + const sent = page.waitForRequest((r) => r.method() === "POST" && r.url().endsWith("/sandbox/deployment")); + await page.getByRole("button", { name: "Save configuration", exact: true }).click(); + expect((await sent).postDataJSON()).toEqual({ provider: "e2b", expected_generation: 0, configuration: { api_url: build.api_url, domain: build.domain, template: build.template }, credential: { api_key: "fixture-private-key" } }); + await expect(page.getByRole("heading", { name: "Sandbox configuration", level: 1 })).toBeVisible(); + const storage = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })); + expect(storage).not.toContain("fixture-private-key"); + expect(storage).not.toContain(build.template); +}); + +test("rejects credentials in a file and clears entered credentials when changing endpoints", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud", exact: true }).click(); + await upload(page, { ...build, api_key: "must-not-be-imported" }); + await expect(page.getByRole("alert")).toContainText("valid OpenAgentCore template build JSON"); + await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue(""); + expect(await writes(request)).toEqual([]); + await upload(page, build); + await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key"); + await page.getByLabel("Sandbox API URL", { exact: true }).fill("https://custom.sandbase.ai"); + await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Template build", { exact: true })).toHaveValue(""); + await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); +}); + +test("offers build guidance for an empty catalog and identifies both discovery failure stages", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud", exact: true }).click(); + let mode = "empty"; + await page.route(`**${discoveryPath}`, async (route) => { + const query = route.request().postDataJSON().query; + if (mode === "empty") return route.fulfill({ json: { templates: [] } }); + if (mode === "templates-error" || query.template) return route.fulfill({ status: 503, json: { error: { message: "fixture", code: "sandbox_verification_unconfirmed" } } }); + return route.fulfill({ json: { templates: [{ id: "template", names: ["fixture-runtime"] }] } }); + }); + await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key"); + await expect(page.getByText("No templates are visible to this key.", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Build an OpenAgentCore template", exact: true })).toHaveAttribute("href", /deploy\/e2b\/README.md#build-a-template$/); + mode = "templates-error"; + await page.getByLabel("E2B API key", { exact: true }).fill("fixture-second-key"); + await expect(page.getByRole("alert")).toContainText("Template discovery failed"); + mode = "builds-error"; + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await page.getByLabel("Template", { exact: true }).selectOption("template"); + await expect(page.getByRole("alert")).toContainText("Build discovery failed"); + await page.getByRole("button", { name: "Enter an exact template build", exact: true }).click(); + await page.getByLabel("Template build", { exact: true }).fill("template:latest"); + await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); + await page.getByLabel("Template build", { exact: true }).fill(build.template); + await expect(page.getByRole("button", { name: "Next", exact: true })).toBeEnabled(); +}); + +test("a delayed import cannot overwrite a later endpoint edit", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud", exact: true }).click(); + await page.evaluate(() => { + const original = File.prototype.text; + File.prototype.text = function () { + return new Promise((resolve, reject) => { + (window as unknown as { finishBuildImport: () => void }).finishBuildImport = () => { void original.call(this).then(resolve, reject); }; + }); + }; + }); + await upload(page, build); + await page.getByLabel("Sandbox API URL", { exact: true }).fill("https://custom.sandbase.ai"); + await page.evaluate(() => (window as unknown as { finishBuildImport: () => void }).finishBuildImport()); + await expect(page.getByLabel("Sandbox API URL", { exact: true })).toHaveValue("https://custom.sandbase.ai"); + await expect(page.getByText("Build imported.", { exact: false })).toHaveCount(0); + expect(await writes(request)).toEqual([]); +}); + +test("an imported build still surfaces Core admission rejection and supports Chinese", async ({ page, request }, info) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud", exact: true }).click(); + await upload(page, build); + await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key"); + await page.route("**/core/v1/sandbox/deployment", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + return route.fulfill({ status: 400, json: { error: { message: "fixture", code: "sandbox_configuration_invalid", param: "configuration" } } }); + }); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await page.getByRole("button", { name: "Save configuration", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Connect E2B", exact: true })).toBeVisible(); + await expect(page.getByRole("alert")).toContainText("Select a ready immutable E2B template build with matching resources."); + await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Template build", { exact: true })).toHaveValue(build.template); + await page.getByRole("button", { name: "Language and appearance" }).click(); + await page.getByRole("menuitemradio", { name: "简体中文" }).click(); + await expect(page.getByLabel("导入模板构建文件", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "构建 OpenAgentCore 模板", exact: true })).toBeVisible(); + await page.screenshot({ path: info.outputPath("import-zh-rejected.png"), fullPage: true }); +}); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index e89b35994..4bb4fbad1 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -286,7 +286,7 @@ test("uses the official E2B preset and clears a selected build when the key chan 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.getByLabel("Template build", { exact: true })).toHaveValue(""); await expect(page.getByRole("button", { name: "Next" })).toBeDisabled(); }); @@ -295,9 +295,9 @@ test("shows the retained E2B build while a replacement key is checked", async ({ await page.getByRole("button", { name: "Change resources" }).click(); const edit = page.getByRole("dialog", { name: "Change resources" }); const saved = "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b"; - await expect(edit.getByLabel("Template build")).toHaveValue(saved); + await expect(edit.getByLabel("Template build", { exact: true })).toHaveValue(saved); await edit.getByLabel("E2B API key").fill("replacement-fixture-key"); - await expect(edit.getByLabel("Template build")).toHaveValue(saved); + await expect(edit.getByLabel("Template build", { exact: true })).toHaveValue(saved); await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled(); }); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts b/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts index 3d2b6a7d6..6428faee7 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validEndpoint } from "./SandboxSetupWizard"; +import { validEndpoint } from "./e2b-template-manifest"; describe("E2B endpoint input", () => { it("accepts the official default and a paired compatible service", () => { diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 63fb3316a..dfbf32c16 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; import { useConsoleNavigation } from "../../lib/console-navigation"; -import { coreFieldError } from "../../lib/core-error"; +import { coreFieldError, knownCoreError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; @@ -19,6 +19,7 @@ import { defaultSandboxResources, distributionRuntime, savedSpecification, valid import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; import { sandboxAdmin } from "./sandbox-queries"; +import { MAX_MANIFEST_BYTES, parseTemplateManifest, validEndpoint, validTemplate } from "./e2b-template-manifest"; import "./sandbox-wizard.css"; type Where = "nodes" | "direct"; @@ -42,28 +43,6 @@ function e2bService(apiURL?: string): E2BService { const MIB = 2 ** 20; const EASE = [0.16, 1, 0.3, 1] as const; -// Core accepts a template ID of up to 128 characters and a canonical, non-nil build UUID. -const TEMPLATE = /^[a-zA-Z0-9_-]{1,128}:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/; -function validTemplate(value: string): boolean { - const build = TEMPLATE.exec(value)?.[1]; - return build !== undefined && /[^0-]/.test(build); -} - -export function validEndpoint(apiURL: string, domain: string): boolean { - const publicName = (host: string) => host.length <= 253 && host.includes(".") && !/^[0-9.]+$/.test(host) && - !host.endsWith(".local") && !host.endsWith(".localhost") && - host.split(".").every((label) => label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)); - if (!apiURL && !domain) return true; - if (!apiURL || !domain || apiURL.length > 512 || !publicName(domain)) return false; - try { - const url = new URL(apiURL); - return url.protocol === "https:" && url.origin === apiURL && !url.username && !url.password && - !url.port && publicName(url.hostname) && (url.hostname === domain || url.hostname.endsWith(`.${domain}`)); - } catch { - return false; - } -} - /** * Per-sandbox presets around the deployment default: half and double of it. * Disks apply to microsandbox only, the one provider that enforces them. @@ -134,6 +113,13 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab 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 [importError, setImportError] = useState(false); + const [imported, setImported] = useState(false); + const [manualTemplate, setManualTemplate] = useState(false); + const importRevision = useRef(0); + useEffect(() => () => { importRevision.current++; }, []); + const [discoveryError, setDiscoveryError] = useState(null); + const [buildError, setBuildError] = useState(null); const [templates, setTemplates] = useState([]); const [builds, setBuilds] = useState([]); const [selectedTemplate, setSelectedTemplate] = useState(current?.e2bTemplate?.split(":")[0] ?? ""); @@ -156,14 +142,14 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const { navigate } = useConsoleNavigation(); useEffect(() => { - setTemplates([]); setBuilds([]); setDiscovery("idle"); setBuildDiscovery("idle"); + setTemplates([]); setBuilds([]); setDiscovery("idle"); setBuildDiscovery("idle"); setDiscoveryError(null); setBuildError(null); 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"); }); + .catch((error: unknown) => { if (!controller.signal.aborted) { setDiscovery("error"); setDiscoveryError(error); } }); }, 500); return () => { window.clearTimeout(timer); controller.abort(); }; }, [provider, apiKey, apiURL, domain, discoveryRetry]); @@ -175,15 +161,38 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab 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"); }); + .catch((error: unknown) => { if (!controller.signal.aborted) { setBuildDiscovery("error"); setBuildError(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); + importRevision.current++; + setImported(false); setImportError(false); + setApiKey(key); setReplacementRequested(Boolean(key.trim())); setAPIURL(url); setDomain(dataDomain); setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]); } + async function importBuild(file: File) { + const revision = ++importRevision.current; + setImportError(false); + try { + if (file.size > MAX_MANIFEST_BYTES) throw new Error("Invalid template build file"); + const manifest = parseTemplateManifest(await file.text()); + if (revision !== importRevision.current) return; + // Never send an already-entered credential to an imported endpoint. + changeConnection("", manifest.api_url, manifest.domain); + setReplacementRequested(false); + setService(e2bService(manifest.api_url)); + setTemplate(manifest.template); + setSelectedTemplate(manifest.template.split(":")[0]!); + setManualTemplate(true); + setImported(true); + setRejection(null); setFieldRejection(null); + } catch { + if (revision === importRevision.current) setImportError(true); + } + } + // 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 }); @@ -202,10 +211,11 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const order: Step[] = editing ? where === "direct" ? ["e2b", "review"] : ["size", "review"] : where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"]; const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); - const back = () => setStep(step === "advanced" ? "review" : order[Math.max(0, index - 1)]!); + const back = () => { importRevision.current++; setStep(step === "advanced" ? "review" : order[Math.max(0, index - 1)]!); }; // The saved backend keeps its size and Runtime; another starts from its standard size and this console's Runtime. function choose(next: SandboxProvider) { + importRevision.current++; if (next !== provider) { setRejection(null); const kept = current ? savedSpecification(next, current.provider, current.specification) : null; @@ -280,6 +290,14 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab page = (
+ + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void importBuild(file); + }} /> + + {imported ?

{t("Build imported. Enter the API key for this service; Core validates the build when you save.")}

: null} + + changeConnection("", event.target.value, domain)} placeholder="https://sandbox.example.com" autoComplete="off" spellCheck={false} /> + + + changeConnection("", apiURL, event.target.value)} placeholder="sandbox.example.com" autoComplete="off" spellCheck={false} /> + {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 === "loading" ?
: null} + {discovery === "error" ?

{t("Template discovery failed. Check the service connection and its E2B template API support.")}{discoveryError ? <> {knownCoreError(discoveryError, tCommon)} : null}

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

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

: null} - {editing && !apiKey.trim() ? setTemplate(event.target.value)} /> : <> + {manualTemplate || (editing && !apiKey.trim()) ? { importRevision.current++; setImported(false); setTemplate(event.target.value); }} /> : <> - { importRevision.current++; setImported(false); setSelectedTemplate(event.target.value); setTemplate(""); }}> {editing && selectedTemplate && !templates.some((item) => item.id === selectedTemplate) ? : null} {templates.map((item) => )} - {buildDiscovery === "loading" ?

{t("Loading ready builds…")}

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

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

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

{t("Build discovery failed. Check that this service exposes immutable E2B builds.")}{buildError ? <> {knownCoreError(buildError, tCommon)} : null}

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

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

: null} - { importRevision.current++; setImported(false); setTemplate(event.target.value); }}> {editing && template && !builds.some((item) => `${selectedTemplate}:${item.id}` === template) ? : null} {builds.map((item) => )} } - - changeConnection(apiKey, event.target.value, domain)} placeholder="https://sandbox.example.com" autoComplete="off" spellCheck={false} /> - - - changeConnection(apiKey, apiURL, event.target.value)} placeholder="sandbox.example.com" autoComplete="off" spellCheck={false} /> - + + {t("Build an OpenAgentCore template")}
-