From c3ae83e12b5b0593b0126d017ed44ebe6ac78b07 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 08:42:56 +0000 Subject: [PATCH] Set up sandboxes page by page --- apps/web/DESIGN.md | 15 + apps/web/PRODUCT.md | 8 +- apps/web/e2e/console.ts | 8 +- apps/web/e2e/fixture-console.mjs | 26 +- apps/web/e2e/nodes.spec.ts | 61 ++- .../src/features/onboarding/onboarding.css | 4 +- .../sandbox/SandboxDeploymentSettings.tsx | 15 +- .../features/sandbox/SandboxManagerView.tsx | 6 +- .../web/src/features/sandbox/SandboxSetup.tsx | 90 ----- .../features/sandbox/SandboxSetupWizard.tsx | 355 ++++++++++++++++++ .../src/features/sandbox/runtime-release.ts | 20 + .../src/features/sandbox/sandbox-wizard.css | 261 +++++++++++++ apps/web/src/features/system/SystemPage.tsx | 9 + apps/web/src/i18n/locales/en/system.ts | 8 + apps/web/src/i18n/locales/zh-CN/system.ts | 8 + apps/web/src/lib/locale-strings.ts | 58 ++- apps/web/vite.config.ts | 1 + docs/web/protocol-coverage.md | 7 +- 18 files changed, 821 insertions(+), 139 deletions(-) delete mode 100644 apps/web/src/features/sandbox/SandboxSetup.tsx create mode 100644 apps/web/src/features/sandbox/SandboxSetupWizard.tsx create mode 100644 apps/web/src/features/sandbox/runtime-release.ts create mode 100644 apps/web/src/features/sandbox/sandbox-wizard.css diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index faef2812b..a59c4e97b 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -590,6 +590,21 @@ the onboarding forward and reveals the console in a circle growing from the pressed button. With reduced motion the orbits hold their places, the grid is a still frame, the request is a plain block and no transition runs. +### Sandbox setup +Setting up hosted sandboxes, and changing the provider or resources in maintenance, +is a set of pages inside the Nodes page, one decision each: where sandboxes run (own machines +or E2B), then the backend (Docker or microsandbox, compared behind a help tip) or +the E2B account, then the size of each sandbox (three presets), then a review. +Choices are large cards that advance on a click; short indigo dashes show the +progress; pages slide and blur across. The review states where sandboxes run, +the size, the Runtime (taken from this console's distribution manifest) and the +Core address, asking for it only when the console's own address cannot serve. +Advanced settings, one link away, hold the complete form: resources, the Core +address, the Runtime release and the E2B template. A change keeps the saved size +and Runtime while the backend stays the same (a saved size outside the presets is +offered as Current); another backend starts from its standard size and this +console's Runtime, and E2B always needs its key again. Rules sit behind help tips. + ### Loading and motion The console has no spinners and no "Loading…" lines. Reads are cached (TanStack Query) and prefetched on navigation hover, so revisits show data at once and diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 9523e21d3..f2095c93d 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -66,9 +66,11 @@ workbench. Creator column naming the creating key. Detail pages show the resource's facts and offer Copy and Delete. - **Platform**: Projects and keys (projects, their assets and usage, named keys, - write history), Nodes (sandbox deployment, the node list with each node's - capacity, host figures and allocations, enrollment and removal), System (startup - configuration and the sandbox deployment). + write history), Nodes (sandbox setup as pages — where sandboxes run, the + backend or E2B account, the size of each sandbox, a review, and advanced settings + with the complete form — then the node list with each node's capacity, host + figures and allocations, enrollment and removal), System (startup configuration + and the sandbox deployment, including each sandbox's size and the Runtime). - **First run**: after the administrator account is created and while no project exists, full-screen steps outside the shell create the first project (default name `Default`) and its first key, show the plaintext once with an example request, diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 80468b90f..132ee6d7a 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -4,13 +4,13 @@ const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`; /** Fresh fixture state: "setup" (no administrator yet), "login" or "authenticated". */ -export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated", options: { fresh?: boolean } = {}) { - await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}`); +export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" } = {}) { + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}`); } /** Opens the console already signed in, in English. */ -export async function openConsole(page: Page, request: APIRequestContext, hash = "overview") { - await resetFixture(request, "authenticated"); +export async function openConsole(page: Page, request: APIRequestContext, hash = "overview", options: { sandbox?: "configured" | "none" } = {}) { + await resetFixture(request, "authenticated", options); await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]); await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); await page.goto(`/#${hash}`); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index c070f9f3b..94f770f86 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -14,7 +14,16 @@ const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); const SESSION_COOKIE = "core_console=fixture-session"; let state; -function reset(mode = "setup", fresh = false) { +const hex = (c) => c.repeat(64); +/** The Runtime release this fixture's distribution manifest describes. */ +const release = { source_commit: "c0ffee".padEnd(40, "0"), image_id: `sha256:${hex("1")}`, image_manifest_digest: `sha256:${hex("2")}`, microsandbox_ref: `parsar-core-runtime@sha256:${hex("3")}`, runtime_sha256: hex("4"), firmware_sha256: hex("5") }; +const manifest = { platform: "linux/amd64", source_commit: release.source_commit, images: { runtime: release.image_id }, image_manifest_digests: { runtime: release.image_manifest_digest }, runtime_ref: release.microsandbox_ref, microsandbox: { runtime_sha256: release.runtime_sha256, firmware_sha256: release.firmware_sha256 }, artifacts: {} }; + +function configuredDeployment() { + return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture" }; +} + +function reset(mode = "setup", fresh = false, sandbox = "configured") { const base = buildDemo(); const now = Math.floor(Date.now() / 1000); const resources = buildResources(now, base.agents, base.sessions); @@ -25,6 +34,8 @@ function reset(mode = "setup", fresh = false) { ...base, resources, admin, auth: { mode, username: mode === "authenticated" ? "admin" : null, password: mode === "setup" ? null : "correct horse battery" }, violations: [], writes: [], failNext: null, nextId: 1, + // "none": the deployment is not configured yet, so the Nodes page offers setup. + deployment: sandbox === "none" ? null : configuredDeployment(), }; } reset(); @@ -189,8 +200,15 @@ function adminRead(response, path, url) { } async function sandboxRoute(request, response, path) { + if (path === "/deployment" && request.method === "POST") { + const input = await body(request); + if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); + if (!input.resources || (input.provider !== "e2b" && !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); + state.deployment = { ...configuredDeployment(), provider: input.provider, core_url: input.core_url, mode: input.provider === "e2b" ? "direct" : "nodes", specification: { resources: input.resources, ...(input.runtime ? { runtime: input.runtime } : {}) } }; + return send(response, 201, state.deployment); + } if (path === "/deployment") { - return send(response, 200, { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: state.allocations.length, pending: 0 } }); + return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 } }); } if (path === "/nodes") return send(response, 200, { data: state.nodes }); if (path === "/enrollment-tokens" && request.method === "POST") { @@ -211,7 +229,7 @@ async function sandboxRoute(request, response, path) { async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "setup", url.searchParams.get("projects") === "none"); + reset(url.searchParams.get("auth") ?? "setup", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured"); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { @@ -226,6 +244,8 @@ http.createServer(async (request, response) => { const url = new URL(request.url, `http://127.0.0.1:${port}`); try { if (url.pathname.startsWith("/__fixture/")) return await fixtureRoute(request, response, url); + // The console service serves its distribution manifest to anyone, as nodes download it. + if (url.pathname === "/node-install/manifest.json") return send(response, 200, manifest); if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) { state.violations.push(`${request.method} ${url.pathname}`); return error(response, 404, "The console does not serve /v1."); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 3781daa66..0d462d338 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -18,7 +18,22 @@ test("prepares a one-time node command and removes a node after confirmation", a await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); }); -test("preserves saved resources and Runtime, and clears provider-specific fields on switching", async ({ page, request }) => { +test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { + await openConsole(page, request, "nodes", { sandbox: "none" }); + await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); + await page.getByRole("button", { name: "Own machines" }).click(); + await page.getByRole("button", { name: "Docker" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); + await page.getByLabel("Core address").fill("https://core.example.com"); + await page.getByRole("button", { name: "Save configuration" }).click(); + + // The saved specification carries the Runtime read from the console's manifest. + await expect(page.getByText("c0ffee000000")).toBeVisible(); + await expect(page.getByRole("button", { name: "Add node" })).toBeVisible(); +}); + +test("keeps the saved size and Runtime for the same backend, and starts another from its defaults", async ({ page, request }) => { const runtime = { source_commit: "0".repeat(40), image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}`, microsandbox_ref: `parsar-core-runtime@sha256:${"b".repeat(64)}`, runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) }; const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime }; @@ -30,29 +45,39 @@ test("preserves saved resources and Runtime, and clears provider-specific fields await route.fulfill({ json: deployment }); }); await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ json: { data: [] } })); - await page.route("**/node-install/manifest.json", (route) => route.fulfill({ json: { platform: "linux/amd64", source_commit: "e".repeat(40), - images: { runtime: `sha256:${"f".repeat(64)}` }, image_manifest_digests: { runtime: `sha256:${"1".repeat(64)}` }, - runtime_ref: `parsar-core-runtime@sha256:${"1".repeat(64)}`, microsandbox: { runtime_sha256: "2".repeat(64), firmware_sha256: "3".repeat(64) } } })); await openConsole(page, request, "nodes"); + const save = page.getByRole("button", { name: "Save and stay in maintenance" }); + const back = page.getByRole("button", { name: "Back" }); + + // The same backend keeps its saved size and Runtime. await page.getByRole("button", { name: "Change provider or resources" }).click(); - await page.getByLabel("Where to run sandboxes").selectOption("nodes"); - await page.getByRole("combobox", { name: "Sandbox provider", exact: true }).selectOption("docker"); - await page.getByText("Advanced sandbox resources", { exact: true }).click(); - await expect(page.getByLabel("CPU cores per sandbox", { exact: true })).toHaveValue("7"); - await page.getByRole("button", { name: "Save provider and stay in maintenance" }).click(); + await page.getByRole("button", { name: "Own machines" }).click(); + await page.getByRole("button", { name: "Docker" }).click(); + await page.getByRole("button", { name: /^Current/ }).click(); + await save.click(); await expect.poll(() => submitted?.resources).toEqual(current.resources); expect(submitted?.runtime).toEqual(runtime); - expect(submitted?.specification).toBeUndefined(); - await page.getByRole("combobox", { name: "Sandbox provider", exact: true }).selectOption("microsandbox"); - await expect(page.getByLabel("CPU cores per sandbox", { exact: true })).toHaveValue("2"); - await page.getByRole("button", { name: "Save provider and stay in maintenance" }).click(); + + // Another backend starts from its own size, with disks, and this console's Runtime. + await back.click(); + await back.click(); + await page.getByRole("button", { name: "microsandbox" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + await save.click(); await expect.poll(() => submitted?.provider).toBe("microsandbox"); expect(submitted?.resources).toEqual({ cpus: 2, memory_mib: 4096, root_disk_mib: 8192, environment_disk_mib: 8192 }); - expect(submitted?.runtime).toMatchObject({ source_commit: "e".repeat(40) }); - await page.getByLabel("Where to run sandboxes").selectOption("direct"); - await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key"); - await page.getByLabel("Immutable Runtime template", { exact: true }).fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); - await page.getByRole("button", { name: "Save provider and stay in maintenance" }).click(); + expect(submitted?.runtime).toMatchObject({ source_commit: "c0ffee".padEnd(40, "0") }); + + // E2B needs its key again and takes no Runtime or disks. + await back.click(); + await back.click(); + await back.click(); + await page.getByRole("button", { name: "E2B cloud" }).click(); + await page.getByLabel("E2B API key").fill("fixture-private-key"); + await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + await save.click(); await expect.poll(() => submitted?.provider).toBe("e2b"); expect(submitted?.resources).toEqual({ cpus: 2, memory_mib: 2048 }); expect(submitted?.runtime).toBeUndefined(); diff --git a/apps/web/src/features/onboarding/onboarding.css b/apps/web/src/features/onboarding/onboarding.css index d4418cbf0..6b35f9fcd 100644 --- a/apps/web/src/features/onboarding/onboarding.css +++ b/apps/web/src/features/onboarding/onboarding.css @@ -378,7 +378,7 @@ color: var(--accent); font-size: 12px; font-weight: 600; - background: color-mix(in oklch, var(--accent) 10%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 999px; } @@ -415,7 +415,7 @@ height: 30px; place-items: center; color: var(--accent); - background: color-mix(in oklch, var(--accent) 8%, var(--surface)); + background: color-mix(in srgb, var(--accent) 8%, var(--surface)); border-radius: var(--radius-control); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent) 18%, transparent); } diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index f95798c32..9ed571d3e 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -2,8 +2,9 @@ import { useState } from "react"; import type { InitializeSandboxDeployment, SandboxDeployment } from "@agents-core-web/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; +import { formatBytes, MISSING } from "../../lib/format"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; -import { SandboxSetup } from "./SandboxSetup"; +import { SandboxSetupWizard } from "./SandboxSetupWizard"; export function SandboxDeploymentSettings({ deployment, disabled, fresh, error, onMaintenance, onUpdate, onRefresh }: { deployment: SandboxDeployment; @@ -17,6 +18,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, error, const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; const [changing, setChanging] = useState(false); + const spec = deployment.specification; const clean = fresh && deployment.resources?.allocations === 0 && deployment.resources?.pending === 0; return
@@ -28,7 +30,8 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, error,
{t("Provider")}
{sandboxProviderLabel(deployment.provider, locale)}
- {deployment.specification ? <>
{t("CPU cores per sandbox")}
{deployment.specification.resources.cpus}
{t("Memory per sandbox (MiB)")}
{deployment.specification.resources.memory_mib}
: null} +
{t("Each sandbox")}
{spec ? `${t("{{cpus}} CPU · {{memory}}", { cpus: spec.resources.cpus, memory: formatBytes(spec.resources.memory_mib * 2 ** 20) })}${spec.resources.root_disk_mib ? ` · ${t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(spec.resources.root_disk_mib * 2 ** 20), data: formatBytes((spec.resources.environment_disk_mib ?? 0) * 2 ** 20) })}` : ""}` : MISSING}
+ {spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null}
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
@@ -44,7 +47,13 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, error, {t("Maintenance pauses new hosted placement. Clean up existing execution resources before changing provider; historical Sessions and results are preserved by the switch.")} {t("Stopped or offline resources, snapshots, uncertain creates and pending environments still block switching. Deleting a Session alone does not prove cleanup; Core must confirm both counts are zero.")}

{!changing ? : <> - + {t("Saving retires old node identities and enrollment credentials. It does not migrate Sessions or resume placement automatically.")} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 3ef127b06..2b6ef38d4 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -9,7 +9,7 @@ import { useConsoleNavigation } from "../../lib/console-navigation"; import { sandboxRequestError } from "../../lib/sandbox-labels"; import type { SandboxConsoleConfig } from "./console-config"; import { sandboxAdmin, sandboxConsoleConfigQuery, sandboxDeploymentQuery, sandboxScope, sandboxSnapshotQuery, type SandboxSnapshot } from "./sandbox-queries"; -import { SandboxSetup } from "./SandboxSetup"; +import { SandboxSetupWizard } from "./SandboxSetupWizard"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; import { NodeEnrollment } from "./NodeEnrollment"; import { NodeList } from "./NodeList"; @@ -193,14 +193,14 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{status} {snapshot && !snapshot.deployment.provider && writeError ?

{writeError}

: null} - {snapshot && !snapshot.deployment.provider ? : null} + {snapshot && !snapshot.deployment.provider ? : null} {snapshot?.deployment.provider ? <> {snapshot.deployment.maintenance ?

{t("Maintenance is enabled. New sandbox placement is paused.")}

: null} {hostedNodes ?
{nodes.length ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> - :

{t("Add your first node")}

{t("No nodes registered. Add a node to provide hosted capacity.")}

} + : }
: null} : null}
diff --git a/apps/web/src/features/sandbox/SandboxSetup.tsx b/apps/web/src/features/sandbox/SandboxSetup.tsx deleted file mode 100644 index 353a2e6fe..000000000 --- a/apps/web/src/features/sandbox/SandboxSetup.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState, type FormEvent } from "react"; -import type { InitializeSandboxDeployment, SandboxProvider, SandboxSpecification } from "@agents-core-web/agents-client"; -import { useTranslation } from "react-i18next"; -import { sandboxSetupOrigin } from "./core-origin"; -import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; - -export function SandboxSetup({ initialCoreUrl, disabled, onInitialize, switching = false, savedProvider, initialSpecification }: { - initialCoreUrl: string; - disabled: boolean; - onInitialize: (input: InitializeSandboxDeployment) => Promise; - switching?: boolean; - savedProvider?: SandboxProvider | ""; - initialSpecification?: SandboxSpecification; -}) { - const { t } = useTranslation("sandbox"); - const [location, setLocation] = useState<"" | "nodes" | "direct">(""); - const [provider, setProvider] = useState(""); - const [coreUrl, setCoreUrl] = useState(initialCoreUrl); - const [apiKey, setApiKey] = useState(""); - const [template, setTemplate] = useState(""); - const [specification, setSpecification] = useState(null); - const [specificationProvider, setSpecificationProvider] = useState(""); - const [specificationError, setSpecificationError] = useState(false); - const [loadAttempt, setLoadAttempt] = useState(0); - useEffect(() => { - setSpecification(null); setSpecificationProvider(""); setSpecificationError(false); - if (!provider) return; - const saved = savedSpecification(provider, savedProvider, initialSpecification); - if (saved) { setSpecification(saved); setSpecificationProvider(provider); return; } - const resources = defaultSandboxResources(provider); - if (provider === "e2b") { setSpecification({ resources }); setSpecificationProvider(provider); return; } - const controller = new AbortController(); - void distributionRuntime(controller.signal).then((runtime) => { - if (!controller.signal.aborted) { setSpecification({ resources, runtime }); setSpecificationProvider(provider); } - }).catch(() => { if (!controller.signal.aborted) setSpecificationError(true); }); - return () => controller.abort(); - }, [provider, savedProvider, initialSpecification, loadAttempt]); - const validSpecification = Boolean(provider && specificationProvider === provider && specification - && validSandboxResources(provider, specification.resources) && (provider === "e2b" || specification.runtime)); - const origin = sandboxSetupOrigin(coreUrl); - const needsNetworkAddress = !sandboxSetupOrigin(initialCoreUrl); - const validTemplate = /^[a-zA-Z0-9_-]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(template.trim()); - const validE2B = provider !== "e2b" || (apiKey.trim() && validTemplate); - async function submit(event: FormEvent) { - event.preventDefault(); - if (!provider || !origin || !validE2B || !validSpecification || disabled) return; - try { - await onInitialize({ provider, core_url: switching ? initialCoreUrl : origin, resources: specification!.resources, ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : { runtime: specification!.runtime }) }); - } finally { setApiKey(""); } - } - return
void submit(event)} aria-labelledby="sandbox-setup-heading"> -

{t(switching ? "Choose the next provider" : "Set up hosted sandboxes")}

-

{t("One provider serves this entire Core deployment. Switching requires maintenance and verified cleanup of all existing execution resources.")}

- - {location === "nodes" ? : null} - {provider ? <> - {specificationError ? <>

{t("The matching Runtime release could not be loaded. Check the console distribution and try again.")}

: !specification ?

{t("Loading Runtime specification…")}

:
- {t("Advanced sandbox resources")} -

{t(provider === "e2b" ? "CPU and memory must match the selected E2B template build. Core verifies them before saving." : "These limits apply to every sandbox in this deployment.")}

- - - {provider === "microsandbox" ? <> - - - : null} -
} - : null} - {location === "direct" ? <> -

{t("Core creates E2B sandboxes directly. No node enrollment is needed.")}

- -

{t("The key is write-only. It is cleared from this form after submission and never saved in browser storage.")}

- -

{template && !validTemplate ? t("Enter a template ID and build UUID separated by a colon.") : null}

-

{t("Use the qualified managed Runtime template ID followed by its build UUID. A template alias or a generic E2B template cannot provide Core execution.")}

- : null} - {switching ?

{t("Core origin stays unchanged")}: {initialCoreUrl}

:
{t("Advanced network settings")} - {needsNetworkAddress ?

{t("This console address cannot be used by sandbox guests. Enter the HTTPS Core address that your nodes and guests can reach.")}

: null} - -

{t("Use an HTTPS Core origin reachable from every node and sandbox guest, without a path or credentials. The console URL may be different.")}

- {coreUrl && !origin ?

{t("Enter a non-loopback HTTPS origin, such as https://core.example.")}

: null} -
} - -
; -} diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx new file mode 100644 index 000000000..2e59567e6 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -0,0 +1,355 @@ +import type { InitializeSandboxDeployment, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@agents-core-web/agents-client"; +import { useQuery } from "@tanstack/react-query"; +import { AnimatePresence } from "motion/react"; +import * as m from "motion/react-m"; +import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; +import { useId, useState, type FormEvent, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { HelpTip } from "../../components/console-ui"; +import { formatBytes } from "../../lib/format"; +import type { MessageKey } from "../../lib/locale-strings"; +import { sandboxSetupOrigin } from "./core-origin"; +import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; +import "./sandbox-wizard.css"; + +type Where = "nodes" | "direct"; +type Step = "where" | "backend" | "e2b" | "size" | "review" | "advanced"; +type Preset = "small" | "standard" | "large"; +type Size = Preset | "current" | "custom"; + +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); +} + +/** + * Per-sandbox presets around the deployment default: half and double of it. + * Disks apply to microsandbox only, the one provider that enforces them. + */ +function presets(provider: SandboxProvider): Record { + const standard = defaultSandboxResources(provider); + const scale = (factor: number): SandboxResources => ({ + cpus: Math.max(1, standard.cpus * factor), + memory_mib: standard.memory_mib * factor, + ...(standard.root_disk_mib ? { root_disk_mib: standard.root_disk_mib * factor, environment_disk_mib: standard.environment_disk_mib! * factor } : {}), + }); + return { small: scale(0.5), standard, large: scale(2) }; +} + +const releaseLabels: Record = { + source_commit: "Source commit", + image_id: "Image ID", + image_manifest_digest: "Image manifest digest", + microsandbox_ref: "microsandbox reference", + runtime_sha256: "Runtime SHA-256", + firmware_sha256: "Firmware SHA-256", +}; + +function presetOf(provider: SandboxProvider, resources: SandboxResources): Preset | null { + const same = (a: SandboxResources, b: SandboxResources) => a.cpus === b.cpus && a.memory_mib === b.memory_mib + && (a.root_disk_mib ?? 0) === (b.root_disk_mib ?? 0) && (a.environment_disk_mib ?? 0) === (b.environment_disk_mib ?? 0); + const all = presets(provider); + return (Object.keys(all) as Preset[]).find((key) => same(all[key], resources)) ?? null; +} + +/** + * Hosted sandbox setup as pages, one decision each: where sandboxes run, + * which backend (own machines) or the E2B account, how big each sandbox is, + * then a review. Advanced settings hold the complete form. The Runtime + * release comes from this console's distribution manifest when it serves one. + * `current` pre-selects the saved choices when a deployment changes. Keeping + * the backend keeps its saved size and Runtime; another backend starts from its + * defaults and this console's Runtime. The Core address stays as it is and an + * E2B key must be entered again. + */ +export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switching = false, onSubmit }: { + initialCoreUrl: string; + current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string }; + disabled: boolean; + switching?: boolean; + onSubmit: (input: InitializeSandboxDeployment) => Promise; +}) { + const { t } = useTranslation("sandbox"); + const id = useId(); + const [step, setStep] = useState("where"); + const [where, setWhere] = useState(current ? (current.provider === "e2b" ? "direct" : "nodes") : null); + const [provider, setProvider] = useState(current?.provider ?? null); + const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null; + const [resources, setResources] = useState(current?.specification?.resources ?? defaultSandboxResources("docker")); + const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); + // An unusable console address (loopback, plain HTTP) is not offered as the Core address. + const [coreUrl, setCoreUrl] = useState(sandboxSetupOrigin(initialCoreUrl) ? initialCoreUrl : ""); + const [apiKey, setApiKey] = useState(""); + const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); + const [runtime, setRuntime] = useState>({}); + const [busy, setBusy] = useState(false); + + // 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 }); + const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; + + const origin = switching ? initialCoreUrl : sandboxSetupOrigin(coreUrl); + // The review asks for the Core address only when the console's own cannot serve. + const askOrigin = !switching && !sandboxSetupOrigin(initialCoreUrl); + const needsRuntime = provider === "docker" || provider === "microsandbox"; + const runtimeReady = !needsRuntime || isRuntimeRelease(release); + // Core keeps no key across a change: E2B always needs one. + const e2bReady = provider !== "e2b" || (apiKey.trim().length > 0 && validTemplate(template.trim())); + const sizeReady = provider !== null && validSandboxResources(provider, resources); + const ready = provider !== null && origin !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; + + const order: Step[] = where === "direct" ? ["where", "e2b", "size", "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 and Runtime; another starts from its standard size and this console's Runtime. + function choose(next: SandboxProvider) { + if (next !== provider) { + const kept = current ? savedSpecification(next, current.provider, current.specification) : null; + setResources(kept?.resources ?? presets(next).standard); + setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); + setRuntime({}); + } + setProvider(next); + } + + async function save(event?: FormEvent) { + event?.preventDefault(); + if (!ready || !provider) return; + setBusy(true); + try { + await onSubmit({ + provider, + core_url: origin!, + resources, + ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), + ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : {}), + }); + } finally { + setApiKey(""); + setBusy(false); + } + } + + const sizeLabel = (value: SandboxResources) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); + const diskLabel = (value: SandboxResources) => t("Root disk {{root}} · data disk {{data}}", { root: formatBytes((value.root_disk_mib ?? 0) * MIB), data: formatBytes((value.environment_disk_mib ?? 0) * MIB) }); + + let page: ReactNode; + if (step === "where") { + page = ( + +
+ { setWhere("direct"); choose("e2b"); setStep("e2b"); }} /> + { setWhere("nodes"); setApiKey(""); if (provider === "e2b") setProvider(null); setStep("backend"); }} /> +
+
+ ); + } else if (step === "backend") { + page = ( + +
+ { choose("docker"); setStep("size"); }} /> + { choose("microsandbox"); setStep("size"); }} /> +
+