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 ? setChanging(true)}>{t("Change provider or resources")} : <>
-
+
setChanging(false)}>{t("Cancel")}
{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 ;
-}
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"); }} />
+
+
+
+ );
+ } else if (step === "e2b") {
+ page = (
+
+
+
+ setApiKey(event.target.value)} />
+
+
+ setTemplate(event.target.value)} placeholder="parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b" autoComplete="off" spellCheck={false} aria-invalid={Boolean(template && !validTemplate(template.trim()))} />
+
+
+ setStep("size")} nextDisabled={!e2bReady} t={t} />
+
+ );
+ } else if (step === "size") {
+ const options = presets(provider ?? "docker");
+ // A saved size outside the presets stays on offer as the current one.
+ const kept = saved && provider && presetOf(provider, saved.resources) === null ? saved.resources : null;
+ const disks = (value: SandboxResources) => (provider === "microsandbox" ? diskLabel(value) : undefined);
+ page = (
+
+
+ {kept ? { setSize("current"); setResources(kept); setStep("review"); }} /> : null}
+ {(Object.keys(options) as Preset[]).map((key) => (
+ { setSize(key); setResources(options[key]); setStep("review"); }}
+ />
+ ))}
+
+ { setSize("custom"); setStep("advanced"); }}>
+ {t("Custom size in advanced settings")}
+
+
+
+ );
+ } else if (step === "review") {
+ page = (
+
+
+
{t("Sandboxes run on")} {where === "direct" ? t("E2B cloud") : `${t("Own machines")} · ${provider === "docker" ? "Docker" : "microsandbox"}`}
+
{t("Each sandbox")} {sizeLabel(resources)}{provider === "microsandbox" ? {diskLabel(resources)} : null}
+ {provider === "e2b" ?
{t("Template build")} {template || "—"} : null}
+ {needsRuntime ? (
+
+
{t("Runtime")}{t("The Runtime release every node runs: the saved one while the backend stays the same, otherwise the one this console distributes.")}
+ {runtimeReady ? {release.source_commit!.slice(0, 12)} : {t("Runtime release needed")}{t("This console serves no Runtime manifest. Enter the release under advanced settings.")} }
+
+ ) : null}
+ {askOrigin ? null : (
+
+
{t("Core address")}{t("The address nodes and sandboxes use to reach Core. It must be HTTPS and reachable from them; the console's own address may differ.")}
+ {origin ? {origin} : {t("Core address needed")} }
+
+ )}
+
+ {askOrigin ? (
+
+ setCoreUrl(event.target.value)} placeholder="https://core.example" />
+
+ ) : null}
+ setStep("advanced")}>
+ {t("Advanced settings")}
+
+
+
{t("Back")}
+
void save()}>
+ {busy ? t("Saving…") : t(switching ? "Save and stay in maintenance" : "Save configuration")}
+
+
+
+ );
+ } else {
+ page = (
+
+
+
+ );
+ }
+
+ return (
+
+ {step !== "advanced" ? (
+
+ {order.map((entry, position) => )}
+
+ ) : null}
+
+
+ {page}
+
+
+
+ );
+}
+
+function Question({ title, help, children }: { title: string; help?: string; children: ReactNode }) {
+ return (
+
+
{title}{help ? {help} : null}
+ {children}
+
+ );
+}
+
+/** A large option that selects and moves on in one click. */
+function Choice({ icon: Icon, title, value, detail, selected, onClick }: { icon?: LucideIcon; title: string; value?: string; detail?: string; selected: boolean; onClick: () => void }) {
+ return (
+
+ {Icon ? : null}
+ {title}
+ {value ? {value} : null}
+ {detail ? {detail} : null}
+
+ );
+}
+
+function Field({ id, label, help, error, children }: { id: string; label: string; help?: string; error?: string | null; children: ReactNode }) {
+ return (
+
+ {label} {help ? {help} : null}
+ {children}
+ {error ? {error} : null}
+
+ );
+}
+
+function NumberField({ id, label, value, onChange }: { id: string; label: string; value: number; onChange: (value: number) => void }) {
+ return (
+
+ onChange(Number.parseInt(event.target.value, 10))} />
+
+ );
+}
+
+function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?: () => void; nextDisabled?: boolean; t: (key: MessageKey) => string }) {
+ return (
+
+
{t("Back")}
+ {onNext ?
{t("Next")} : null}
+
+ );
+}
diff --git a/apps/web/src/features/sandbox/runtime-release.ts b/apps/web/src/features/sandbox/runtime-release.ts
new file mode 100644
index 000000000..6e56710d9
--- /dev/null
+++ b/apps/web/src/features/sandbox/runtime-release.ts
@@ -0,0 +1,20 @@
+import type { SandboxRuntimeRelease } from "@agents-core-web/agents-client";
+
+const patterns: Record = {
+ source_commit: /^[0-9a-f]{40}$/,
+ image_id: /^sha256:[0-9a-f]{64}$/,
+ image_manifest_digest: /^sha256:[0-9a-f]{64}$/,
+ microsandbox_ref: /^parsar-core-runtime@sha256:[0-9a-f]{64}$/,
+ runtime_sha256: /^[0-9a-f]{64}$/,
+ firmware_sha256: /^[0-9a-f]{64}$/,
+};
+
+export const RUNTIME_RELEASE_FIELDS = Object.keys(patterns) as (keyof SandboxRuntimeRelease)[];
+
+export function isRuntimeReleaseField(field: keyof SandboxRuntimeRelease, value: string): boolean {
+ return patterns[field].test(value);
+}
+
+export function isRuntimeRelease(value: Partial): value is SandboxRuntimeRelease {
+ return RUNTIME_RELEASE_FIELDS.every((field) => typeof value[field] === "string" && patterns[field].test(value[field]!));
+}
diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css
new file mode 100644
index 000000000..411d1c65a
--- /dev/null
+++ b/apps/web/src/features/sandbox/sandbox-wizard.css
@@ -0,0 +1,261 @@
+/*
+ * Sandbox setup as pages: one question per page, large options that advance on
+ * a click, a review, and the complete form under advanced settings. Rules and
+ * explanations sit behind help tips; nothing is small print.
+ */
+
+.sandbox-wizard {
+ display: grid;
+ width: min(100%, 640px);
+ gap: 28px;
+ margin: 12px auto 40px;
+}
+
+.wizard-steps {
+ display: flex;
+ justify-content: center;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.wizard-steps li {
+ width: 22px;
+ height: 4px;
+ background: var(--hover-2);
+ border-radius: 999px;
+ transition: background-color 240ms var(--ease-out-strong);
+}
+
+.wizard-steps li.done {
+ background: color-mix(in srgb, var(--accent) 40%, var(--hover-2));
+}
+
+.wizard-steps li.current {
+ background: var(--accent);
+}
+
+.wizard-page {
+ display: grid;
+ gap: 24px;
+}
+
+.wizard-title {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin: 0;
+ color: var(--ink);
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1.3;
+ letter-spacing: -0.02em;
+ text-align: center;
+}
+
+.wizard-choices {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.wizard-choices-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.wizard-choices-4 {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.wizard-choice {
+ display: grid;
+ justify-items: center;
+ align-content: center;
+ gap: 8px;
+ min-height: 132px;
+ padding: 22px 16px;
+ color: var(--ink);
+ font: inherit;
+ text-align: center;
+ background: var(--surface);
+ border: 0;
+ border-radius: var(--radius-card);
+ box-shadow: var(--shadow-card);
+ cursor: pointer;
+ transition: box-shadow 160ms var(--ease-out-strong), transform 160ms var(--ease-out-strong);
+}
+
+.wizard-choice:hover {
+ box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 45%, var(--frame-line)), 0 8px 24px -16px oklch(0 0 0 / 30%);
+ transform: translateY(-1px);
+}
+
+.wizard-choice:active {
+ transform: translateY(0) scale(0.99);
+}
+
+.wizard-choice.selected {
+ box-shadow: 0 0 0 1.5px var(--accent), 0 8px 24px -16px color-mix(in oklch, var(--accent) 60%, transparent);
+}
+
+.wizard-choice:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.wizard-choice-icon {
+ display: grid;
+ width: 44px;
+ height: 44px;
+ place-items: center;
+ color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 9%, var(--surface));
+ border-radius: 12px;
+}
+
+.wizard-choice-title {
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.wizard-choice-value {
+ color: var(--ink-2);
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+}
+
+.wizard-link {
+ display: inline-flex;
+ justify-self: center;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ color: var(--ink-2);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ background: none;
+ border: 0;
+ border-radius: var(--radius-chip);
+ cursor: pointer;
+}
+
+.wizard-link:hover {
+ color: var(--ink);
+ background: var(--hover);
+}
+
+.wizard-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.wizard-fields {
+ display: grid;
+ gap: 18px;
+}
+
+.wizard-field input {
+ width: 100%;
+}
+
+.wizard-group {
+ display: grid;
+ gap: 14px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.wizard-group legend {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 10px;
+ padding: 0;
+ color: var(--ink);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.wizard-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px 16px;
+}
+
+.wizard-review {
+ display: grid;
+ gap: 0;
+ margin: 0;
+ padding: 4px 18px;
+ background: var(--surface);
+ border-radius: var(--radius-card);
+ box-shadow: var(--shadow-card);
+}
+
+.wizard-review > div {
+ display: grid;
+ grid-template-columns: 150px minmax(0, 1fr);
+ gap: 16px;
+ align-items: baseline;
+ padding: 13px 0;
+}
+
+.wizard-review > div + div {
+ border-top: 1px solid var(--grid-line);
+}
+
+.wizard-review dt {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--ink-3);
+ font-size: 13px;
+}
+
+.wizard-review dd {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+ margin: 0;
+ color: var(--ink);
+ font-size: 14px;
+ overflow-wrap: anywhere;
+}
+
+.wizard-review code {
+ font-size: 13px;
+}
+
+.wizard-review-sub {
+ color: var(--ink-2);
+ font-size: 13px;
+}
+
+.wizard-missing {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--orange);
+ font-weight: 500;
+}
+
+@media (max-width: 640px) {
+ .wizard-choices-3 {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .wizard-choices-4 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .wizard-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx
index df73fe89e..7c4425c35 100644
--- a/apps/web/src/features/system/SystemPage.tsx
+++ b/apps/web/src/features/system/SystemPage.tsx
@@ -8,10 +8,12 @@ import { ErrorState } from "../../components/ErrorState";
import { CopyableId } from "../../components/list-ui";
import { admin } from "../../lib/projects";
import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries";
+import { formatBytes } from "../../lib/format";
import "./system.css";
import { startupConfigurationQuery } from "./system-queries";
const harnessNames: Record = { claude_sdk: "Claude SDK", codex: "Codex", mcode: "MiniMax Code" };
+const MIB = 2 ** 20;
const providerNames: Record = { docker: "Docker", microsandbox: "microsandbox", e2b: "E2B" };
export interface HarnessRow {
@@ -75,6 +77,7 @@ export function SystemPage() {
const provider = deployment.data?.provider || managed?.provider || "";
const maintenance = deployment.data?.maintenance ?? managed?.maintenance ?? null;
const mode = deployment.data?.mode || "";
+ const spec = deployment.data?.specification ?? null;
return (
@@ -137,6 +140,12 @@ export function SystemPage() {
{deployment.data?.installation_id ? : missing}
{deployment.data?.core_url ? {deployment.data.core_url} : missing}
{deployment.data?.e2b ? {deployment.data.e2b.template || "—"} : null}
+ {/* The per-sandbox specification saved with the deployment: every sandbox gets these limits. */}
+ {spec ? t("sandbox.cores", { count: spec.resources.cpus }) : missing}
+ {spec ? formatBytes(spec.resources.memory_mib * MIB) : missing}
+ {spec?.resources.root_disk_mib ? {formatBytes(spec.resources.root_disk_mib * MIB)} : null}
+ {spec?.resources.environment_disk_mib ? {formatBytes(spec.resources.environment_disk_mib * MIB)} : null}
+ {spec?.runtime ? {spec.runtime.source_commit.slice(0, 12)} : null}
diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts
index 4d3c84101..90bac1b51 100644
--- a/apps/web/src/i18n/locales/en/system.ts
+++ b/apps/web/src/i18n/locales/en/system.ts
@@ -30,6 +30,14 @@ export const system = {
coreOrigin: "Core origin",
coreOriginHelp: "The address nodes and sandbox guests use to reach Core.",
e2bTemplate: "E2B template",
+ cpus: "CPUs per sandbox",
+ cores: "{{count}} cores", cores_one: "{{count}} core", cores_other: "{{count}} cores",
+ memory: "Memory per sandbox",
+ rootDisk: "Root disk per sandbox",
+ dataDisk: "Data disk per sandbox",
+ dataDiskHelp: "microsandbox gives each sandbox a separate disk at /environment.",
+ runtime: "Runtime",
+ runtimeHelp: "The Runtime release every node installs; nodes refuse any other.",
},
gateway: {
title: "Daemon gateway",
diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts
index f55408de6..ed4aed511 100644
--- a/apps/web/src/i18n/locales/zh-CN/system.ts
+++ b/apps/web/src/i18n/locales/zh-CN/system.ts
@@ -32,6 +32,14 @@ export const system: TranslationShape = {
coreOrigin: "Core 地址",
coreOriginHelp: "节点和沙箱访问 Core 使用的地址。",
e2bTemplate: "E2B 模板",
+ cpus: "每个沙箱的 CPU",
+ cores: "{{count}} 核", cores_one: "{{count}} 核", cores_other: "{{count}} 核",
+ memory: "每个沙箱的内存",
+ rootDisk: "每个沙箱的根盘",
+ dataDisk: "每个沙箱的数据盘",
+ dataDiskHelp: "microsandbox 为每个沙箱挂一块独立的数据盘,位于 /environment。",
+ runtime: "Runtime",
+ runtimeHelp: "每个节点安装的 Runtime 版本;节点只接受这一个。",
},
gateway: {
title: "Daemon 网关",
diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts
index da3496d5c..ad361c0f1 100644
--- a/apps/web/src/lib/locale-strings.ts
+++ b/apps/web/src/lib/locale-strings.ts
@@ -1,17 +1,7 @@
import { consoleAuthChinese } from "./console-auth-strings";
export const chinese = {
...consoleAuthChinese,
- "The matching Runtime release could not be loaded. Check the console distribution and try again.": "无法加载匹配的 Runtime 版本,请检查控制台安装包后重试。",
- "Loading Runtime specification…": "正在加载 Runtime 配置…",
- "Advanced sandbox resources": "高级沙箱资源配置",
- "These limits apply to every sandbox in this deployment.": "这些资源限制适用于此部署中的所有沙箱。",
- "CPU and memory must match the selected E2B template build. Core verifies them before saving.": "CPU 和内存必须与所选 E2B 模板构建一致,Core 会在保存前验证。",
- "CPU cores per sandbox": "每个沙箱的 CPU 核数",
- "Memory per sandbox (MiB)": "每个沙箱的内存(MiB)",
- "Root disk per sandbox (MiB)": "每个沙箱的根磁盘(MiB)",
- "Environment disk per sandbox (MiB)": "每个沙箱的环境磁盘(MiB)",
"Change provider or resources": "修改运行后端或资源",
-
"This console address cannot be used by sandbox guests. Enter the HTTPS Core address that your nodes and guests can reach.": "沙箱无法使用此控制台地址。请输入节点和沙箱都能访问的 HTTPS Core 地址。",
"Use an HTTPS Core origin reachable from every node and sandbox guest, without a path or credentials. The console URL may be different.": "请输入所有节点和沙箱都能访问的 HTTPS Core 源地址,不含路径或凭据。此地址可能与控制台地址不同。",
"Enter a non-loopback HTTPS origin, such as https://core.example.": "请输入非回环的 HTTPS 源地址,例如 https://core.example。",
@@ -168,7 +158,6 @@ export const chinese = {
"Check cleanup": "检查清理状态",
"Core reports no remaining execution resources. You can choose the next provider.": "Core 确认已无剩余执行资源,可以选择下一个运行后端。",
"Provider changes are blocked until Core confirms complete cleanup.": "Core 确认清理完成前无法更改运行后端。",
- "Change provider": "更改运行后端",
"Saving retires old node identities and enrollment credentials. It does not migrate Sessions or resume placement automatically.": "保存后旧节点身份和注册凭据将失效。不会迁移会话或自动恢复分配。",
"Cancel": "取消",
"Resume hosted placement": "恢复托管分配",
@@ -190,5 +179,52 @@ export const chinese = {
"{{name}} will be removed from this deployment.": "{{name}} 将从此部署中移除。",
"Open {{name}}": "打开 {{name}}",
"Remove {{name}}": "移除 {{name}}",
+ "1–255 CPUs, 512–1048576 MiB of memory. microsandbox disks are at least 1024 MiB.": "CPU 1–255 核,内存 512–1048576 MiB;microsandbox 的磁盘至少 1024 MiB。",
+ "Advanced settings": "高级设置",
+ "CPUs": "CPU(核)",
+ "Check this value": "请检查这个值",
+ "Connect E2B": "连接 E2B",
+ "Core address": "Core 地址",
+ "Core address needed": "需要 Core 地址",
+ "Custom size in advanced settings": "在高级设置里自定义规格",
+ "Data disk at /environment (MiB)": "数据盘 /environment(MiB)",
+ "Docker runs each sandbox as a container: quick to set up on any Docker host, with CPU and memory limits but no disk quota. microsandbox runs each sandbox as a lightweight virtual machine: stronger isolation and its own root and data disks with size limits, but the host needs KVM.": "Docker:每个沙箱是一个容器,任何装了 Docker 的主机都能很快用上,能限制 CPU 和内存,但没有磁盘配额。microsandbox:每个沙箱是一个轻量虚拟机,隔离更强,有独立的根盘和数据盘并能限制大小,但主机需要支持 KVM。",
+ "E2B runs sandboxes in its cloud: no machines to manage, billed by E2B. Own machines run them on hosts you add, with Docker or microsandbox.": "E2B 云端:沙箱跑在 E2B 的云上,不用管理机器,由 E2B 计费。自有机器:沙箱跑在你添加的主机上,用 Docker 或 microsandbox。",
+ "Each sandbox": "每个沙箱",
+ "Every sandbox of this deployment gets these limits. How many run at once on a machine is set per node.": "这个部署里的每个沙箱都用这组限额;一台机器上同时运行几个沙箱,在各个节点上单独设置。",
+ "Filled in from this console's distribution when it serves one. Otherwise copy these from the distribution manifest that matches your nodes; image configuration IDs and manifest digests are different values.": "控制台提供发行清单时会自动填好;否则从与节点配套的发行清单里复制。镜像配置 ID 和清单摘要是两个不同的值。",
+ "Firmware SHA-256": "固件 SHA-256",
+ "How big is each sandbox?": "每个沙箱多大?",
+ "Image ID": "镜像 ID",
+ "Image manifest digest": "镜像清单摘要",
+ "Large": "大",
+ "Memory (MiB)": "内存(MiB)",
+ "Next": "下一步",
+ "Review and save": "确认并保存",
+ "Root disk (MiB)": "根盘(MiB)",
+ "Root disk {{root}} · data disk {{data}}": "根盘 {{root}} · 数据盘 {{data}}",
+ "Runtime": "Runtime",
+ "Runtime SHA-256": "Runtime SHA-256",
+ "Runtime release": "Runtime 版本",
+ "Runtime release needed": "需要 Runtime 版本",
+ "Sandboxes run on": "运行在",
+ "Saving…": "保存中…",
+ "Small": "小",
+ "Source commit": "源码提交",
+ "Standard": "标准",
+ "Template build": "模板构建",
+ "The Runtime release every node runs: the saved one while the backend stays the same, otherwise the one this console distributes.": "所有节点运行的 Runtime 版本:后端不变时沿用已保存的版本,否则使用此控制台分发的版本。",
+ "Current": "当前规格",
+ "The address nodes and sandboxes use to reach Core. It must be HTTPS and reachable from them; the console's own address may differ.": "节点和沙箱访问 Core 用的地址,必须是它们能访问的 HTTPS 地址;可以和控制台自己的地址不同。",
+ "The exact ready build, as template-id:build-uuid. A template alias alone is not enough. Its CPU and memory must match the size you choose next.": "精确的已就绪构建,格式为 template-id:build-uuid,只填模板别名不行。它的 CPU 和内存必须与下一步选的规格一致。",
+ "The key is write-only: Core encrypts it and never shows it again.": "key 只写入:Core 加密保存,之后不再显示。",
+ "This console serves no Runtime manifest. Enter the release under advanced settings.": "这个控制台没有提供 Runtime 清单,请在高级设置里填写。",
+ "Where should sandboxes run?": "沙箱在哪里运行?",
+ "Which sandbox backend?": "用哪种沙箱运行后端?",
+ "microsandbox reference": "microsandbox 引用",
+ "{{cpus}} CPU · {{memory}}": "{{cpus}} 核 · {{memory}}",
+ "Save configuration": "保存配置",
+ "Save and stay in maintenance": "保存并保持维护模式",
+ "Change the sandbox configuration": "修改沙箱配置",
} as const;
export type MessageKey = keyof typeof chinese;
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 741704b43..04d40b805 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -52,6 +52,7 @@ export default defineConfig(({ command, mode }) => {
// The console service's own routes (sign-in, capability flags) and the
// management surfaces it forwards, as in production.
"/console": { target, changeOrigin: true },
+ "/node-install": { target, changeOrigin: true },
"/core/v1/sandbox": { target, changeOrigin: true },
"/core/v1/admin": { target, changeOrigin: true },
"/v1": {
diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md
index c90d73fc4..0c6934b8d 100644
--- a/docs/web/protocol-coverage.md
+++ b/docs/web/protocol-coverage.md
@@ -101,7 +101,7 @@ log (`GET /audit-log`) and Runtime history capabilities
| Operation | Route | Console use |
| --- | --- | --- |
-| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, Core origin, maintenance state and installation ID; initialize the deployment; change its settings with the expected generation |
+| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, Core origin, maintenance state, installation ID and specification; initialize the deployment with `resources`, the Docker or microsandbox `runtime` release, or the E2B account; change its settings with the expected generation |
| Maintenance | `PATCH /core/v1/sandbox/deployment/maintenance` | Enter or leave maintenance to change the provider |
| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics |
| Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics |
@@ -109,7 +109,10 @@ log (`GET /audit-log`) and Runtime history capabilities
| Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion |
These pages appear only when `/console/config` reports `sandbox_admin: true`. An E2B
-deployment has no nodes; its API key is write-only.
+deployment has no nodes; its API key is write-only. The Runtime release sent for
+Docker and microsandbox comes from the console's own `GET /node-install/manifest.json`
+(the distribution manifest the node installer uses); without it the administrator
+enters the release under advanced settings.
## Writes