From 13d0dd3fc5314dd152099fa59b8bc99630b9f865 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Fri, 23 Oct 2026 19:49:37 +0000
Subject: [PATCH 1/7] Declare each Sandbox Provider's setup in one
SetupDeclaration
Web follows the declared mode, fields and presets, discovery is typed (field and path in, options out), and the per-provider validators and Web provider knowledge are deleted.
---
apps/web/e2e/console.ts | 7 +-
apps/web/e2e/fixture-console.mjs | 5 +-
apps/web/e2e/monitoring.spec.ts | 4 +-
apps/web/e2e/nodes.spec.ts | 27 +-
apps/web/e2e/public-url.spec.ts | 2 +-
apps/web/e2e/sandbox-generation.spec.ts | 4 +-
apps/web/src/features/metrics/MetricsView.css | 7 -
.../features/metrics/SandboxMetricsPage.tsx | 20 +-
.../src/features/overview/OverviewPage.tsx | 4 +-
.../features/overview/getting-started.test.ts | 9 +-
.../src/features/overview/getting-started.ts | 9 +-
.../features/sandbox/NodeCleanupDialog.tsx | 6 +-
.../sandbox/SandboxDeploymentSettings.tsx | 27 +-
.../features/sandbox/SandboxResetControls.tsx | 2 +-
.../sandbox/SandboxSetupWizard.test.ts | 18 -
.../features/sandbox/SandboxSetupWizard.tsx | 391 +++++++++---------
.../sandbox/deployment-specification.ts | 19 +-
.../features/sandbox/sandbox-update.test.ts | 22 +-
.../src/features/sandbox/sandbox-update.ts | 14 +-
.../src/features/sandbox/sandbox-wizard.css | 15 +-
apps/web/src/i18n/locales/en/core-errors.ts | 10 +-
apps/web/src/i18n/locales/en/metrics.ts | 14 +-
apps/web/src/i18n/locales/en/overview.ts | 10 +-
.../src/i18n/locales/en/sandbox-navigation.ts | 2 +-
.../web/src/i18n/locales/zh-CN/core-errors.ts | 10 +-
apps/web/src/i18n/locales/zh-CN/metrics.ts | 14 +-
apps/web/src/i18n/locales/zh-CN/overview.ts | 10 +-
.../i18n/locales/zh-CN/sandbox-navigation.ts | 2 +-
apps/web/src/i18n/locales/zh-CN/sandbox.ts | 58 +--
apps/web/src/lib/admin-view.ts | 2 +-
apps/web/src/lib/sandbox-labels.ts | 10 +-
contracts/agents-api/core.openapi.yaml | 62 ++-
contracts/agents-api/runtime.openapi.yaml | 2 +-
contracts/agents-api/sandbox-deployment.md | 6 +-
contracts/agents-api/zh/sandbox-deployment.md | 8 +-
docs/sandbox-provider.md | 6 +-
docs/web/console-api-usage.md | 2 +-
docs/zh/sandbox-provider.md | 8 +-
docs/zh/web/console-api-usage.md | 4 +-
.../agents-client/src/deployment-contract.ts | 2 +-
.../agents-client/src/generated/core-api.ts | 20 +-
.../agents-client/src/sandbox-client.test.ts | 27 +-
packages/agents-client/src/sandbox-client.ts | 114 ++---
.../src/sandbox-e2b-error-params.test.ts | 6 +-
services/core/cmd/server/managed_setup.go | 3 +-
services/core/internal/api/admin_runtime.go | 4 +-
services/core/internal/api/fakes_test.go | 4 +-
.../api/sandbox_configuration_discovery.go | 7 +-
.../sandbox_configuration_discovery_test.go | 20 +-
.../internal/api/sandbox_deployment_setup.go | 9 +-
services/core/internal/api/sandbox_manager.go | 4 +-
.../api/sandbox_node_configuration.go | 2 +-
.../internal/sandbox/deployment_contract.go | 35 +-
.../core/internal/sandbox/docker/provider.go | 2 +-
.../core/internal/sandbox/docker/selection.go | 16 +-
.../internal/sandbox/e2b/configuration.go | 6 +-
.../sandbox/e2b/configuration_discovery.go | 43 +-
.../core/internal/sandbox/e2b/deployment.go | 42 +-
.../sandbox/e2b/installed_paths_test.go | 4 +-
.../core/internal/sandbox/e2b/provider.go | 2 +-
.../sandbox/microsandbox/deployment.go | 13 +-
.../core/internal/sandbox/providers/config.go | 2 +-
.../sandbox/providers/configuration.go | 21 +-
.../providers/configuration_flow_test.go | 4 +-
.../sandbox/providers/registration.go | 16 +-
.../registration_configuration_test.go | 16 +-
.../sandbox/providers/registration_test.go | 28 +-
.../internal/sandbox/providers/registry.go | 63 +--
.../core/internal/sandbox/sandbox_provider.go | 104 ++++-
.../public_handler_fixture_test.go | 5 +-
70 files changed, 824 insertions(+), 672 deletions(-)
delete mode 100644 apps/web/src/features/sandbox/SandboxSetupWizard.test.ts
diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts
index fbe39052b..a703ecf56 100644
--- a/apps/web/e2e/console.ts
+++ b/apps/web/e2e/console.ts
@@ -44,9 +44,10 @@ export async function openConsole(page: Page, request: APIRequestContext, 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");
+ await page.getByLabel("API key").fill("fixture-private-key");
+ const template = page.getByLabel("Template", { exact: true });
+ await template.first().selectOption("template");
+ await template.nth(1).selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe");
}
/** Makes the next matching write fail once with the given status. */
diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs
index c5a67bea7..e2fa694f3 100644
--- a/apps/web/e2e/fixture-console.mjs
+++ b/apps/web/e2e/fixture-console.mjs
@@ -342,8 +342,9 @@ async function sandboxRoute(request, response, path, url) {
["https://api.e2b.app", "e2b.app"],
].some(([apiURL, domain]) => input.configuration?.api_url === apiURL && input.configuration?.domain === domain);
if (input.credential?.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection.");
- if (!input.query?.template) 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 (input.query?.field !== "template") return error(response, 400, "Invalid discovery field.");
+ if (input.query.path.length === 0) return send(response, 200, { options: [{ value: "template", label: "fixture-runtime · template", complete: false }] });
+ return send(response, 200, { options: [{ value: "template:94be54a1-138c-4f30-bc87-b13686272dbe", label: "94be54a1-138c-4f30-bc87-b13686272dbe", resources: { cpus: 2, memory_mib: 2048 }, complete: true }] });
}
// Retired even for authenticated callers; never reinterpret maintenance as reset.
if (path === "/deployment/maintenance") return error(response, 404, "Not found.");
diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts
index 8ca0e5ca8..30ee5c4b7 100644
--- a/apps/web/e2e/monitoring.spec.ts
+++ b/apps/web/e2e/monitoring.spec.ts
@@ -250,7 +250,7 @@ test("shows a node's host on Nodes and opens a sandbox in a dialog from Sandbox
test("shows E2B's cloud instead of machines", async ({ page, request }) => {
await openConsole(page, request, "sandbox-metrics", { sandbox: "e2b" });
- await expect(page.getByRole("heading", { name: "E2B cloud" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Provider cloud" })).toBeVisible();
// Each sandbox takes the template build's size, disk included.
await expect(page.getByText("10 GiB disk")).toBeVisible();
await expect(page.getByRole("button", { name: "Add node" })).toHaveCount(0);
@@ -258,5 +258,5 @@ test("shows E2B's cloud instead of machines", async ({ page, request }) => {
await page.getByRole("navigation").getByRole("button", { name: "Nodes", exact: true }).click();
await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeVisible();
- await expect(page.getByText("E2B runs sandboxes in its cloud. There are no nodes to manage.")).toBeVisible();
+ await expect(page.getByText("The provider runs sandboxes in its cloud. There are no nodes to manage.")).toBeVisible();
});
diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts
index 5fb60fca2..b09fb9000 100644
--- a/apps/web/e2e/nodes.spec.ts
+++ b/apps/web/e2e/nodes.spec.ts
@@ -229,13 +229,13 @@ test("preselects microsandbox and asks once before switching to Docker", async (
await page.getByRole("button", { name: "Own machines" }).click();
const microsandbox = page.getByRole("button", { name: "microsandbox Recommended" });
const docker = page.getByRole("button", { name: "Docker", exact: true });
- const confirm = page.getByRole("dialog", { name: "Use Docker instead of microsandbox?" });
+ const confirm = page.getByRole("dialog", { name: "Use Docker?" });
const sizeStep = page.getByRole("heading", { name: "How big is each sandbox?" });
await expect(microsandbox).toHaveAttribute("aria-pressed", "true");
// Keeping microsandbox is the default action; it leaves microsandbox selected.
await docker.click();
- const keep = confirm.getByRole("button", { name: "Keep microsandbox" });
+ const keep = confirm.getByRole("button", { name: "Back" });
await expect(keep).toBeFocused();
await keep.click();
await expect(confirm).toBeHidden();
@@ -258,8 +258,8 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page,
if (sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/deployment")) submitted = sent.postDataJSON() as Record;
});
await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" });
- await page.getByRole("button", { name: "E2B cloud" }).click();
- await expect(page.getByLabel("E2B provider")).toHaveValue("sandbase");
+ await page.getByRole("button", { name: "E2B", exact: true }).click();
+ await expect(page.getByLabel("Service")).toHaveValue("0");
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" });
@@ -283,22 +283,21 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page,
test("uses the official E2B preset and clears a selected build when the key changes", async ({ page, request }) => {
await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" });
- await page.getByRole("button", { name: "E2B cloud" }).click();
- await page.getByLabel("E2B provider").selectOption("official");
+ await page.getByRole("button", { name: "E2B", exact: true }).click();
+ await page.getByLabel("Service").selectOption("1");
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 page.getByLabel("Service").selectOption("-1");
await expect(keyConsole).toHaveCount(0);
- await page.getByLabel("E2B provider").selectOption("official");
+ await page.getByLabel("Service").selectOption("1");
await selectFixtureE2BBuild(page);
await expect(page.getByRole("button", { name: "Next" })).toBeEnabled();
- await page.getByLabel("E2B API key").fill("changed-fixture-key");
+ await page.getByLabel("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();
});
@@ -307,9 +306,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 edit.getByLabel("E2B API key").fill("replacement-fixture-key");
- await expect(edit.getByLabel("Template build")).toHaveValue(saved);
+ await expect(edit.getByLabel("Template").first()).toContainText(saved);
+ await edit.getByLabel("API key").fill("replacement-fixture-key");
+ await expect(edit.getByLabel("Template").first()).toContainText(saved);
await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled();
});
@@ -330,7 +329,7 @@ test("edits only the saved backend, preserving a custom size", async ({ page, re
await openConsole(page, request, "settings?id=sandbox");
await page.getByRole("button", { name: "Change resources" }).click();
await expect(page.getByRole("button", { name: "Own machines" })).toHaveCount(0);
- await expect(page.getByRole("button", { name: "E2B cloud" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "E2B", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /^Current/ }).click();
await page.getByRole("button", { name: "Save configuration" }).click();
await expect.poll(() => submitted).toMatchObject({ provider: "docker", expected_generation: 1, resources: current.resources });
diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts
index d5d96e1cf..c743b9e65 100644
--- a/apps/web/e2e/public-url.spec.ts
+++ b/apps/web/e2e/public-url.spec.ts
@@ -24,7 +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 a link to domain setup", async ({ page, request }) => {
await openConsole(page, request, "settings?id=sandbox", { sandbox: "none", installation: "local" });
- await page.getByRole("button", { name: "E2B cloud" }).click();
+ await page.getByRole("button", { name: "E2B", exact: true }).click();
await selectFixtureE2BBuild(page);
await page.getByRole("button", { name: "Next" }).click();
const address = page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" });
diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts
index 7f19b121a..2d9a609bf 100644
--- a/apps/web/e2e/sandbox-generation.spec.ts
+++ b/apps/web/e2e/sandbox-generation.spec.ts
@@ -33,8 +33,8 @@ async function saveConfiguration(page: Page) {
}
async function editE2B(page: Page, key?: string) {
await page.getByRole("button", { name: "Change resources", exact: true }).click();
- await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue("");
- if (key !== undefined) await page.getByLabel("E2B API key", { exact: true }).fill(key);
+ await expect(page.getByLabel("API key", { exact: true })).toHaveValue("");
+ if (key !== undefined) await page.getByLabel("API key", { exact: true }).fill(key);
await page.getByRole("button", { name: "Next", exact: true }).click();
}
diff --git a/apps/web/src/features/metrics/MetricsView.css b/apps/web/src/features/metrics/MetricsView.css
index ad07c1929..0c32402ce 100644
--- a/apps/web/src/features/metrics/MetricsView.css
+++ b/apps/web/src/features/metrics/MetricsView.css
@@ -42,13 +42,6 @@
gap: 24px;
}
-/* An E2B template build is long: the figure cuts it off, and the full build is in its tooltip. */
-.cloud-template {
- font-family: var(--font-mono);
- font-size: 12px;
- font-weight: 400;
-}
-
/* A figure that opens the Session log filtered to it; it keeps its cell's colour. */
.figure-link {
padding: 0;
diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx
index b413064a1..a039dfd29 100644
--- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx
+++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx
@@ -32,7 +32,6 @@ import { RUNTIME_SNAPSHOT_REFRESH_MS } from "../dashboard/runtime-snapshot";
import { FleetReadNotice } from "../fleet/FleetReadNotice";
import { SandboxResetNotice } from "../fleet/SandboxResetNotice";
import { fleetSnapshot, useSandboxFleet } from "../fleet/use-sandbox-fleet";
-import { sandboxSize, templateBuildStatus } from "../sandbox/deployment-specification";
import {
hostedRuntimeRows,
hostedRuntimeUsage,
@@ -131,17 +130,12 @@ export function SandboxMetricsPage() {
);
}
-/** An E2B deployment: what runs in its cloud now, and with what. */
+/** A direct deployment: what runs in the Provider's cloud now, and with what. */
function CloudSection({ deployment }: { deployment: SandboxDeployment }) {
const { t, i18n } = useTranslation("metrics");
const locale = i18n.resolvedLanguage;
const { navigate } = useConsoleNavigation();
- // An E2B selection may adopt its template build's size instead of saving one.
- const resources = sandboxSize(deployment);
- const build = deployment.metadata?.template_build;
- const disk = build?.resources.root_disk_mib ?? null;
- const status = templateBuildStatus(build);
- const template = deployment.configuration?.template;
+ const resources = deployment.specification?.resources;
return (
{t("sandbox.cores", { value: formatInteger(resources.cpus, locale) })} · {formatBytes(resources.memory_mib * 2 ** 20)}
- {disk !== null ? {t("sandbox.cloud.disk", { disk: formatBytes(disk * 2 ** 20) })} : null}
> : MISSING}
/>
-
- {t(`sandbox.cloud.build.${status}`)}
- {template ? {template} : null}
- >}
- />
);
diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx
index 3bd74942a..58f9a46b5 100644
--- a/apps/web/src/features/overview/OverviewPage.tsx
+++ b/apps/web/src/features/overview/OverviewPage.tsx
@@ -102,7 +102,7 @@ export function OverviewPage() {
const activity = useMemo(() => (sessions ? sessionActivity(sessions.map((entry) => entry.value), now) : null), [now, sessions]);
const attention = useMemo(() => (sessions ? attentionSessions(sessions, activityStart(now)) : null), [now, sessions]);
const capacity = fleet ? capacitySummary(fleet.nodes) : null;
- // An E2B deployment has no machines: capacity is what Core holds in E2B's cloud.
+ // A direct deployment has no machines: capacity is what Core holds in the Provider's cloud.
const cloud = cloudHost(fleet);
const failuresLastHour = sessions ? recentFailures(sessions.map((entry) => entry.value), now) : null;
@@ -319,7 +319,7 @@ function cloudHost(fleet: FleetSnapshot | null): CloudHost | null {
return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.configuration?.template || null };
}
-/** Core and its sandbox nodes (or E2B's cloud) as an inventory; each row opens its page. */
+/** Core and its sandbox nodes (or the Provider's cloud) as an inventory; each row opens its page. */
function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; core: CoreStatus; localOnly: boolean }) {
const { t } = useTranslation("overview");
const { navigate } = useConsoleNavigation();
diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts
index 3952215bf..0409d1a5f 100644
--- a/apps/web/src/features/overview/getting-started.test.ts
+++ b/apps/web/src/features/overview/getting-started.test.ts
@@ -58,16 +58,13 @@ describe("Getting started steps", () => {
expect(steps.sandboxes).toMatchObject({ state: "todo", action: "nodes", cloud: provider === "e2b" });
}
});
- it("counts sandboxes ready with a saved deployment and a ready node, or a saved E2B deployment whose build is not reported unready", () => {
+ it("counts sandboxes ready with a saved deployment and a ready node, or a saved direct deployment", () => {
expect(sandboxes(fleet(deployment({ provider: "", mode: "" }), []))).toMatchObject({ state: "todo", action: "setup" });
expect(sandboxes(fleet(deployment(), []))).toMatchObject({ state: "todo", action: "add-node" });
expect(sandboxes(fleet(deployment(), [node("n1", { provider_ready: false }), node("n2", { online: false })]))).toMatchObject({ state: "todo", action: "nodes" });
expect(sandboxes(fleet(deployment()))).toMatchObject({ state: "done" });
- const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", configuration: { template: "t", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } });
- expect(sandboxes(fleet(e2b("building"), []))).toMatchObject({ state: "todo", cloud: true });
- expect(sandboxes(fleet(e2b("ready"), []))).toMatchObject({ state: "done", cloud: true });
- // Saved before Core recorded the build: Core admitted it, so it counts as ready.
- expect(sandboxes(fleet(e2b(null), []))).toMatchObject({ state: "done", cloud: true });
+ const direct = deployment({ provider: "e2b", mode: "direct", configuration: { template: "t", api_url: "https://api.e2b.app", domain: "e2b.app" }, credential_configured: true });
+ expect(sandboxes(fleet(direct, []))).toMatchObject({ state: "done", cloud: true });
expect(sandboxes({ status: "loading" }).state).toBeNull();
expect(sandboxes({ status: "failed", error: new Error("down") }).state).toBe("unknown");
});
diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts
index 48e6be964..2c59596a1 100644
--- a/apps/web/src/features/overview/getting-started.ts
+++ b/apps/web/src/features/overview/getting-started.ts
@@ -2,7 +2,6 @@ import type { AdminProject, CoreHarness } from "@oac/agents-client";
import { nodeServingReady } from "../fleet/fleet-model";
import { type FleetState } from "../fleet/use-sandbox-fleet";
-import { templateBuildStatus } from "../sandbox/deployment-specification";
import { harnessReady } from "../system/harness-queries";
/**
@@ -56,10 +55,8 @@ export function gettingStartedSteps(input: {
/**
* Own machines are ready once the deployment is saved and a node is online
- * with its provider ready; a direct Provider (E2B) once the deployment is
- * saved, since Core admits only a ready template build. Only a build Core reports as not ready leaves
- * the step to do; a selection saved before Core recorded its build has no
- * status and counts as done.
+ * with its provider ready; a direct Provider once the deployment is saved,
+ * since Core admits only a configuration it validated.
*/
function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] {
if (fleet.status !== "ready") {
@@ -69,7 +66,7 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] {
const { deployment, nodes } = fleet.snapshot;
if (!deployment.provider) return { state: "todo", action: "setup", cloud: false };
if (deployment.mode === "direct") {
- return { state: templateBuildStatus(deployment.metadata?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true };
+ return { state: "done", action: "nodes", cloud: true };
}
if (nodes.some(nodeServingReady)) return { state: "done", action: "nodes", cloud: false };
return { state: "todo", action: nodes.length ? "nodes" : "add-node", cloud: false };
diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx
index 2de73c23f..fd3fba2e3 100644
--- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx
+++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx
@@ -22,8 +22,8 @@ export interface NodeCleanup {
* host (deploy/node/node_install.py `uninstall_system`).
* The installer first confirms with Core, at the node's own address, that the
* node is removed, which holds from the removal on. The command uses sudo unless the shell is already root. A node enrolled with an earlier address may find it gone; then
- * `--force` skips only that confirmation. It keeps Docker images and volumes and
- * deletes a microsandbox store once Core confirmed the removal; the uninstaller
+ * `--force` skips only that confirmation. It keeps images and volumes and
+ * deletes the sandbox store once Core confirmed the removal; the uninstaller
* prints what it kept. Like Add node's, the command downloads from the installation's public
* URL, which the dialog reads (again, if it is not at hand): until it is read, if
* the read fails (with Try again), or while other machines can't use it, the
@@ -50,7 +50,7 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle
? join(t("To remove a node's service and files from its host, run this on the host."), t("Core must no longer list the node, so remove it on this page first. A host that never registered needs no removal."))
: t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}
- {t("It keeps Docker images and volumes; a microsandbox store goes once Core confirmed the removal.")}
+ {t("It keeps images and volumes; the sandbox store goes once Core confirmed the removal.")}
{cleanup.oldAddress !== null ?
{t("Old Core address gone?")}
diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
index a41e04ded..e0875ebd6 100644
--- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
+++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
@@ -1,18 +1,15 @@
import { useState, type ReactNode } from "react";
-import type { UpdateSandboxDeployment, SandboxDeployment } from "@oac/agents-client";
+import { sandboxProviderSetup, type UpdateSandboxDeployment, type SandboxDeployment } from "@oac/agents-client";
import { useTranslation } from "react-i18next";
import { Modal } from "../../components/Modal";
import { HelpTip, RefreshButton } from "../../components/console-ui";
import { formatBytes, formatPeriod, MISSING } from "../../lib/format";
-import type { ParseKeys } from "i18next";
import { sandboxProviderLabel } from "../../lib/sandbox-labels";
import { Fact } from "../system/Fact";
-import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification";
import { SandboxRolloutSummary } from "./SandboxRolloutSummary";
import { SandboxSetupWizard } from "./SandboxSetupWizard";
const MIB = 2 ** 20;
-const buildStatusLabel: Record
, ParseKeys<"sandbox">> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" };
export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdate, writeFailure, onRefresh, refreshing, pending }: {
deployment: SandboxDeployment;
@@ -30,10 +27,8 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
const [changing, setChanging] = useState(false);
const [editKey, setEditKey] = useState(0);
const spec = deployment.specification;
- // An E2B selection may adopt its template build's size instead of saving one.
- const size = sandboxSize(deployment);
- const build = deployment.metadata?.template_build;
- const buildSize = templateBuildSize(deployment);
+ const size = deployment.specification?.resources;
+ const setup = deployment.provider ? sandboxProviderSetup(deployment.provider) : null;
const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) });
const canEdit = fresh && !deployment.reset;
const change = () => { setEditKey((key) => key + 1); setChanging(true); };
@@ -43,7 +38,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
{t("Deployment provider")}
{t("One provider serves this deployment. Reset it before choosing a different backend.")}
- {deployment.mode === "direct" ? ` ${t("Core creates E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""}
+ {deployment.mode === "direct" ? ` ${t("Core creates {{provider}} sandboxes directly. No node enrollment is needed.", { provider: setup?.label })} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""}
{deployment.suspension ?
{t("Idle suspension")} {t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })} : null}
{t("Allocated resources")} {deployment.resources?.allocations ?? t("Unknown state")}
@@ -59,16 +54,8 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
{sandboxProviderLabel(deployment.provider, locale)}
{size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) })}` : ""}` : MISSING}
- {deployment.provider === "e2b" ? <>
- {deployment.configuration?.api_url}
- {deployment.configuration?.domain}
- {[
- t(buildStatusLabel[templateBuildStatus(build)]),
- ...(buildSize ? [sizeLabel(buildSize)] : []),
- ...(build?.resources.root_disk_mib != null ? [t("{{disk}} disk", { disk: formatBytes(build.resources.root_disk_mib * MIB) })] : []),
- ].join(" · ")}
- {t(deployment.credential_configured ? "Configured" : "Not configured")}
- > : null}
+ {setup?.fields.filter((field) => !field.secret).map((field) => {deployment.configuration?.[field.name] || MISSING} )}
+ {setup?.credential === "required" ? {t(deployment.credential_configured ? "Configured" : "Not configured")} : null}
{!deployment.reset ? { if (!pending) setChanging(false); }} footer={<> setChanging(false)}>{t("Cancel editing")} >}>
@@ -76,7 +63,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
key={editKey}
coreUrl={deployment.core_url}
expectedGeneration={deployment.generation}
- current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined}
+ current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, configuration: deployment.configuration } : undefined}
disabled={disabled || !canEdit}
editing
onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }}
diff --git a/apps/web/src/features/sandbox/SandboxResetControls.tsx b/apps/web/src/features/sandbox/SandboxResetControls.tsx
index 105157055..254a7272d 100644
--- a/apps/web/src/features/sandbox/SandboxResetControls.tsx
+++ b/apps/web/src/features/sandbox/SandboxResetControls.tsx
@@ -81,7 +81,7 @@ export function SandboxResetControls({ deployment, disabled, stale, onStart, onC
>}>
{dialog?.action === "cancel" ?
{t("Stop the reset and allow new hosted Sessions again. Sessions already archived stay archived and cannot be resumed.")}
: <>
-
{t("Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.")} {t("Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.")} {t("After cleanup, Core clears the provider and saved E2B credential, retires nodes and enrollment commands, and returns to setup.")}
+
{t("Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.")} {t("Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.")} {t("After cleanup, Core clears the provider and saved credential, retires nodes and enrollment commands, and returns to setup.")}
{dialog?.action === "force" ?
{t("Force immediately cancels remaining work. Offline resources can still block cleanup.")}
:
{t("Clear hosted work")}
setClear("auto")} />{t("Auto — let busy work finish")}{t("Idle, suspended and queued Sessions are archived immediately. Running or waiting Turns and file writes may finish until the deadline; then Core forces the rest.")}
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts b/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts
deleted file mode 100644
index 3d2b6a7d6..000000000
--- a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { validEndpoint } from "./SandboxSetupWizard";
-
-describe("E2B endpoint input", () => {
- it("accepts the official default and a paired compatible service", () => {
- expect(validEndpoint("", "")).toBe(true);
- expect(validEndpoint("https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai")).toBe(true);
- });
-
- it("rejects incomplete, local and malformed selectors before submission", () => {
- expect(validEndpoint("https://sandbox-test.sandbase.ai", "")).toBe(false);
- expect(validEndpoint("https://localhost", "sandbox-test.sandbase.ai")).toBe(false);
- expect(validEndpoint("https://127.0.0.1", "sandbox-test.sandbase.ai")).toBe(false);
- expect(validEndpoint("https://bad-.example", "good.example")).toBe(false);
- expect(validEndpoint("https://good.example", "-bad.example")).toBe(false);
- expect(validEndpoint("https://unrelated.example", "sandbox-test.sandbase.ai")).toBe(false);
- });
-});
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
index 45fad9877..8c09db31f 100644
--- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
+++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
@@ -1,8 +1,8 @@
-import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client";
+import { AgentCoreError, deploymentContract, sandboxProviderSetup, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxConfigurationOption, type SandboxDiscoveryInput, type SandboxProvider, type SandboxResources, type SandboxSetupField, type SandboxSpecification } from "@oac/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, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react";
+import { ArrowLeft, ArrowRight, Cloud, 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";
@@ -17,55 +17,33 @@ import type { ParseKeys } from "i18next";
import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels";
import { defaultSandboxResources, savedSpecification, selectedWorkspace, validSandboxResources } from "./deployment-specification";
import { workspaceStorageQuery } from "../settings/workspace-storage";
-import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update";
+import { credentialReady, submittedCredential } from "./sandbox-update";
import { sandboxAdmin } from "./sandbox-queries";
import "./sandbox-wizard.css";
type Where = "nodes" | "direct";
-type Step = "where" | "backend" | "e2b" | "size" | "review" | "advanced";
+type Step = "where" | "backend" | "connection" | "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";
+type Values = Record;
+const kinds = Object.keys(deploymentContract.providers) as SandboxProvider[];
+const providersOf = (mode: Where) => kinds.filter((kind) => deploymentContract.providers[kind].mode === mode);
+
+/** The service whose values the configuration holds, or -1 for a custom one. */
+function serviceOf(provider: SandboxProvider, configuration: Values): number {
+ return sandboxProviderSetup(provider).presets.findIndex((preset) => Object.entries(preset.values).every(([name, value]) => configuration[name] === value));
+}
+
+/** The values of the connection fields a service sets, empty for a custom one. */
+function serviceValues(provider: SandboxProvider, index: number): Values {
+ const setup = sandboxProviderSetup(provider);
+ return Object.fromEntries(setup.fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, setup.presets[index]?.values[field.name] ?? ""]));
}
const MIB = 2 ** 20;
const bounds = Object.fromEntries(deploymentContract.resources.map(({ name, min, max }) => [name, `${min}–${max}`]));
const resourceBounds = { cpus: bounds.cpus, memory: bounds.memory_mib, disk: deploymentContract.minimum_disk };
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 Provider's declared default size: half and
* double of it, disks included where the default declares them. A Provider
@@ -91,23 +69,24 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese
/**
* Hosted sandbox setup as pages, one decision each: where sandboxes run,
- * which backend (own machines, microsandbox preselected) or the E2B account,
- * how big each sandbox is (only for a Provider that declares a default size:
- * E2B sandboxes take the template build's size), then a review. The Provider's
- * declarations decide the size and disk inputs. Advanced settings hold the
- * complete form.
+ * which backend (own machines, the recommended one preselected) or the
+ * Provider's connection, how big each sandbox is (only for a Provider that
+ * declares a default size), then a review. A Provider's setup declaration
+ * decides its connection fields, services, cautions and discovered options; the
+ * Provider's resource declarations decide the size and disk inputs. Advanced
+ * settings hold the complete size form.
* `current` pre-selects the saved choices when a deployment changes. Keeping
* the backend keeps its saved size; another backend starts from its defaults.
- * E2B updates can retain the saved key.
- * Docker isolates less than microsandbox, so choosing it takes a confirmation,
- * once per wizard session; a saved Docker deployment has already made it.
+ * An update can retain the saved credential.
+ * A Provider that declares cautions takes a confirmation, once per wizard
+ * session; a saved deployment has already made it.
* Core's address is config.json's `public_url`: the review only shows it, and
* a configuration Core rejects for it is explained here, where it was saved.
*/
type WizardProps = {
coreUrl: string;
expectedGeneration: number;
- current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string; e2bAPIURL?: string; e2bDomain?: string };
+ current?: { provider: SandboxProvider; specification?: SandboxSpecification; configuration?: Values };
disabled: boolean;
} & ({ editing: true; onSubmit: (input: UpdateSandboxDeployment) => Promise } |
{ editing?: false; onSubmit: (input: InitializeSandboxDeployment) => Promise });
@@ -118,10 +97,11 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
const id = useId();
const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en";
const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null;
- const [step, setStep] = useState(editing ? currentMode === "direct" ? "e2b" : "size" : "where");
+ const [step, setStep] = useState(editing ? currentMode === "direct" ? "connection" : "size" : "where");
const [where, setWhere] = useState(currentMode);
const [provider, setProvider] = useState(current?.provider ?? null);
const policy = provider ? deploymentContract.providers[provider] : null;
+ const setup = provider ? sandboxProviderSetup(provider) : null;
const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null;
// The selected storage sizes a first initialization; a saved specification takes precedence.
const storage = useQuery(workspaceStorageQuery).data;
@@ -129,25 +109,14 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
// Choosing a Provider that declares a default size replaces this placeholder.
const [resources, setResources] = useState(current?.specification?.resources ?? { cpus: 0, memory_mib: 0 });
const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard");
- const [apiKey, setApiKey] = useState("");
+ // Every declared field's value, secret ones included; a secret is cleared once a save was attempted.
+ const [values, setValues] = useState(current?.configuration ?? {});
const [replacementRequested, setReplacementRequested] = useState(false);
- const [template, setTemplate] = useState(current?.e2bTemplate ?? "");
- 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 [busy, setBusy] = useState(false);
- const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker");
- const [confirmingDocker, setConfirmingDocker] = useState(false);
- const keepMicrosandbox = useRef(null);
- // Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url.
+ const [confirmed, setConfirmed] = useState(current?.provider ?? null);
+ const [confirming, setConfirming] = useState(null);
+ const goBack = useRef(null);
+ // Core's reason for rejecting the saved configuration, such as a loopback public_url.
const [rejection, setRejection] = useState(null);
const [fieldRejection, setFieldRejection] = useState(null);
const fieldError = (param: string) => coreFieldError(fieldRejection, param, tCommon);
@@ -156,50 +125,38 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
const installation = useQuery(installationQuery);
const address = coreUrl || installation.data?.public_url || null;
const { navigate } = useConsoleNavigation();
+ const text = (value: { readonly en: string; readonly "zh-CN": string }) => value[locale];
- 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([]);
+ const fields = setup?.fields ?? [];
+ const secretFields = fields.filter((field) => field.secret);
+ const valueOf = (field: SandboxSetupField) => (values[field.name] ?? "").trim();
+ // A connection change invalidates the discovered values; a secret edited while updating keeps the saved ones.
+ function change(field: SandboxSetupField, value: string) {
+ const resets = field.secret && editing ? [] : fields.filter((other) => other.discovered);
+ setValues((previous) => ({ ...previous, ...Object.fromEntries(resets.map((other) => [other.name, ""])), [field.name]: value }));
+ setFieldRejection(null);
}
-
- // Initial setup requires a key; an update may retain the committed key.
- const keyReady = e2bKeyReady(Boolean(editing), replacementRequested, apiKey);
- const connectionChanged = Boolean(editing && (apiURL.trim() !== (current?.e2bAPIURL || E2B_PRESETS.official.apiURL) || domain.trim() !== (current?.e2bDomain || E2B_PRESETS.official.domain)));
- const e2bReady = provider !== "e2b" || (keyReady && validTemplate(template.trim()) && validEndpoint(apiURL.trim(), domain.trim()) && (!editing || !connectionChanged || apiKey.trim().length > 0));
+ const service = provider ? serviceOf(provider, values) : -1;
+ // Discovery waits for the credential and every required value it depends on.
+ const dependencies = fields.filter((field) => !field.discovered && !field.optional);
+ const request: Omit | null = dependencies.length > 0 && dependencies.every((field) => valueOf(field)) ? {
+ configuration: Object.fromEntries(fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, valueOf(field)] as const).filter(([, value]) => value)),
+ credential: Object.fromEntries(secretFields.map((field) => [field.name, valueOf(field)])),
+ } : null;
+ // Initial setup requires the credential; an update may retain the committed one.
+ const keyReady = credentialReady(Boolean(editing), replacementRequested, secretFields.every((field) => valueOf(field)));
+ const connectionReady = fields.every((field) => field.secret || field.optional || valueOf(field));
// A Provider without a declared default size takes it from its configuration, so the selection sends no resources.
const sized = Boolean(policy?.default_resources);
const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources, workspace));
- const ready = provider !== null && e2bReady && sizeReady && !disabled && !busy;
+ const ready = provider !== null && keyReady && connectionReady && sizeReady && !disabled && !busy;
- const order: Step[] = editing ? where === "direct" ? ["e2b", "review"] : ["size", "review"] : where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"];
+ const connects = where === "direct";
+ const order: Step[] = editing ? connects ? ["connection", "review"] : ["size", "review"] : connects ? ["where", "connection", "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)]!);
- // The saved backend keeps its size; another starts from its declared default size.
+ // The saved backend keeps its saved size and connection; another starts from its declared defaults.
function choose(next: SandboxProvider) {
if (next !== provider) {
setRejection(null);
@@ -207,15 +164,19 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
const proposed = kept?.resources ?? defaultSandboxResources(next);
if (proposed) setResources(proposed);
setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard");
+ setValues(kept ? current?.configuration ?? {} : serviceValues(next, 0));
}
setProvider(next);
}
- function selectDocker() {
- setConfirmingDocker(false);
- setDockerConfirmed(true);
- choose("docker");
- setStep("size");
+ function proceed(next: SandboxProvider) {
+ setConfirming(null);
+ choose(next);
+ setStep(deploymentContract.providers[next].mode === "direct" ? "connection" : "size");
+ }
+ function select(next: SandboxProvider) {
+ if (sandboxProviderSetup(next).cautions.length > 0 && confirmed !== next) setConfirming(next);
+ else proceed(next);
}
async function save(event?: FormEvent) {
@@ -226,9 +187,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
const selection = {
provider, expected_generation: expectedGeneration,
...(sized ? { resources } : {}),
+ ...(fields.length ? { configuration: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.name, valueOf(field)])) } : {}),
+ ...submittedCredential(secretFields.map((field) => [field.name, valueOf(field)])),
};
- if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { ...e2bUpdateSelection(template, apiKey), configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) });
- else await onSubmit({ ...selection, ...(provider === "e2b" ? { credential: { api_key: apiKey.trim() }, configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) });
+ await onSubmit(selection);
} catch (error) {
// A configuration Core rejected is explained here; the page reports every other failure.
const reason = sandboxConfigurationRejection(error, locale);
@@ -236,13 +198,13 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
setRejection(reason);
setFieldRejection(error);
if (error instanceof AgentCoreError && error.param) {
- if (["credential", "configuration", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("e2b");
+ if (["credential", "configuration"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("connection");
else if (error.param.startsWith("resources.")) setStep("advanced");
}
setResetRequired(error instanceof AgentCoreError && ["sandbox_credential_ownership", "sandbox_reset_required"].includes(error.code ?? ""));
setAddressRejected(error instanceof AgentCoreError && error.code === "sandbox_configuration_error");
} finally {
- setApiKey("");
+ setValues((previous) => ({ ...previous, ...Object.fromEntries(secretFields.map((field) => [field.name, ""])) }));
setBusy(false);
}
}
@@ -252,77 +214,69 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
let page: ReactNode;
if (step === "where") {
+ const hosted = providersOf("direct");
+ const help = [...hosted.map((kind) => `${sandboxProviderSetup(kind).label}: ${text(sandboxProviderSetup(kind).summary)}`), t("Own machines run sandboxes on hosts you add.")].join(" ");
page = (
-
+
- { setWhere("direct"); choose("e2b"); setStep("e2b"); }} />
- { setWhere("nodes"); setApiKey(""); if (policy?.mode !== "nodes") choose("microsandbox"); setStep("backend"); }} />
+ {hosted.map((kind) => { setWhere("direct"); select(kind); }} />)}
+ {providersOf("nodes").length > 0 ? {
+ setWhere("nodes");
+ setValues({});
+ if (policy?.mode !== "nodes") choose(providersOf("nodes").find((kind) => sandboxProviderSetup(kind).recommended) ?? providersOf("nodes")[0]!);
+ setStep("backend");
+ }} /> : null}
);
} else if (step === "backend") {
+ const backends = providersOf("nodes");
page = (
-
+ `${sandboxProviderSetup(kind).label}: ${text(sandboxProviderSetup(kind).summary)}`).join(" ")}>
- { choose("microsandbox"); setStep("size"); }} />
- { if (dockerConfirmed) selectDocker(); else setConfirmingDocker(true); }} />
+ {backends.map((kind) => select(kind)} />)}
);
- } else if (step === "e2b") {
+ } else if (step === "connection" && setup) {
+ const preset = setup.presets[service];
page = (
-
+
-
- {
- const next = event.target.value as E2BService;
- setService(next);
- const preset = next === "custom" ? { apiURL: "", domain: "" } : E2B_PRESETS[next];
- changeConnection("", preset.apiURL, preset.domain);
- }}>
- SandBase Sandbox
- E2B
- {t("Other E2B-compatible provider")}
-
-
-
- {t("Console → API keys")}
-
- ) : null}>
- { if (editing) { setApiKey(event.target.value); setTemplates([]); setBuilds([]); } else changeConnection(event.target.value, apiURL, domain); setReplacementRequested(Boolean(event.target.value.trim())); setFieldRejection(null); }} />
-
- {discovery === "loading" ?
{t("Loading templates…")}
: null}
- {discovery === "error" ?
{t("Could not load templates. Check the key and provider connection.")} setDiscoveryRetry((value) => value + 1)}>{t("Try again")}
: null}
- {discovery === "ready" && templates.length === 0 ?
{t("No templates are visible to this key.")}
: null}
- {editing && !apiKey.trim() ?
setTemplate(event.target.value)} /> : <>
-
- { setSelectedTemplate(event.target.value); setTemplate(""); }}>
- {t("Select a template")}
- {editing && selectedTemplate && !templates.some((item) => item.id === selectedTemplate) ? {t("Current")} · {selectedTemplate} : null}
- {templates.map((item) => {item.names[0] ? `${item.names[0]} · ` : ""}{item.id} )}
-
-
- {buildDiscovery === "loading" ?
{t("Loading ready builds…")}
: null}
- {buildDiscovery === "error" ?
{t("Could not load builds.")} setDiscoveryRetry((value) => value + 1)}>{t("Try again")}
: null}
- {buildDiscovery === "ready" && builds.length === 0 ?
{t("This template has no ready builds.")}
: null}
-
- setTemplate(event.target.value)}>
- {t("Select a ready build")}
- {editing && template && !builds.some((item) => `${selectedTemplate}:${item.id}` === template) ? {t("Current")} · {template} : null}
- {builds.map((item) => {item.id} · {item.cpus} CPU / {item.memory_mib} MiB )}
-
-
- >}
-
- 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} />
-
+ {setup.presets.length > 0 ? (
+
+ setValues(serviceValues(provider!, Number(event.target.value)))}>
+ {setup.presets.map((entry, position) => {entry.label} )}
+ {t("Other compatible service")}
+
+
+ ) : null}
+ {fields.map((field) => {
+ const fieldId = `${id}-${field.name}`;
+ const help = field.help.en ? text(field.help) : undefined;
+ if (field.secret) {
+ return (
+
+ {t("Console → API keys")}
+
+ ) : null}>
+ { change(field, event.target.value); setReplacementRequested(Boolean(event.target.value.trim())); }} />
+
+ );
+ }
+ if (field.discovered) {
+ return
change(field, value)} />;
+ }
+ return (
+
+ change(field, event.target.value)} autoComplete="off" spellCheck={false} />
+
+ );
+ })}
- setStep("review")} nextDisabled={!e2bReady} t={t} />
+ setStep("review")} nextDisabled={!(keyReady && connectionReady)} t={t} />
);
} else if (step === "size") {
@@ -356,18 +310,16 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
page = (
-
{t("Sandboxes run on")} {where === "direct" ? t("E2B cloud") : `${t("Own machines")} · ${sandboxProviderLabel(provider ?? "", locale)}`}
-
{t("Each sandbox")} {sized ? sizeLabel(resources) : t("From the template build")}{policy?.disk ? {diskLabel(resources)} : null}
- {provider === "e2b" ?
{t("Template build")} {template || "—"} : null}
- {provider === "e2b" ?
{t("Sandbox API URL")} {apiURL || "https://api.e2b.app"} : null}
- {provider === "e2b" ?
{t("Sandbox data-plane domain")} {domain || "e2b.app"} : null}
- {provider === "e2b" && editing ?
{t("E2B credential")} {t(replacementRequested ? "Replace saved key" : "Keep saved key")} : null}
+
{t("Sandboxes run on")} {where === "direct" ? setup?.label : `${t("Own machines")} · ${sandboxProviderLabel(provider ?? "", locale)}`}
+
{t("Each sandbox")} {sized ? sizeLabel(resources) : t("From the selected configuration")}{policy?.disk ? {diskLabel(resources)} : null}
+ {fields.filter((field) => !field.secret).map((field) =>
{text(field.label)} {valueOf(field) || "—"} )}
+ {secretFields.length > 0 && editing ?
{t("Credential")} {t(replacementRequested ? "Replace saved key" : "Keep saved key")} : null}
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
{address ? {address} : "—"}
{t("Managed in Settings")}
- {installation.data?.local_only ? {t("Set a public address before connecting remote nodes; E2B sandboxes need an HTTPS one.")} : null}
+ {installation.data?.local_only ? {setup?.public_origin === "required" ? t("Set a public address before connecting; {{provider}} sandboxes need an HTTPS one.", { provider: setup.label }) : t("Set a public address before connecting remote nodes.")} : null}
@@ -379,10 +331,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
) : null}
{/* Initial setup needs the cleared key re-entered; updates may keep the saved key. */}
- {provider === "e2b" && !keyReady ? (
+ {!keyReady ? (
- {t("Enter the E2B key again to save.")}
- setStep("e2b")}>{t("Enter the key")}
+ {t("Enter the key again to save.")}
+ setStep("connection")}>{t("Enter the key")}
{editing ? setReplacementRequested(false)}>{t("Keep saved key")} : null}
) : null}
@@ -402,7 +354,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{shown.session
diff --git a/apps/web/src/features/overview/FleetOverview.tsx b/apps/web/src/features/overview/FleetOverview.tsx
index 3698fde0d..6007b5153 100644
--- a/apps/web/src/features/overview/FleetOverview.tsx
+++ b/apps/web/src/features/overview/FleetOverview.tsx
@@ -22,14 +22,13 @@ export function overviewNodes(nodes: readonly SandboxNode[]): SandboxNode[] {
.map(({ node }) => node);
}
-/** E2B's cloud in place of machines: what Core holds there. */
+/** The provider's cloud in place of machines: what Core holds there. */
export interface CloudHost {
running: number;
pending: number;
- template: string | null;
}
-/** A compact fleet inventory: Core, then E2B's cloud or the nodes; each row opens its page. */
+/** A compact fleet inventory: Core, then the provider's cloud or the nodes; each row opens its page. */
export function FleetOverview({ nodes, cloud, coreLabel, coreTone, onOpenNode, onOpenCloud, onOpenCore }: {
nodes: readonly SandboxNode[];
/** A direct deployment: Core links to the Provider's cloud instead of to machines. */
diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx
index 58f9a46b5..fe907ecfc 100644
--- a/apps/web/src/features/overview/OverviewPage.tsx
+++ b/apps/web/src/features/overview/OverviewPage.tsx
@@ -316,7 +316,7 @@ function fleetDetail(state: FleetState, t: TFunction<"overview">): string {
function cloudHost(fleet: FleetSnapshot | null): CloudHost | null {
if (fleet?.deployment.mode !== "direct") return null;
- return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.configuration?.template || null };
+ return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending };
}
/** Core and its sandbox nodes (or the Provider's cloud) as an inventory; each row opens its page. */
From 243154a6b136c09978606895e48d5e3a2c9a5ae6 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Fri, 23 Oct 2026 20:01:32 +0000
Subject: [PATCH 3/7] Fix e2e selectors for the declared template field
---
apps/web/e2e/console.ts | 2 +-
apps/web/e2e/nodes.spec.ts | 6 +++---
apps/web/e2e/sandbox-generation.spec.ts | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts
index a703ecf56..14381899f 100644
--- a/apps/web/e2e/console.ts
+++ b/apps/web/e2e/console.ts
@@ -45,7 +45,7 @@ export async function openConsole(page: Page, request: APIRequestContext, hash =
/** Selects the ready build returned by the E2B discovery fixture. */
export async function selectFixtureE2BBuild(page: Page) {
await page.getByLabel("API key").fill("fixture-private-key");
- const template = page.getByLabel("Template", { exact: true });
+ const template = page.getByLabel("Template build");
await template.first().selectOption("template");
await template.nth(1).selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe");
}
diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts
index b09fb9000..29211a156 100644
--- a/apps/web/e2e/nodes.spec.ts
+++ b/apps/web/e2e/nodes.spec.ts
@@ -297,7 +297,7 @@ test("uses the official E2B preset and clears a selected build when the key chan
await selectFixtureE2BBuild(page);
await expect(page.getByRole("button", { name: "Next" })).toBeEnabled();
await page.getByLabel("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();
});
@@ -306,9 +306,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").first()).toContainText(saved);
+ await expect(edit.getByLabel("Template build").first()).toContainText(saved);
await edit.getByLabel("API key").fill("replacement-fixture-key");
- await expect(edit.getByLabel("Template").first()).toContainText(saved);
+ await expect(edit.getByLabel("Template build").first()).toContainText(saved);
await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled();
});
diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts
index 2d9a609bf..612823d57 100644
--- a/apps/web/e2e/sandbox-generation.spec.ts
+++ b/apps/web/e2e/sandbox-generation.spec.ts
@@ -212,7 +212,7 @@ test("a different E2B team leaves the committed configuration intact and require
await editE2B(page, "fixture-other-team-key");
const rejected = await saveConfiguration(page);
expect(rejected.response.status()).toBe(409);
- await expect(page.locator(".wizard-rejection")).toContainText("Reset before changing teams.");
+ await expect(page.locator(".wizard-rejection")).toContainText("Reset before changing accounts.");
await expect(page.locator(".wizard-rejection")).toContainText("the saved configuration is unchanged");
expect(await deploymentRead(page)).toEqual(before);
await expect(page.getByRole("button", { name: "Save configuration", exact: true })).toBeDisabled();
From 68bd2a199706116a8ca52c277078c526bfab6c7c Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Fri, 23 Oct 2026 20:03:31 +0000
Subject: [PATCH 4/7] Drop the disk assertion for the generic cloud view
---
apps/web/e2e/monitoring.spec.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts
index 30ee5c4b7..b18ec423d 100644
--- a/apps/web/e2e/monitoring.spec.ts
+++ b/apps/web/e2e/monitoring.spec.ts
@@ -251,8 +251,6 @@ test("shows a node's host on Nodes and opens a sandbox in a dialog from Sandbox
test("shows E2B's cloud instead of machines", async ({ page, request }) => {
await openConsole(page, request, "sandbox-metrics", { sandbox: "e2b" });
await expect(page.getByRole("heading", { name: "Provider cloud" })).toBeVisible();
- // Each sandbox takes the template build's size, disk included.
- await expect(page.getByText("10 GiB disk")).toBeVisible();
await expect(page.getByRole("button", { name: "Add node" })).toHaveCount(0);
await expect(page.getByRole("columnheader", { name: "Node", exact: true })).toHaveCount(0);
From e93993687ea3f997256712352ddfc5acfc444898 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Fri, 23 Oct 2026 20:08:06 +0000
Subject: [PATCH 5/7] Match the declared public-address caution in the e2e spec
---
apps/web/e2e/public-url.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts
index c743b9e65..ad0eb3af6 100644
--- a/apps/web/e2e/public-url.spec.ts
+++ b/apps/web/e2e/public-url.spec.ts
@@ -28,7 +28,7 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy
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("Set a public address before connecting remote nodes");
+ await expect(address).toContainText("Set a public address before connecting; E2B sandboxes need an HTTPS one.");
await page.getByRole("button", { name: "Save configuration" }).click();
const rejection = page.locator(".wizard-rejection");
await expect(rejection).toContainText("E2B sandboxes need a public HTTPS address.");
From 14dab15a68052701dd32cd9b403e5550cad9034f Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Fri, 23 Oct 2026 20:38:56 +0000
Subject: [PATCH 6/7] Align e2e specs with the declared discovery and rejection
copy
---
apps/web/e2e/nodes.spec.ts | 2 +-
apps/web/e2e/public-url.spec.ts | 2 +-
apps/web/e2e/sandbox-generation.spec.ts | 7 ++++++-
3 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts
index 29211a156..0bc1c57d8 100644
--- a/apps/web/e2e/nodes.spec.ts
+++ b/apps/web/e2e/nodes.spec.ts
@@ -306,7 +306,7 @@ 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").first()).toContainText(saved);
+ await expect(edit.getByLabel("Template build")).toHaveValue(saved);
await edit.getByLabel("API key").fill("replacement-fixture-key");
await expect(edit.getByLabel("Template build").first()).toContainText(saved);
await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled();
diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts
index ad0eb3af6..b7a7ddd2a 100644
--- a/apps/web/e2e/public-url.spec.ts
+++ b/apps/web/e2e/public-url.spec.ts
@@ -31,7 +31,7 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy
await expect(address).toContainText("Set a public address before connecting; E2B sandboxes need an HTTPS one.");
await page.getByRole("button", { name: "Save configuration" }).click();
const rejection = page.locator(".wizard-rejection");
- await expect(rejection).toContainText("E2B sandboxes need a public HTTPS address.");
+ await expect(rejection).toContainText("This sandbox provider needs a public HTTPS address.");
await expect(rejection.getByRole("button", { name: "Managed in Settings" })).toBeVisible();
// Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review.
await expect(page.getByRole("dialog")).toHaveCount(0);
diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts
index 612823d57..9bc9da9d1 100644
--- a/apps/web/e2e/sandbox-generation.spec.ts
+++ b/apps/web/e2e/sandbox-generation.spec.ts
@@ -34,7 +34,12 @@ async function saveConfiguration(page: Page) {
async function editE2B(page: Page, key?: string) {
await page.getByRole("button", { name: "Change resources", exact: true }).click();
await expect(page.getByLabel("API key", { exact: true })).toHaveValue("");
- if (key !== undefined) await page.getByLabel("API key", { exact: true }).fill(key);
+ if (key !== undefined) {
+ // The wizard checks a replacement key by listing options, after a short settle.
+ const checked = page.waitForResponse((response) => response.url().endsWith(templateDiscoveryPath));
+ await page.getByLabel("API key", { exact: true }).fill(key);
+ await checked;
+ }
await page.getByRole("button", { name: "Next", exact: true }).click();
}
From 11eb6f1ae8d6455a171e19d04a7bfc843b36d8d4 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Fri, 23 Oct 2026 20:40:47 +0000
Subject: [PATCH 7/7] Block Next while the credential is being checked
---
apps/web/e2e/sandbox-generation.spec.ts | 7 +------
.../src/features/sandbox/SandboxSetupWizard.tsx | 14 +++++++++-----
2 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts
index 9bc9da9d1..612823d57 100644
--- a/apps/web/e2e/sandbox-generation.spec.ts
+++ b/apps/web/e2e/sandbox-generation.spec.ts
@@ -34,12 +34,7 @@ async function saveConfiguration(page: Page) {
async function editE2B(page: Page, key?: string) {
await page.getByRole("button", { name: "Change resources", exact: true }).click();
await expect(page.getByLabel("API key", { exact: true })).toHaveValue("");
- if (key !== undefined) {
- // The wizard checks a replacement key by listing options, after a short settle.
- const checked = page.waitForResponse((response) => response.url().endsWith(templateDiscoveryPath));
- await page.getByLabel("API key", { exact: true }).fill(key);
- await checked;
- }
+ if (key !== undefined) await page.getByLabel("API key", { exact: true }).fill(key);
await page.getByRole("button", { name: "Next", exact: true }).click();
}
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
index 8c09db31f..95e0138d6 100644
--- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
+++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
@@ -112,6 +112,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
// Every declared field's value, secret ones included; a secret is cleared once a save was attempted.
const [values, setValues] = useState(current?.configuration ?? {});
const [replacementRequested, setReplacementRequested] = useState(false);
+ const [checking, setChecking] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmed, setConfirmed] = useState(current?.provider ?? null);
const [confirming, setConfirming] = useState(null);
@@ -267,7 +268,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
);
}
if (field.discovered) {
- return change(field, value)} />;
+ return change(field, value)} onChecking={setChecking} />;
}
return (
@@ -276,7 +277,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
);
})}
- setStep("review")} nextDisabled={!(keyReady && connectionReady)} t={t} />
+ setStep("review")} nextDisabled={!(keyReady && connectionReady) || checking} t={t} />
);
} else if (step === "size") {
@@ -469,15 +470,19 @@ type Level = "loading" | "error" | SandboxConfigurationOption[];
* complete. The page remounts it when the connection changes. While updating
* without a new credential it keeps the saved value in an input.
*/
-function DiscoveredField({ id, provider, field, label, help, request, value, editing, error, onChange }: {
+function DiscoveredField({ id, provider, field, label, help, request, value, editing, error, onChange, onChecking }: {
id: string; provider: SandboxProvider; field: SandboxSetupField; label: string; help?: string; request: Omit | null;
- value: string; editing: boolean; error: string | null; onChange: (value: string) => void;
+ value: string; editing: boolean; error: string | null; onChange: (value: string) => void; onChecking: (checking: boolean) => void;
}) {
const { t } = useTranslation("sandbox");
const [picks, setPicks] = useState([]);
const [levels, setLevels] = useState([]);
const [retry, setRetry] = useState(0);
const open = picks.length === 0 || !picks[picks.length - 1]!.complete;
+ const last = levels[picks.length];
+ // The step cannot be left while the credential is still being checked, whether settling or loading.
+ const checking = Boolean(request) && open && (last === undefined || last === "loading");
+ useEffect(() => { onChecking(checking); return () => onChecking(false); }, [checking]);
useEffect(() => {
if (!request || !open) return;
const depth = picks.length;
@@ -496,7 +501,6 @@ function DiscoveredField({ id, provider, field, label, help, request, value, edi
if (!request && editing && value) {
return onChange(event.target.value)} autoComplete="off" spellCheck={false} /> ;
}
- const last = levels[picks.length];
const choose = (depth: number, option: SandboxConfigurationOption | undefined) => {
setLevels((previous) => previous.slice(0, depth + 1));
setPicks((previous) => option ? [...previous.slice(0, depth), option] : previous.slice(0, depth));