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={<>}> @@ -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")}
{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)} />)}
) : 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.")} - + {t("Enter the key again to save.")} + {editing ? : null}

) : null} @@ -402,7 +354,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ event.preventDefault(); setStep("review"); }}> {sized ?
- {t("Each sandbox")}{t("{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.", resourceBounds)} + {t("Each sandbox")}{t("{{cpus}} CPUs, {{memory}} MiB of memory. Disks are at least {{disk}} MiB.", resourceBounds)}
{ setSize("custom"); setResources({ ...resources, cpus }); setFieldRejection(null); }} /> { setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} /> @@ -412,11 +364,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab : null}
: null} - {provider === "e2b" ? ( - - { setTemplate(event.target.value); setFieldRejection(null); }} autoComplete="off" spellCheck={false} /> - - ) : null}
@@ -447,19 +394,17 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {/* Portaled: the sliding page's transform would otherwise contain the fixed backdrop. */} {createPortal( setConfirmingDocker(false)} + open={confirming !== null} + title={t("Use {{provider}}?", { provider: confirming ? sandboxProviderSetup(confirming).label : "" })} + initialFocus={goBack} + onClose={() => setConfirming(null)} footer={<> - - + + } > -
    -
  • {t("Weaker isolation")}{t("Containers share the host's kernel, so a container escape reaches the host. microsandbox runs each sandbox in its own microVM.")}
  • -
  • {t("Root-equivalent access")}{t("The node's service account joins the docker group, which is equivalent to root on that host.")}
  • -
  • {t("Limited use")}{t("Docker suits only trusted workloads, or hosts without KVM.")}
  • +
      + {confirming ? sandboxProviderSetup(confirming).cautions.map((caution) =>
    • {text(caution)}
    • ) : null}
    , document.body, @@ -515,3 +460,65 @@ function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?:
); } + +type Level = "loading" | "error" | SandboxConfigurationOption[]; + +/** + * A field whose value the Provider offers for the entered credential: one select + * per level, each loaded after the option chosen above it, until an option is + * 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 }: { + id: string; provider: SandboxProvider; field: SandboxSetupField; label: string; help?: string; request: Omit | null; + value: string; editing: boolean; error: string | null; onChange: (value: string) => 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; + useEffect(() => { + if (!request || !open) return; + const depth = picks.length; + const controller = new AbortController(); + // Typing the credential settles for half a second before the first read. + const timer = window.setTimeout(() => { + setLevels((previous) => [...previous.slice(0, depth), "loading"]); + void sandboxAdmin.discoverConfiguration(provider, { ...request, query: { field: field.name, path: picks.map((pick) => pick.value) } }, { signal: controller.signal }) + .then((options) => { if (!controller.signal.aborted) setLevels((previous) => [...previous.slice(0, depth), options]); }) + .catch(() => { if (!controller.signal.aborted) setLevels((previous) => [...previous.slice(0, depth), "error"]); }); + }, depth === 0 ? 500 : 0); + return () => { window.clearTimeout(timer); controller.abort(); }; + // The page remounts this field when the request changes. + }, [picks, retry]); + + 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)); + onChange(option?.complete ? option.value : ""); + }; + const selects = Array.from({ length: Math.max(1, Math.min(picks.length + 1, levels.length)) }, (_, depth) => { + const level = levels[depth]; + const options = Array.isArray(level) ? level : []; + const pick = picks[depth]; + return ( + + ); + }); + return ( + + {selects} + {last === "loading" ?

{t("Loading options…")}

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

{t("Could not load options. Check the credential and provider connection.")}

: null} + {Array.isArray(last) && last.length === 0 ?

{t("No options are available for this credential.")}

: null} +
+ ); +} diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 5e6cfc596..8809d1ea9 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,4 +1,4 @@ -import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; +import { deploymentContract, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; /** The size the Provider declares for setup to propose; null when its configuration selects the size. */ export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null { @@ -27,20 +27,3 @@ export function validSandboxResources(provider: SandboxProvider, resources: Sand export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { return provider === savedProvider && specification ? structuredClone(specification) : null; } - -/** CPU and memory of the E2B template build as Core read them when the selection was saved; null while either is unknown. */ -export function templateBuildSize(deployment: SandboxDeployment): { cpus: number; memory_mib: number } | null { - const build = deployment.metadata?.template_build?.resources; - return build && build.cpus !== null && build.memory_mib !== null ? { cpus: build.cpus, memory_mib: build.memory_mib } : null; -} - -/** Core admits only a ready build; a selection saved before Core recorded the build has no status. */ -export function templateBuildStatus(build: SandboxE2BTemplateBuild | undefined): "ready" | "notReady" | "unknown" { - if (!build?.status) return "unknown"; - return build.status === "ready" ? "ready" : "notReady"; -} - -/** Each sandbox's limits: the saved specification, else the E2B template build that an E2B selection adopts. */ -export function sandboxSize(deployment: SandboxDeployment): SandboxResources | null { - return deployment.specification?.resources ?? templateBuildSize(deployment); -} diff --git a/apps/web/src/features/sandbox/sandbox-update.test.ts b/apps/web/src/features/sandbox/sandbox-update.test.ts index 9c1a10e9d..006a828bd 100644 --- a/apps/web/src/features/sandbox/sandbox-update.test.ts +++ b/apps/web/src/features/sandbox/sandbox-update.test.ts @@ -1,20 +1,16 @@ import { describe, expect, it } from "vitest"; -import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; +import { credentialReady, submittedCredential } from "./sandbox-update"; -describe("E2B update selection", () => { - it("omits a retained key while keeping the complete template selection", () => { - expect(e2bUpdateSelection(" template:build ", " ")).toEqual({ configuration: { template: "template:build" } }); - expect(e2bKeyReady(true, false, "")).toBe(true); - expect(e2bKeyReady(false, false, "")).toBe(false); - }); - - it("always sends an explicit replacement, including repeated identical values", () => { - for (let attempt = 0; attempt < 2; attempt++) expect(e2bUpdateSelection("template:build", " test-key ")).toEqual({ configuration: { template: "template:build" }, credential: { api_key: "test-key" } }); +describe("sandbox credential update", () => { + it("omits a retained credential and always sends an explicit replacement", () => { + expect(submittedCredential([["api_key", ""]])).toEqual({}); + for (let attempt = 0; attempt < 2; attempt++) expect(submittedCredential([["api_key", "test-key"]])).toEqual({ credential: { api_key: "test-key" } }); }); it("does not silently turn a cleared replacement secret into retained-key resubmission", () => { - expect(e2bKeyReady(true, true, "")).toBe(false); - expect(e2bKeyReady(true, true, "replacement")).toBe(true); - expect(e2bKeyReady(true, false, "")).toBe(true); + expect(credentialReady(true, false, false)).toBe(true); + expect(credentialReady(false, false, false)).toBe(false); + expect(credentialReady(true, true, false)).toBe(false); + expect(credentialReady(true, true, true)).toBe(true); }); }); diff --git a/apps/web/src/features/sandbox/sandbox-update.ts b/apps/web/src/features/sandbox/sandbox-update.ts index 0fa5fa75d..c48b0bb92 100644 --- a/apps/web/src/features/sandbox/sandbox-update.ts +++ b/apps/web/src/features/sandbox/sandbox-update.ts @@ -1,12 +1,10 @@ -import type { UpdateSandboxDeployment } from "@oac/agents-client"; - -/** Omission preserves the credential; every explicit key follows Core's replacement path. */ -export function e2bUpdateSelection(template: string, apiKey: string): Pick { - const key = apiKey.trim(); - return { configuration: { template: template.trim() }, ...(key ? { credential: { api_key: key } } : {}) }; +/** Omission preserves the credential; every explicit value follows Core's replacement path. */ +export function submittedCredential(entries: readonly (readonly [string, string])[]): { credential?: Record } { + const entered = entries.filter(([, value]) => value); + return entered.length > 0 ? { credential: Object.fromEntries(entered) } : {}; } /** Clearing a submitted secret must not silently convert replacement into retention. */ -export function e2bKeyReady(editing: boolean, replacementRequested: boolean, apiKey: string): boolean { - return apiKey.trim().length > 0 || (editing && !replacementRequested); +export function credentialReady(editing: boolean, replacementRequested: boolean, entered: boolean): boolean { + return entered || (editing && !replacementRequested); } diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css index 4810db71b..4e780dc9e 100644 --- a/apps/web/src/features/sandbox/sandbox-wizard.css +++ b/apps/web/src/features/sandbox/sandbox-wizard.css @@ -322,7 +322,7 @@ font-size: 13px; } -/* After a save attempt the E2B key is gone; this says so beside the way back to it. */ +/* After a save attempt the key is gone; this says so beside the way back to it. */ .sandbox-wizard .wizard-key-again { display: flex; flex-wrap: wrap; @@ -351,8 +351,8 @@ margin: 0; } -/* What Docker gives up against microsandbox, in its confirmation. */ -.wizard-docker-risks { +/* The risks a Provider declares, in its confirmation. */ +.wizard-cautions { display: grid; gap: 12px; margin: 0; @@ -362,13 +362,6 @@ list-style: none; } -.wizard-docker-risks li { - display: grid; - gap: 2px; +.wizard-cautions li { color: var(--ink-2); } - -.wizard-docker-risks strong { - color: var(--ink); - font-weight: 600; -} diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index 6e38ff03c..2dd7c8fe1 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -33,11 +33,11 @@ export const coreErrors = { "sandbox_in_use": "Hosted resources remain. Wait for confirmed cleanup before changing the configuration.", "runtime_node_in_use": "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.", "runtime_node_unavailable": "The selected sandbox node is unavailable or has no capacity.", - "sandbox_credential_ownership": "This E2B key cannot manage the retained deployment. Reset before changing teams.", - "sandbox_credential_invalid": "The E2B API key was rejected. The saved configuration is unchanged.", - "sandbox_configuration_invalid": "Select a ready immutable E2B template build with matching resources.", - "sandbox_verification_unconfirmed": "E2B verification could not be confirmed. Refresh before submitting again.", - "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin.", + "sandbox_credential_ownership": "This credential cannot manage the retained deployment. Reset before changing accounts.", + "sandbox_credential_invalid": "The sandbox provider credential was rejected. The saved configuration is unchanged.", + "sandbox_configuration_invalid": "Select a ready immutable provider configuration with matching resources.", + "sandbox_verification_unconfirmed": "Sandbox provider verification could not be confirmed. Refresh before submitting again.", + "sandbox_configuration_error": "This sandbox provider needs a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin.", "sandbox_deployment_conflict": "The sandbox deployment cannot change in its current state. Refresh and check its reset and resource state.", "sandbox_specification_mismatch": "The saved sandbox specification does not match the deployment. Refresh to check the configuration.", "sandbox_operation_unsupported": "The selected sandbox provider does not support this operation.", diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 059054738..e36b7d79a 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -180,7 +180,7 @@ export const metrics = { configurationGeneration: "Sandbox configuration generation", title: "Sandbox metrics", description: "The hosted Runtimes of every project over time: their state, CPU and memory. Node capacity is on Nodes.", - cloudDescription: "Sandboxes Core holds in E2B’s cloud, and hosted Runtime observations across projects.", + cloudDescription: "Sandboxes Core holds in the provider’s cloud, and hosted Runtime observations across projects.", runtimeDialog: { openLabel: "Show sandbox of {{name}}", facts: "Sandbox", @@ -188,18 +188,14 @@ export const metrics = { noSession: "The Session of this sandbox could not be read, so its history cannot be shown.", }, cloud: { - title: "E2B cloud", - counts: "The running count comes from Core’s E2B allocations. The hosted Runtime total below counts Core’s hosted observation records across projects, including every reported lifecycle state. These sources have different coverage and refresh separately.", - help: "This deployment runs its sandboxes in E2B's cloud: there are no machines to manage. Counts are Core's own records of what it placed in E2B.", + title: "Provider cloud", + counts: "The running count comes from Core’s allocations in the provider’s cloud. The hosted Runtime total below counts Core’s hosted observation records across projects, including every reported lifecycle state. These sources have different coverage and refresh separately.", + help: "This deployment runs its sandboxes in the provider's cloud: there are no machines to manage. Counts are Core's own records of what it placed there.", manage: "Sandbox backend", running: "Running sandboxes", - runningHelp: "Sandboxes Core currently holds in E2B.", + runningHelp: "Sandboxes Core currently holds in the provider’s cloud.", pending: "Starting or changing", size: "Each sandbox", - disk: "{{disk}} disk", - template: "Template build", - templateHelp: "The fixed E2B build every sandbox starts from, as Core read it when the selection was saved. Each sandbox gets its CPU, memory and disk.", - build: { ready: "Ready", notReady: "Not ready", unknown: "Unknown" }, }, node: "Node", openNode: "Open node {{name}}", diff --git a/apps/web/src/i18n/locales/en/overview.ts b/apps/web/src/i18n/locales/en/overview.ts index 4af9fe201..9986d5154 100644 --- a/apps/web/src/i18n/locales/en/overview.ts +++ b/apps/web/src/i18n/locales/en/overview.ts @@ -25,7 +25,7 @@ export const overview = { localOnly: "Set a public address to connect applications and nodes.", title: "Get sandboxes ready", body: "Save where sandboxes run, then connect a node that is online and ready.", - bodyCloud: "Save the E2B account; its template build must be ready.", + bodyCloud: "Connect the sandbox provider account.", setup: "Set up sandboxes", addNode: "Add node", nodes: "Open Nodes", @@ -68,7 +68,7 @@ export const overview = { slots: "Sandbox capacity", slotsHelp: "Active sandboxes against the active limit of online hosts.", cloudRunning: "Running sandboxes", - cloudRunningHelp: "Sandboxes Core currently holds in E2B's cloud. E2B sets no limit Core can read.", + cloudRunningHelp: "Sandboxes Core currently holds in the provider's cloud. The provider sets no limit Core can read.", attention: "Needs attention", }, tiles: { @@ -116,7 +116,7 @@ export const overview = { fleet: { previousGeneration: "Inventory was read for configuration generation {{observed}}; the current target is {{target}}. These are earlier observations.", title: "Fleet", - cloudHelp: "Core and the sandboxes it holds in E2B’s cloud. Select Core for Core metrics or E2B for Sandbox metrics.", + cloudHelp: "Core and the sandboxes it holds in the provider’s cloud. Select Core for Core metrics or the cloud for Sandbox metrics.", help: "Core and sandbox nodes, with availability and active sandboxes / limit. Select Core for Core metrics or a node for its page on Nodes. When the list is full, offline and degraded nodes appear first.", core: "Core", open: "{{name}}, {{state}}, sandboxes {{slots}}", @@ -132,8 +132,8 @@ export const overview = { setUp: "Set up sandboxes", notSetUp: "Sandboxes are not set up yet.", cloud: { - name: "E2B cloud", - open: "E2B cloud, {{running}} running sandboxes", + name: "Provider cloud", + open: "Provider cloud, {{running}} running sandboxes", running: "{{count}} running", openBackend: "Sandbox backend", }, diff --git a/apps/web/src/i18n/locales/en/sandbox-navigation.ts b/apps/web/src/i18n/locales/en/sandbox-navigation.ts index cf5c38a84..1ba8a7a03 100644 --- a/apps/web/src/i18n/locales/en/sandbox-navigation.ts +++ b/apps/web/src/i18n/locales/en/sandbox-navigation.ts @@ -5,7 +5,7 @@ export const sandboxNavigation = { "back": "Back to Settings", "nodes": "Open Nodes", "unconfigured": "Set up sandbox hosting in Settings before adding nodes.", - "cloud": "E2B runs sandboxes in its cloud. There are no nodes to manage.", + "cloud": "The provider runs sandboxes in its cloud. There are no nodes to manage.", "reset": "Node enrollment is paused during reset. View progress in Settings.", "details": "Configuration details" } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index 570ab2c25..957ee9144 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -32,11 +32,11 @@ export const coreErrors = { "sandbox_in_use": "仍有托管资源。请等待 Core 确认清理完成后再修改配置。", "runtime_node_in_use": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", "runtime_node_unavailable": "所选沙箱节点不可用或容量不足。", - "sandbox_credential_ownership": "此 E2B 密钥无法管理当前保留的部署。更换团队前请先重置。", - "sandbox_credential_invalid": "E2B API 密钥被拒绝。已保存的配置未改变。", - "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变 E2B 模板构建。", - "sandbox_verification_unconfirmed": "无法确认 E2B 验证结果。请刷新后再提交。", - "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", + "sandbox_credential_ownership": "此凭据无法管理当前保留的部署。更换账号前请先重置。", + "sandbox_credential_invalid": "沙箱提供商凭据被拒绝。已保存的配置未改变。", + "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变提供商配置。", + "sandbox_verification_unconfirmed": "无法确认沙箱提供商的验证结果。请刷新后再提交。", + "sandbox_configuration_error": "此沙箱提供商需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", "sandbox_deployment_conflict": "沙箱部署在当前状态下无法更改。请刷新并检查重置和资源状态。", "sandbox_specification_mismatch": "已保存的沙箱规格与部署不一致。请刷新检查配置。", "sandbox_operation_unsupported": "所选沙箱提供商不支持此操作。", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index c9fc0fe28..b6bf73549 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -180,7 +180,7 @@ export const metrics = { configurationGeneration: "沙箱配置代次", title: "沙箱监控", description: "所有项目的托管 Runtime 随时间的状态、CPU 和内存。节点容量见节点页。", - cloudDescription: "Core 在 E2B 云端持有的沙箱,以及各项目的托管 Runtime 观测。", + cloudDescription: "Core 在提供商云端持有的沙箱,以及各项目的托管 Runtime 观测。", runtimeDialog: { openLabel: "查看 {{name}} 的沙箱", facts: "沙箱", @@ -188,18 +188,14 @@ export const metrics = { noSession: "无法读取这个沙箱所属的 Session,因此无法显示它的历史。", }, cloud: { - title: "E2B 云端", - counts: "运行数量来自 Core 的 E2B 分配记录;下方托管 Runtime 总数统计各项目的托管观测记录,包含所有已报告的生命周期状态。两者覆盖范围不同,并分别刷新。", - help: "这个部署的沙箱在 E2B 的云端运行,没有需要管理的机器。数量是 Core 自己记录的、放在 E2B 上的沙箱。", + title: "提供商云端", + counts: "运行数量来自 Core 在提供商云端的分配记录;下方托管 Runtime 总数统计各项目的托管观测记录,包含所有已报告的生命周期状态。两者覆盖范围不同,并分别刷新。", + help: "这个部署的沙箱在提供商的云端运行,没有需要管理的机器。数量是 Core 自己记录的、放在云端的沙箱。", manage: "沙箱提供商", running: "运行中的沙箱", - runningHelp: "Core 当前在 E2B 上占用的沙箱。", + runningHelp: "Core 当前在提供商云端占用的沙箱。", pending: "启动或切换中", size: "每个沙箱", - disk: "磁盘 {{disk}}", - template: "模板构建", - templateHelp: "每个沙箱启动时使用的固定 E2B 构建,是保存选择时 Core 读到的信息。每个沙箱按它分配 CPU、内存和磁盘。", - build: { ready: "就绪", notReady: "未就绪", unknown: "未知" }, }, node: "节点", openNode: "打开节点 {{name}}", diff --git a/apps/web/src/i18n/locales/zh-CN/overview.ts b/apps/web/src/i18n/locales/zh-CN/overview.ts index c76ef271d..be938ead8 100644 --- a/apps/web/src/i18n/locales/zh-CN/overview.ts +++ b/apps/web/src/i18n/locales/zh-CN/overview.ts @@ -25,7 +25,7 @@ export const overview = { localOnly: "设置公开地址后即可连接外部应用和节点。", title: "准备好沙箱", body: "保存沙箱的运行位置,再接入一台在线且就绪的节点。", - bodyCloud: "保存 E2B 账号,并等它的模板构建就绪。", + bodyCloud: "连接沙箱提供商账号。", setup: "设置沙箱", addNode: "添加节点", nodes: "打开节点", @@ -68,7 +68,7 @@ export const overview = { slots: "沙箱容量", slotsHelp: "活跃沙箱数,相对于在线主机的活跃上限。", cloudRunning: "运行中的沙箱", - cloudRunningHelp: "Core 当前在 E2B 云端占用的沙箱。E2B 没有可供 Core 读取的上限。", + cloudRunningHelp: "Core 当前在提供商云端占用的沙箱。提供商没有可供 Core 读取的上限。", attention: "需要处理", }, tiles: { @@ -116,7 +116,7 @@ export const overview = { fleet: { previousGeneration: "资源清单读取于配置代次 {{observed}},当前目标为 {{target}}。这里保留的是较早的观测。", title: "机群", - cloudHelp: "Core 与它在 E2B 云端持有的沙箱。选择 Core 打开 Core 监控,选择 E2B 打开沙箱监控。", + cloudHelp: "Core 与它在提供商云端持有的沙箱。选择 Core 打开 Core 监控,选择云端打开沙箱监控。", help: "Core 与沙箱节点的可用状态及活跃沙箱 / 上限。选择 Core 打开 Core 监控,选择节点打开它在节点页的详情。列表显示不下时,优先显示离线和异常节点。", core: "Core", open: "{{name}},{{state}},沙箱 {{slots}}", @@ -132,8 +132,8 @@ export const overview = { setUp: "设置沙箱", notSetUp: "还没有设置沙箱。", cloud: { - name: "E2B 云端", - open: "E2B 云端,{{running}} 个运行中的沙箱", + name: "提供商云端", + open: "提供商云端,{{running}} 个运行中的沙箱", running: "{{count}} 个运行中", openBackend: "沙箱提供商", }, diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts b/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts index 8e8ab7544..0cda508ec 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts @@ -5,7 +5,7 @@ export const sandboxNavigation = { "back": "返回设置", "nodes": "打开节点", "unconfigured": "请先在设置中配置沙箱托管,再添加节点。", - "cloud": "E2B 在云端运行沙箱,无需管理节点。", + "cloud": "提供商在云端运行沙箱,无需管理节点。", "reset": "重置期间暂停添加节点,请在设置中查看进度。", "details": "配置详情" } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index b7b60098c..4090cdf44 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -15,18 +15,11 @@ export const sandbox = { "Root disk {{root}} · external workspace": "根磁盘 {{root}} · 外部工作区", "Workspace storage is configured by the operator; no capacity quota is enforced.": "工作区存储由运维人员配置;未实施容量配额。", - "E2B provider": "E2B 提供商", - "Other E2B-compatible provider": "其他兼容 E2B 的提供商", - "Loading templates…": "正在读取模板…", - "Could not load templates. Check the key and provider connection.": "无法读取模板,请检查密钥和提供商连接。", - "No templates are visible to this key.": "这个密钥下没有可见模板。", - "Template": "模板", - "Select a template": "选择模板", - "Loading ready builds…": "正在读取可用构建…", - "Could not load builds.": "无法读取构建。", - "This template has no ready builds.": "这个模板没有 ready 构建。", - "Select a ready build": "选择 ready 构建", - "Core validates the exact ready build again when you save.": "保存时 Core 会再次校验所选的 ready 构建。", + "Service": "服务", + "Other compatible service": "其他兼容服务", + "Loading options…": "正在读取选项…", + "Could not load options. Check the credential and provider connection.": "无法读取选项,请检查凭据和提供商连接。", + "No options are available for this credential.": "这个凭据下没有可选项。", "The selected provider supplies a default. You can edit it for a compatible endpoint.": "提供商已带出默认地址,也可以修改为兼容接口。", ...firstRun, "Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.": "托管 Session 将归档且无法恢复,部署配置与节点注册将清除。未保存的工作区内容可能丢失。", @@ -89,7 +82,7 @@ export const sandbox = { "Cancel reset?": "取消重置?", "Stop the reset and allow new hosted Sessions again. Sessions already archived stay archived and cannot be resumed.": "停止重置并重新允许创建托管 Session。已归档的 Session 会保持归档,无法恢复运行。", "Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.": "重置会归档此部署的托管 Session。已归档的 Session 无法恢复运行。历史记录、File 和 Artifact 会保留;自托管 Session 不受影响。", - "After cleanup, Core clears the provider and saved E2B credential, retires nodes and enrollment commands, and returns to setup.": "清理完成后,Core 会清除沙箱提供商和保存的 E2B 凭据,作废节点及注册命令,然后返回配置流程。", + "After cleanup, Core clears the provider and saved credential, retires nodes and enrollment commands, and returns to setup.": "清理完成后,Core 会清除沙箱提供商和保存的凭据,作废节点及注册命令,然后返回配置流程。", "Force immediately cancels remaining work. Offline resources can still block cleanup.": "强制重置会立即取消剩余任务。离线资源仍可能阻止清理完成。", "Clear hosted work": "清理托管任务", "Auto — let busy work finish": "自动 — 等待忙碌任务完成", @@ -146,7 +139,7 @@ export const sandbox = { "Copy command with --force": "复制命令(--force)", "Checking this installation's public URL…": "正在检查这个安装的公网地址…", "The installation couldn't be read, so no command can be issued.": "无法读取安装信息,暂时不能生成命令。", - "It keeps Docker images and volumes; a microsandbox store goes once Core confirmed the removal.": "它保留 Docker 镜像和卷;Core 确认移除后,microsandbox 存储会一并删除。", + "It keeps images and volumes; the sandbox store goes once Core confirmed the removal.": "它保留镜像和卷;Core 确认移除后,沙箱存储会一并删除。", "Old Core address gone?": "旧 Core 地址已失效?", "{{name}} still points at the old Core address {{address}}.": "{{name}} 仍指向旧的 Core 地址 {{address}}。", "If this node's old Core address no longer responds, first remove it on the Nodes page, then add --force to the uninstall command.": "如果这个节点的旧 Core 地址已无法访问,请先在“节点”页移除它,再在卸载命令后加上 --force。", @@ -276,19 +269,16 @@ export const sandbox = { "Set up hosted sandboxes": "配置托管沙箱", "Copied": "已复制", "Select the command above and copy it manually.": "请选择上方命令并手动复制。", - "E2B cloud": "E2B 云端", "Own machines": "自有机器", - "Core creates E2B sandboxes directly. No node enrollment is needed.": "Core 直接创建 E2B 沙箱,无需注册节点。", - "E2B API key": "E2B API 密钥", + "Core creates {{provider}} sandboxes directly. No node enrollment is needed.": "Core 直接创建 {{provider}} 沙箱,无需注册节点。", "Console → API keys": "控制台 → API 密钥", "Sandbox API URL": "沙箱 API 地址", "Sandbox data-plane domain": "沙箱数据面域名", "Enter both a public HTTPS API origin and a domain.": "请同时填写公网 HTTPS API 地址和域名。", - "Enter a template ID and build UUID separated by a colon.": "请输入模板 ID 和构建 UUID,并用冒号分隔。", "Deployment provider": "部署的沙箱提供商", "Allocated resources": "已分配资源", "Pending environments": "待处理 Environment", - "E2B credential": "E2B 凭据", + "Credential": "凭据", "Configured": "已配置", "Not configured": "未配置", "Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.": "配置已保存不代表执行已就绪。实际执行情况以 Session 和 Environment 状态为准。", @@ -307,16 +297,15 @@ export const sandbox = { "{{name}} will be removed from this deployment.": "{{name}} 将从此部署中移除。", "Open {{name}}": "打开 {{name}}", "Remove {{name}}": "移除 {{name}}", - "{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.": "CPU {{cpus}} 核,内存 {{memory}} MiB;microsandbox 的磁盘至少 {{disk}} MiB。", + "{{cpus}} CPUs, {{memory}} MiB of memory. Disks are at least {{disk}} MiB.": "CPU {{cpus}} 核,内存 {{memory}} MiB;磁盘至少 {{disk}} MiB。", "Advanced settings": "高级设置", "CPUs": "CPU(核)", - "Connect E2B": "连接 E2B", + "Connect {{provider}}": "连接 {{provider}}", "Core address": "Core 地址", "Custom size in advanced settings": "在高级设置里自定义规格", "Capacity quota at /environment (MiB)": "/environment 容量配额(MiB)", "This backend runs every sandbox on workspace storage. Select it under Settings, Workspace storage first.": "这个后端的每个沙箱都运行在工作区存储上。请先在“设置”的“工作区存储”中选择。", - "microsandbox, the recommended default, runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM. Docker runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.": "microsandbox(推荐的默认沙箱提供商):每个沙箱是一个轻量虚拟机,隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。Docker:每个沙箱是一个共用主机内核的容器,能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。", - "E2B runs sandboxes in its cloud: no machines to manage, billed by E2B. Own machines run them on hosts you add, with microsandbox (recommended) or Docker.": "E2B 云端:沙箱跑在 E2B 的云上,不用管理机器,由 E2B 计费。自有机器:沙箱跑在你添加的主机上,用 microsandbox(推荐)或 Docker。", + "Own machines run sandboxes on hosts you add.": "自有机器:沙箱跑在你添加的主机上。", "Each sandbox": "每个沙箱", "How big is each sandbox?": "每个沙箱多大?", "Large": "大", @@ -328,11 +317,7 @@ export const sandbox = { "Saving…": "保存中…", "Small": "小", "Standard": "标准", - "Template build": "模板构建", - "Ready": "就绪", - "Not ready": "未就绪", - "{{disk}} disk": "磁盘 {{disk}}", - "From the template build": "按模板构建", + "From the selected configuration": "按所选配置", "Idle suspension": "空闲挂起", "After {{idle}} · kept {{retention}}": "{{idle}}后 · 保留 {{retention}}", "Current": "当前规格", @@ -340,22 +325,17 @@ export const sandbox = { "Where should sandboxes run?": "沙箱在哪里运行?", "Which sandbox backend?": "用哪个沙箱提供商?", "Recommended": "推荐", - "Use Docker instead of microsandbox?": "改用 Docker 而不是 microsandbox?", - "Weaker isolation": "隔离更弱", - "Containers share the host's kernel, so a container escape reaches the host. microsandbox runs each sandbox in its own microVM.": "容器共用主机内核,一旦容器逃逸就能触及主机。microsandbox 则把每个沙箱放在独立的微虚拟机里运行。", - "Root-equivalent access": "等同 root 权限", - "The node's service account joins the docker group, which is equivalent to root on that host.": "节点的服务账号会加入 docker 组,这在那台主机上等同于 root 权限。", - "Limited use": "适用范围有限", - "Docker suits only trusted workloads, or hosts without KVM.": "Docker 只适合可信的工作负载,或没有 KVM 的主机。", - "Use Docker": "使用 Docker", - "Keep microsandbox": "保留 microsandbox", + "Use {{provider}}?": "使用 {{provider}}?", + "Use {{provider}}": "使用 {{provider}}", "{{cpus}} CPU · {{memory}}": "{{cpus}} 核 · {{memory}}", "Save configuration": "保存配置", "Change the sandbox configuration": "修改沙箱配置", "The address nodes and sandboxes use to reach Core.": "节点和沙箱访问 Core 使用的地址。", "Managed in Settings": "在设置中管理", - "Set a public address before connecting remote nodes; E2B sandboxes need an HTTPS one.": "连接远程节点前,请先设置公开地址;E2B 沙箱需要 HTTPS 地址。", - "Enter the E2B key again to save.": "请重新输入 E2B 密钥后再保存。", + "Set a public address before connecting; {{provider}} sandboxes need an HTTPS one.": "连接前请先设置公开地址;{{provider}} 沙箱需要 HTTPS 地址。", + "Enter the key again to save.": "请重新输入密钥后再保存。", + "Set a public address before connecting remote nodes.": "连接远程节点前,请先设置公开地址。", + "Select {{field}}": "选择{{field}}", "Enter the key": "输入密钥", "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", diff --git a/apps/web/src/lib/admin-view.ts b/apps/web/src/lib/admin-view.ts index 258dae7be..b02c900c1 100644 --- a/apps/web/src/lib/admin-view.ts +++ b/apps/web/src/lib/admin-view.ts @@ -24,7 +24,7 @@ export type ProjectSummary = Omit & { usage: AdminSummaryEntry["usage"] | null; }; -/** A Runtime observation with its project; disk is E2B-only and null elsewhere. */ +/** A Runtime observation with its project; disk is null for a Provider that cannot report it. */ export type OwnedRuntimeObservation = AdminRuntimeObservation["observation"] & { project_id: string }; const PAGE = 100; diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index b852464b1..ba1e6197d 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxProvider } from "@oac/agents-client"; +import { AgentCoreError, sandboxConfigurationUnconfirmed, sandboxProviderSetup, type SandboxProvider } from "@oac/agents-client"; import i18n, { type SupportedLanguage } from "../i18n"; import { coreError, knownCoreError } from "./core-error"; import type { ParseKeys } from "i18next"; @@ -31,7 +31,7 @@ export function sandboxRequestError(error: unknown, locale: SupportedLanguage): * read Core again before trusting what it shows. The status alone decides: no * response at all (a network failure or an abort), a 408 timeout or a 5xx (an * unreadable response is a 502). Any other 4xx is Core's clear refusal, and - * nothing changed, even when the client withheld its reason for an E2B key. + * nothing changed, even when the client withheld its reason for a credential. */ export function sandboxWriteUncertain(error: unknown): boolean { if (!(error instanceof AgentCoreError)) return true; @@ -40,7 +40,7 @@ export function sandboxWriteUncertain(error: unknown): boolean { /** * The reason for a refused deployment configuration whose code the console - * explains, such as E2B with a loopback public_url; null for any other failure. + * explains, such as a Provider that needs a public HTTPS address behind a loopback public_url; null for any other failure. * Nothing was saved, so the administrator corrects the cause and saves again. */ export function sandboxConfigurationRejection(error: unknown, locale: SupportedLanguage = "en"): string | null { @@ -48,7 +48,5 @@ export function sandboxConfigurationRejection(error: unknown, locale: SupportedL } export function sandboxProviderLabel(provider: SandboxProvider | "", locale: SupportedLanguage): string { - if (provider === "docker") return "Docker"; - if (provider === "microsandbox") return "microsandbox"; - return i18n.getFixedT(locale, "sandbox")(provider === "e2b" ? "E2B cloud" : "Unknown state"); + return provider === "" ? i18n.getFixedT(locale, "sandbox")("Unknown state") : sandboxProviderSetup(provider).label; } diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index ff92e1b18..0ff190fa2 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -74,8 +74,8 @@ definitions: allOf: - $ref: '#/definitions/api.RuntimeDiskObservation' description: |- - Current Runtime disk usage and capacity. E2B reports them; Docker and - microsandbox observations return null. + Current Runtime disk usage and capacity. Providers that cannot report + them return null. x-nullable: true environment_id: format: uuid @@ -731,8 +731,9 @@ definitions: allOf: - $ref: '#/definitions/sandbox.Resources' description: |- - Per-sandbox limits, required for Docker and microsandbox. E2B may omit - them; Core then uses the validated template build's cpus and memory_mib. + Per-sandbox limits, required for a Provider that declares default + resources. Another Provider may omit them; Core then uses the cpus and + memory_mib of the configuration it validated. required: - expected_generation type: object @@ -750,8 +751,9 @@ definitions: allOf: - $ref: '#/definitions/sandbox.Resources' description: |- - Per-sandbox limits, required for Docker and microsandbox. E2B may omit - them; Core then uses the validated template build's cpus and memory_mib. + Per-sandbox limits, required for a Provider that declares default + resources. Another Provider may omit them; Core then uses the cpus and + memory_mib of the configuration it validated. required: - expected_generation type: object @@ -775,7 +777,7 @@ definitions: description: An omitted limit is derived from the node's host when it enrolls. type: integer max_retained: - description: Docker never suspends, so Core replaces this with max_active; microsandbox uses both limits. + description: Providers without checkpoint support never suspend, so Core replaces this with max_active; the others use both limits. type: integer type: object api.SandboxIdlePolicyInput: @@ -1871,7 +1873,40 @@ definitions: credential: type: object query: - type: object + $ref: '#/definitions/sandbox.ConfigurationDiscoveryQuery' + type: object + sandbox.ConfigurationDiscoveryQuery: + properties: + field: + type: string + path: + items: + type: string + type: array + type: object + sandbox.ConfigurationOption: + properties: + complete: + type: boolean + label: + type: string + resources: + $ref: '#/definitions/sandbox.Resources' + value: + type: string + required: + - complete + - label + - value + type: object + sandbox.ConfigurationOptions: + properties: + options: + items: + $ref: '#/definitions/sandbox.ConfigurationOption' + type: array + required: + - options type: object sandbox.DeploymentMode: enum: @@ -6911,7 +6946,7 @@ paths: - Core Administration /core/v1/sandbox/deployment: get: - description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. Provider metadata is what Core read when the selection was saved; this read does not call the Provider. produces: - application/json responses: @@ -6994,7 +7029,7 @@ paths: post: consumes: - application/json - description: Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. + description: Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. parameters: - description: Deployment selection in: body @@ -7037,7 +7072,7 @@ paths: put: consumes: - application/json - description: 'Requires the observed generation, the same backend type and no active reset. E2B same-team changes apply online: allocations retain immutable generation and current credentials; omitted api_key preserves it, explicit submission including the same key verifies and advances generation. Other teams require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal.' + description: 'Requires the observed generation, the same backend type and no active reset. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal.' parameters: - description: Replacement deployment selection in: body @@ -7442,7 +7477,7 @@ paths: post: consumes: - application/json - description: Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns safe catalog metadata. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. + description: Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns the options of one Discovered setup field. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. parameters: - description: Registered provider kind in: path @@ -7461,8 +7496,7 @@ paths: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/sandbox.ConfigurationOptions' "400": description: Bad Request schema: diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 3d8aa825a..fb79ea726 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -275,7 +275,7 @@ paths: - Native Installation /api/v1/sandbox-node/configuration: get: - description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. + description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose Provider credentials. Node files cannot override this specification. parameters: - description: Retained node UUID in: header diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 2068ea9d6..50f0307af 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -68,7 +68,9 @@ E2B uses `configuration.template` in `template-id:build-uuid` form; the build UU `POST /core/v1/sandbox/providers/{provider}/discovery` takes exactly `configuration`, `credential` and `query` objects, at most 64 KiB, and runs for at most 30 seconds. Unknown members and null objects are rejected. The credential is used only for that request and is never stored or returned; discovery saves nothing, changes no deployment, allocates nothing and never proves that a selection will be admitted. Docker and microsandbox reject it with 400 `sandbox_operation_unsupported`. -For E2B, post `{"configuration": {"api_url": "…", "domain": "…"}, "credential": {"api_key": "…"}, "query": {}}` to list templates, and add `"query": {"template": "template-id"}` to list that template's ready builds; the official endpoint may omit both endpoint fields. The results are `{"templates": [{"id": "…", "names": ["…"]}]}` and `{"builds": [{"id": "build-uuid", "cpus": 2, "memory_mib": 2048}]}`, either possibly empty. The pinned SDK helper reads `GET /v2/templates` and returns at most 200 results; a limit or provider failure returns 503 with a generic message. The deployment write validates the selected build separately. +Post `{"configuration": {…}, "credential": {…}, "query": {"field": "…", "path": […]}}`, where `field` is a field the provider declares as discovered and `path` holds the values selected at the earlier levels. The result is `{"options": [{"value": "…", "label": "…", "resources": {"cpus": 2, "memory_mib": 2048}, "complete": true}]}`, possibly empty. An option with `complete: true` can be saved as the field's value; the others open the next level. Any other field returns 400 `invalid_request_error`. + +E2B declares `template` as discovered. An empty `path` lists the key's templates, and `["template-id"]` lists that template's ready builds as `template-id:build-uuid` values with their CPU count and memory; the official endpoint may omit `api_url` and `domain`. The pinned SDK helper reads `GET /v2/templates` and returns at most 200 results; a limit or provider failure returns 503 with a generic message. The deployment write validates the selected build separately. ## Safe response @@ -249,6 +251,6 @@ Storage and credential failures stay errors: an empty or failed read never prove ## Canonical node specification -`sandbox/deployment_contract.go` owns the resource bounds and canonical field order, and each registered Provider's `sandbox.DeploymentPolicy` declares its requirements; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds and Provider declarations in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or providers. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. +`sandbox/deployment_contract.go` owns the resource bounds and canonical field order, and each registered Provider's `sandbox.DeploymentPolicy` declares its requirements; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds, Provider declarations and each Provider's setup declaration (`sandbox.SetupDeclaration`) in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or providers. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. The specification digest is the SHA-256 of compact UTF-8 JSON with `provider` first, then `resources`, then `workspace` when external storage is selected. Workspace fields follow the filesystem declaration order (`attachment`, `user_xattr`, `capacity_quota`); its Boolean fields remain present. Resource fields follow the contract's declaration order; zero optional disk fields are omitted and required fields stay present. The digest never depends on the incoming field order or whitespace. `services/core/internal/sandbox/testdata/deployment-contract.json` holds shared acceptance cases, exact canonical bytes and digests that both the Go and Python tests consume. diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 4596ede7e..306cefcb9 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 57d2c630e22ee6e6b7333c36bb042af74e820c1205bb9fb6d41cb8f54acd605d +source_hash: 4b77c11fe9a80e4b6d200ca161ab53e006bfc46b185262ee858fc65d75b5112a --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -71,7 +71,9 @@ E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建 `POST /core/v1/sandbox/providers/{provider}/discovery` 恰好接受 `configuration`、`credential` 和 `query` 对象,总大小最多为 64 KiB,运行时间最多为 30 秒。未知成员和 null 对象会被拒绝。凭据仅用于该请求,绝不会被存储或返回;发现操作不会保存任何内容、不会更改部署、不会分配任何资源,也绝不会证明某项选择会被准入。Docker 和 microsandbox 会以 400 `sandbox_operation_unsupported` 拒绝该操作。 -对于 E2B,发布 `{"configuration": {"api_url": "…", "domain": "…"}, "credential": {"api_key": "…"}, "query": {}}` 可列出模板;添加 `"query": {"template": "template-id"}` 可列出该模板的就绪构建;官方端点可能会省略两个端点字段。结果为 `{"templates": [{"id": "…", "names": ["…"]}]}` 和 `{"builds": [{"id": "build-uuid", "cpus": 2, "memory_mib": 2048}]}`,两者都可能为空。固定版本 SDK 辅助工具读取 `GET /v2/templates` 并返回最多 200 条结果;达到限制或提供商失败会返回 503 和通用消息。部署写入操作会单独验证所选构建。 +发布 `{"configuration": {…}, "credential": {…}, "query": {"field": "…", "path": […]}}`,其中 `field` 是提供商声明为 discovered 的字段,`path` 是前几级已选择的值。结果为 `{"options": [{"value": "…", "label": "…", "resources": {"cpus": 2, "memory_mib": 2048}, "complete": true}]}`,可能为空。`complete: true` 的选项可保存为该字段的值,其余选项打开下一级。其他任何字段返回 400 `invalid_request_error`。 + +E2B 把 `template` 声明为 discovered。空 `path` 列出该密钥的模板,`["template-id"]` 以 `template-id:build-uuid` 值列出该模板的就绪构建及其 CPU 数和内存;官方端点可以省略 `api_url` 和 `domain`。固定版本 SDK 辅助工具读取 `GET /v2/templates` 并返回最多 200 条结果;达到限制或提供商失败会返回 503 和通用消息。部署写入操作会单独验证所选构建。 ## 安全响应 {#safe-response} @@ -254,7 +256,7 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。 ## 规范的节点规格 {#canonical-node-specification} -`sandbox/deployment_contract.go`负责资源边界和规范字段顺序,每个已注册提供商的 `sandbox.DeploymentPolicy` 声明其要求;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界和提供商声明,因此不存在第二套限制或提供商列表。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 +`sandbox/deployment_contract.go`负责资源边界和规范字段顺序,每个已注册提供商的 `sandbox.DeploymentPolicy` 声明其要求;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界、提供商声明和各提供商的 setup 声明(`sandbox.SetupDeclaration`),因此不存在第二套限制或提供商列表。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 规范摘要是紧凑 UTF-8 JSON 的 SHA-256,其中 `provider` 位于首位,其次是 `resources`。资源字段遵循契约的声明顺序;值为零的可选磁盘字段会被省略,必填字段则保持存在。摘要绝不会受传入字段顺序或空白字符影响。`services/core/internal/sandbox/testdata/deployment-contract.json`保存共享验收用例、精确的规范字节和摘要,Go 与 Python 测试都会使用这些内容。 diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 0d81fb3da..d2cb06747 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -130,11 +130,11 @@ A new provider takes these steps: 1. Implement the operation contracts in the adapter package, with native contract tests. 2. Add its specification and resource validators. -3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. +3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` takes a `ConfigurationDiscoveryQuery{Field, Path}`, where `Field` names a declared `Discovered` field and `Path` holds the values chosen at the earlier levels, and returns `ConfigurationOptions`: a list of `{Value, Label, Resources, Complete}`, where `Complete` marks an option that can be saved as the field's value and the others lead to a deeper level. It validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. 4. For a node adapter, export from its package the `BuildLocal` constructor, the typed `native` object it decodes and the native files it adds to the shared node artifacts. Node-local settings, such as host paths, live only in that object; resources are read from the node configuration's `specification`, and the Runtime image comes from the same object. -5. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode and deployment policy with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads them from `deploy/node/node_spec.py`, and the TypeScript client and Web from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. +5. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode, deployment policy and setup declaration with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads the policies from `deploy/node/node_spec.py`, and the TypeScript client and Web read policies and setup declarations from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. 6. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. -7. Web's setup step, deployment summary and client projection name each provider. Add the new provider to `apps/web` until the protocol declares configuration fields. +7. Declare the setup wizard's content in a `sandbox.SetupDeclaration` beside `Policy()` and register it as the entry's `Setup`. It carries the label, a summary and cautions in English and Simplified Chinese, whether the provider is the Recommended choice of its mode, the public configuration and credential fields (`Name`, localized `Label`, `Secret`, `Discovered`, `Optional`, `Help`), and presets that fill field values and link the credential console. Registration rejects a secret field unless the credential is required, a discovered field unless `Discovery` is supported, an empty locale, a preset value for an unknown field, and more than one Recommended provider in a mode. Web follows the declared mode and fields and names no provider: the declaration reaches it only through the generated `packages/agents-client/src/deployment-contract.ts`, not `deploy/node/node_spec.py`. A node configuration, `sandbox.NodeConfig`, holds `provider`, `generation`, `installation_id`, `core_url`, `specification` and the adapter's opaque `native` object. `providers.Build` validates `provider`, `generation`, `installation_id` and `specification`, and passes the configuration with ephemeral `sandbox.LocalOptions` to `BuildLocal`, which decodes `native` strictly. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 49103e848..276eb8de0 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -104,7 +104,7 @@ The list carries each Harness's configuration, so the console does not read `GET | Operation | Route | Console use | | --- | --- | --- | | Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in Settings leading to Settings → Service and deployment, and leaves nothing to confirm; initialize the deployment with Docker or microsandbox `resources`, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows in Settings → Sandbox and scheduling and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows in Settings → Sandbox and scheduling | -| E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the templates the entered E2B key can see, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | +| E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the options of a discovered field with the entered credential, one level at a time. For E2B these are the key's templates, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | | Reset | `POST`, `DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources, or cancel the remaining clear at the observed generation; show Core's remaining and offline projection | | Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; each hosted Runtime's node on Sandbox metrics. An online node whose `provider_ready` is false is degraded; the help tip beside its status on the Nodes list and on the node's page says *Sandbox provider unavailable*, and the target preparation reason (`rollout.diagnostic`, a [readiness code](../getting-started/nodes.md#readiness-codes)) shows in its target status. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Edit node** reads the node's `recommended_capacity` from this list. **Add node** follows only the node whose `enrollment_id` equals its command's | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | A node's page on Nodes: the host's CPU busy share, memory and free disk from its last heartbeat, and CPU and memory history over 1 hour, 6 hours or 24 hours, refreshed every 30 s while visible. | diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 9a5b772dd..673b5e66c 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: 93958e2d8c2c983cd63b9a3b75ab2e29322100ba3f175622037dd5b2f6ff4c1b +source_hash: 4fb55cb4416f6b909fce7491a9d53f586d69f4a1abc9deec29c8e1ebc7b52679 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。Docker、microsandbox 和 E2B 实现了该协议。 @@ -132,11 +132,11 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` 1. 在 adapter 包中实现 operation 契约,并编写原生契约测试。 2. 添加 specification 和 resource validator。 -3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node 提供商仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 +3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 接收 `ConfigurationDiscoveryQuery{Field, Path}`,其中 `Field` 是已声明的 `Discovered` 字段,`Path` 是前几级已选择的值,并返回 `ConfigurationOptions`:由 `{Value, Label, Resources, Complete}` 组成的列表,`Complete` 表示该选项可保存为字段值,其余选项通向下一级。它验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node 提供商仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 4. node adapter 从自己的包中导出 `BuildLocal` constructor、它解码的类型化 `native` 对象,以及它在共享 node artifact 之外添加的原生文件。node-local 设置(如主机路径)只存放在该对象中;resources 从 node 配置的 `specification` 读取,Runtime 镜像来自同一对象。 -5. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即提供商 kind,也用于标记该提供商的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式和 deployment policy 与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取,因此 Web 读取这些声明,而不比较提供商 kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 +5. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即提供商 kind,也用于标记该提供商的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式、deployment policy 和 setup 声明与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取 policy,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取 policy 和 setup 声明,因此 Web 读取这些声明,而不比较提供商 kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 6. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约把该提供商交付给运维人员。 -7. Web 的 setup 步骤、部署摘要和客户端投影会指名每个提供商。在协议声明配置字段之前,把新提供商加入 `apps/web`。 +7. 在 `Policy()` 旁声明 setup 向导的内容:编写 `sandbox.SetupDeclaration`,并注册为条目的 `Setup`。它包含中英文的标签、摘要和注意事项,该提供商是否为其模式的 Recommended 选择,公开配置字段和凭据字段(`Name`、本地化的 `Label`、`Secret`、`Discovered`、`Optional`、`Help`),以及填充字段值并链接凭据控制台的 preset。注册会拒绝:凭据非必需却声明了 secret 字段、不支持 `Discovery` 却声明了 discovered 字段、空的语言文本、指向未知字段的 preset 值,以及同一模式下多于一个 Recommended 提供商。Web 遵循声明的模式和字段,不出现任何提供商名称:声明只通过生成的 `packages/agents-client/src/deployment-contract.ts` 到达 Web,不进入 `deploy/node/node_spec.py`。 node 配置 `sandbox.NodeConfig` 包含 `provider`、`generation`、`installation_id`、`core_url`、`specification`,以及 adapter 的不透明 `native` 对象。`providers.Build` 验证 `provider`、`generation`、`installation_id` 和 `specification`,并将配置与临时 `sandbox.LocalOptions` 传给 `BuildLocal`,由它严格解码 `native`。调用方通过 `Standalone` 明确选择独立注册或单提供商执行,或通过 `GenerationStateDirectory` 中的规范绝对 node state directory 选择 generation 拥有的执行。context 缺失或混合时拒绝。adapter 负责 generation 特有的原生 preparation 和 readiness 检查。Microsandbox 将 helper lease 绑定到安装实例、generation 和 specification digest,然后在平台、容量和 artifact readiness 后检查固定 image。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 54fafc2c6..4cf32fc4b 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: fb035e336b49105be6cc6fb4ec72e2f62e98b7ca6ea696a907cdce2fd8824e9e +source_hash: c20ea9f9af5f9f642b0df4fee8046aa4e97aabdaa53ffb07cf8a1022bbadc09a --- 本页列出各控制台页面调用的 Core 路由,以及控制台如何限定读取范围。本页面向控制台开发者;运维人员请阅读 [Web 控制台](index.md)。[Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API 命名空间和凭据](../api/index.md) 定义了本文使用的术语。 @@ -106,7 +106,7 @@ source_hash: fb035e336b49105be6cc6fb4ec72e2f62e98b7ca6ea696a907cdce2fd8824e9e | 操作 | 路由 | 控制台用途 | | --- | --- | --- | | 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 **在设置中管理** 前往 **设置 → 服务与部署**,且无需确认;使用 Docker 或 microsandbox 的 `resources` 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。E2B 的 `metadata.template_build`(状态、CPU、内存、磁盘)会显示在 **设置 → 沙箱与调度** 和 **沙箱监控** 中;当缺少 `specification.resources` 时,它还会确定每个 Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 **设置 → 沙箱与调度** 中 | -| E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导先列出输入的 E2B 密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | +| E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导用输入的凭据逐级列出 discovered 字段的选项;E2B 先列出该密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | | 重置 | `POST`、`DELETE /core/v1/sandbox/deployment/reset` | 显式清除托管资源,或在观测到的 generation 处取消剩余清除;显示 Core 的剩余资源和离线预测 | | 节点 | `GET /core/v1/sandbox/nodes` | **节点** 页面;**概览** 上的机群;**沙箱监控** 中每个托管 Runtime 所在的节点。`provider_ready` 为 false 的在线节点为降级;**节点** 列表及节点页面中紧邻其状态的帮助提示显示 *沙箱提供商不可用*,目标准备原因(`rollout.diagnostic`,一个[就绪状态码](../getting-started/nodes.md#readiness-codes))显示在其目标状态中。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,**节点** 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 **地址已过期**,而不是健康状态。**添加节点** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | | 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | **节点** 中的节点页面:主机最近一次心跳报告的 CPU 忙碌占比、内存使用量和可用磁盘,以及 1 小时、6 小时或 24 小时内 CPU 与内存的历史记录;页面可见时每 30 秒刷新一次。**编辑节点** 读取节点的 `recommended_capacity` 作为推荐值 | diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 4b04cab05..d7376ad97 100644 --- a/packages/agents-client/src/deployment-contract.ts +++ b/packages/agents-client/src/deployment-contract.ts @@ -1,3 +1,3 @@ // Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT. -export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"providers":{"docker":{"mode":"nodes","disk":false,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"default_resources":null},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192}}},"minimum_disk":1024} as const; +export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"providers":{"docker":{"mode":"nodes","disk":false,"default_resources":{"cpus":2,"memory_mib":2048},"setup":{"label":"Docker","summary":{"en":"Runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.","zh-CN":"每个沙箱是一个共用主机内核的容器:能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。"},"cautions":[{"en":"Containers share the host's kernel, so a container escape reaches the host.","zh-CN":"容器共用主机内核,一旦容器逃逸就能触及主机。"},{"en":"The node's service account joins the docker group, which is equivalent to root on that host.","zh-CN":"节点的服务账号会加入 docker 组,这在那台主机上等同于 root 权限。"}],"recommended":false,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}},"e2b":{"mode":"direct","disk":false,"default_resources":null,"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"cautions":[],"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"setup":{"label":"microsandbox","summary":{"en":"Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"cautions":[],"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}}},"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 9af63ee48..b8eeb6163 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -124,10 +124,28 @@ export const capacityFields = ["max_active", "max_retained"] as const; export interface ConfigurationDiscoveryInput { configuration?: Record; credential?: Record; - query?: Record; + query?: ConfigurationDiscoveryQuery; } export const configurationDiscoveryInputFields = ["configuration", "credential", "query"] as const; export const configurationDiscoveryInputRequired = [] as const; +export interface ConfigurationDiscoveryQuery { + field?: string; + path?: string[]; +} +export const configurationDiscoveryQueryFields = ["field", "path"] as const; +export const configurationDiscoveryQueryRequired = [] as const; +export interface ConfigurationOption { + complete: boolean; + label: string; + resources?: SandboxResources; + value: string; +} +export const configurationOptionFields = ["complete", "label", "resources", "value"] as const; +export const configurationOptionRequired = ["complete", "label", "value"] as const; +export interface ConfigurationOptions { + options: ConfigurationOption[]; +} +export const configurationOptionsFields = ["options"] as const; export interface CoreAPIError { code: string | null; details?: Record; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 46e4f9b18..126533cf9 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -122,24 +122,25 @@ describe("strict sandbox administration projections", () => { }); describe("Core sandbox credential boundaries", () => { - it("uses Core-key routes for transient template discovery and projects safe fields", async () => { + it("uses a Core-key route for transient discovery and projects safe fields", async () => { + const build = { value: "tpl_123:00000000-0000-0000-0000-000000000001", label: "00000000-0000-0000-0000-000000000001", resources: { cpus: 2, memory_mib: 2048 }, complete: true }; const fetch = vi.fn() - .mockResolvedValueOnce(response({ templates: [{ id: "tpl_123", names: ["runtime"] }] })) - .mockResolvedValueOnce(response({ builds: [{ id: "00000000-0000-0000-0000-000000000001", cpus: 2, memory_mib: 2048 }] })); + .mockResolvedValueOnce(response({ options: [{ value: "tpl_123", label: "runtime", complete: false }] })) + .mockResolvedValueOnce(response({ options: [build] })); const client = new SandboxAdminClient({ fetch }); - const input = { api_key: "private-test-key", api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" }; - expect(await client.listE2BTemplates(input)).toHaveLength(1); - expect(await client.listE2BReadyBuilds("tpl_123", input)).toHaveLength(1); + const input = { configuration: { api_url: "https://sandbox.sandbase.ai" }, credential: { api_key: "private-test-key" }, query: { field: "template", path: [] as string[] } }; + expect(await client.discoverConfiguration("e2b", input)).toHaveLength(1); + expect(await client.discoverConfiguration("e2b", { ...input, query: { field: "template", path: ["tpl_123"] } })).toEqual([build]); expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/core/v1/sandbox/providers/e2b/discovery", "/core/v1/sandbox/providers/e2b/discovery"]); expect(fetch.mock.calls.every(([, init]) => init?.method === "POST" && !new Headers(init?.headers).has("Authorization") && String(init?.body).includes("private-test-key"))).toBe(true); }); it("rejects extra fields in credentialed discovery responses", async () => { - const input = { api_key: "private-test-key" }; - const templates = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response({ templates: [], builds: [] })) }); - const builds = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response({ builds: [], templates: [] })) }); - await expect(templates.listE2BTemplates(input)).rejects.toMatchObject({ code: "invalid_admin_response" }); - await expect(builds.listE2BReadyBuilds("tpl_123", input)).rejects.toMatchObject({ code: "invalid_admin_response" }); + const input = { configuration: {}, credential: { api_key: "private-test-key" }, query: { field: "template", path: [] as string[] } }; + for (const body of [{ options: [], templates: [] }, { options: [{ value: "a", label: "a", complete: false, id: "a" }] }, { options: [{ value: "a", label: "a" }] }]) { + const client = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response(body)) }); + await expect(client.discoverConfiguration("e2b", input)).rejects.toMatchObject({ code: "invalid_admin_response" }); + } }); it("defaults to /core/v1/sandbox and is not a /v1 client", async () => { @@ -285,7 +286,7 @@ describe("hosted provider configuration", () => { expect(fetch).toHaveBeenCalledTimes(2); }); it("projects public-URL rejection to fixed copy even when the upstream message reflects a key", async () => { - const rejection = { message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", code: "sandbox_configuration_error", param: null, type: "reflected" }; + const rejection = { message: "This sandbox provider reaches Core over the internet. Set an HTTPS public URL that is not loopback.", code: "sandbox_configuration_error", param: null, type: "reflected" }; const fetch = vi.fn() .mockResolvedValueOnce(response({ error: rejection }, 409)) .mockResolvedValueOnce(response({ error: { ...rejection, message: rejection.message + e2b.api_key } }, 409)); @@ -397,7 +398,7 @@ it("keeps omitted-key public-URL errors actionable without reflecting a stored k const fetch = vi.fn().mockResolvedValue(response({ error: { code: "sandbox_configuration_error", message: `arbitrary upstream ${secret}`, param: secret, details: { credential: secret } } }, 409)); const client = new SandboxAdminClient({ fetch }); const error = await client.updateDeployment({ provider: "e2b", expected_generation: 1, configuration: { template: e2bDeployment.configuration.template } }).catch(error => error); - expect(error).toMatchObject({ status: 409, code: "sandbox_configuration_error", param: null, message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback." }); + expect(error).toMatchObject({ status: 409, code: "sandbox_configuration_error", param: null, message: "This sandbox provider reaches Core over the internet. Set an HTTPS public URL that is not loopback." }); expect(JSON.stringify(error)).not.toContain(secret); expect(error.message).not.toContain(secret); expect(error.details).toBeUndefined(); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index c4fd8ba8b..19ec1f72f 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -1,15 +1,15 @@ import { AgentCoreError } from "./client"; import { CoreRequester, type CoreClientOptions } from "./core-request"; import { deploymentContract } from "./deployment-contract"; -import { hasOwn, isNonnegativeInteger, isOneOf, isRecord, onlyFields, sameResourceId, schemaFields } from "./response-projection"; +import { hasOwn, isNonnegativeInteger, isOneOf, isRecord, sameResourceId, schemaFields } from "./response-projection"; import { attachmentKindValues, deploymentResourcesFields, deploymentSpecFields, deploymentSpecRequired, deploymentViewFields, deploymentModeValues, deploymentViewRequired, hostHistoryFields, hostHistoryPointFields, nodeAllocationDiagnosticValues, nodeAllocationFields, nodeDetailFields, - capacityFields, nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, + capacityFields, configurationOptionFields, configurationOptionRequired, configurationOptionsFields, nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, resetFields, resetOfflineNodeFields, resetRemainingFields, rolloutFields, rolloutNodesFields, rolloutStateValues, sandboxAllocationListFields, sandboxNodeListFields, sandboxResourcesFields, sandboxResourcesRequired, suspensionFields, - type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnosticCode, + type ConfigurationOption, type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnosticCode, type NodeHost, type NodeRollout, type NodeUpdate, type Reset, type ResetOfflineNode, type ResetRequest, type Rollout, type SandboxIdlePolicyInput, type SandboxDeploymentInput, type SandboxEnrollmentToken, type SandboxResources as SandboxResourcesResource, } from "./generated/core-api"; @@ -25,6 +25,27 @@ export function normalizeSandboxNodeDiagnostic(value: string): SandboxNodeDiagno /** A registered Provider kind; deploymentContract.providers holds each one's declaration. */ export type SandboxProvider = keyof typeof deploymentContract.providers; +/** Operator-facing copy in every supported locale. */ +export interface SandboxSetupText { readonly en: string; readonly "zh-CN": string } +/** One value an operator enters: a secret one is the credential, any other configuration. */ +export interface SandboxSetupField { readonly name: string; readonly secret: boolean; readonly optional: boolean; readonly discovered: boolean; readonly label: SandboxSetupText; readonly help: SandboxSetupText } +/** A named set of field values, such as one hosted service. */ +export interface SandboxSetupPreset { readonly label: string; readonly values: Readonly>; readonly credential_url?: string } +/** What the console needs to set up one Provider, generated from the Provider's declaration. */ +export interface SandboxProviderSetup { + readonly label: string; + readonly summary: SandboxSetupText; + readonly cautions: readonly SandboxSetupText[]; + readonly recommended: boolean; + readonly fields: readonly SandboxSetupField[]; + readonly presets: readonly SandboxSetupPreset[]; + readonly credential: "required" | "not_required"; + readonly public_origin: "required" | "not_required"; + readonly discovery: boolean; +} +export function sandboxProviderSetup(provider: SandboxProvider): SandboxProviderSetup { + return deploymentContract.providers[provider].setup as SandboxProviderSetup; +} /** Client-generated, never a Core code: a deployment write whose rejection could echo the key and is withheld. */ export const sandboxConfigurationUnconfirmed = "sandbox_configuration_unconfirmed"; const sandboxConfigurationParams = new Set([...deploymentContract.resources.map(({ name }) => `resources.${name}`)]); @@ -44,37 +65,35 @@ export type SandboxNode = Node; export type SandboxEnrollment = SandboxEnrollmentToken; /** One node with its host observation and complete UTC buckets of host history. */ export type SandboxNodeDetail = NodeDetail; -/** A node's name and sandbox limits, with a retained limit of at least the active one. Under Docker, Core sets the retained limit to the active one. */ +/** A node's name and sandbox limits, with a retained limit of at least the active one. Core sets the retained limit to the active one on a Provider without checkpoint support. */ export type SandboxNodeUpdate = NodeUpdate; export type SandboxAllocation = NodeAllocation; -// The schema types each Provider's configuration, credential and metadata as free-form objects; the client names E2B's. +// The schema types each Provider's configuration and credential as free-form objects; their members are the Provider's declared setup fields. /** * Core derives the deployment's address from the installation public URL; a `core_url` member is rejected. - * `expected_generation` is required, zero for first setup. Docker and microsandbox require `resources`; - * E2B may omit `resources` to adopt its template build's CPU and memory, and always runs that build. + * `expected_generation` is required, zero for first setup. A Provider that declares default resources requires `resources`; + * another may omit them to adopt the size of the configuration it validated. */ export type InitializeSandboxDeployment = Omit & { provider: SandboxProvider; - configuration?: SandboxE2BConfiguration; - /** Write-only. Omission on update preserves the current credential. */ - credential?: { api_key: string }; + /** The declared non-secret field values. */ + configuration?: Record; + /** Write-only, the declared secret field values. Omission on update preserves the current credential. */ + credential?: Record; }; export type UpdateSandboxDeployment = InitializeSandboxDeployment; -export type SandboxDeployment = Omit & { +export type SandboxDeployment = Omit & { provider: SandboxProvider | ""; - configuration?: SandboxE2BConfiguration; - metadata?: { template_build?: SandboxE2BTemplateBuild }; + configuration?: Record; }; -export interface SandboxE2BConfiguration { template?: string; api_url?: string; domain?: string } -/** The fixed E2B build as Core read it when the selection was saved; unknown values are null. */ -export interface SandboxE2BTemplateBuild { - status: string | null; - resources: { cpus: number | null; memory_mib: number | null; root_disk_mib: number | null }; +/** One choice for a discovered field: a complete option's value is the field's value, another's is the next path element. */ +export type SandboxConfigurationOption = ConfigurationOption; +export interface SandboxDiscoveryInput { + configuration: Record; + credential: Record; + query: { field: string; path: string[] }; } -export interface SandboxE2BDiscoveryInput { api_key: string; api_url?: string; domain?: string } -export interface SandboxE2BTemplate { id: string; names: string[] } -export interface SandboxE2BReadyBuild { id: string; cpus: number; memory_mib: number } export type SandboxNodeHistoryRange = "1h" | "6h" | "24h"; function invalidSandboxResponse(): never { @@ -106,17 +125,6 @@ function projectSpecification(value: unknown): SandboxSpecification { } return result; } -/** The adapter's public projection has no credential member. */ -function projectE2B(configuration: unknown, metadata: unknown): Pick { - const config = members(configuration, ["template", "api_url", "domain"]); - valid(strings(config, ["template", "api_url", "domain"])); - const facts = members(metadata, ["template_build"], []); - if (!hasOwn(facts, "template_build")) return { configuration: { ...config }, metadata: {} }; - const build = members(facts.template_build, ["status", "resources"]); - const resources = members(build.resources, ["cpus", "memory_mib", "root_disk_mib"]); - valid((build.status === null || typeof build.status === "string") && Object.values(resources).every(nullable(isNonnegativeInteger))); - return { configuration: { ...config }, metadata: { template_build: { status: build.status as string | null, resources: { ...resources } as SandboxE2BTemplateBuild["resources"] } } }; -} /** Counts and blocker identities are one Core snapshot, never reconstructed from node lists. */ function projectReset(value: unknown, held: number): SandboxReset | null { if (value === null) return null; @@ -152,13 +160,17 @@ function projectNodeRollout(value: unknown, online: unknown): SandboxNodeRollout } /** Configured deployments carry a validated public configuration and observation object. */ function projectDeployment(value: unknown): SandboxDeployment { - const e2b = isRecord(value) && value.provider === "e2b"; // A selected Provider always has its configuration and metadata; no Provider has neither. const selected = isRecord(value) && value.provider !== ""; const deployment = members(value, deploymentViewFields, selected ? [...deploymentViewRequired, "configuration", "metadata"] : deploymentViewRequired); valid(selected || (!hasOwn(deployment, "configuration") && !hasOwn(deployment, "metadata"))); valid(typeof deployment.credential_configured === "boolean"); - if (!e2b && deployment.provider !== "") { members(deployment.configuration, []); members(deployment.metadata, []); valid(deployment.credential_configured === false); } + if (selected) { + valid(typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider) && isRecord(deployment.metadata)); + const setup = sandboxProviderSetup(deployment.provider as SandboxProvider); + const configuration = members(deployment.configuration, setup.fields.filter((field) => !field.secret).map((field) => field.name)); + valid(strings(configuration, Object.keys(configuration)) && (setup.credential === "required" || deployment.credential_configured === false)); + } const resources = members(deployment.resources, deploymentResourcesFields); const suspension = deployment.suspension === null ? null : members(deployment.suspension, suspensionFields); const configured = hasOwn(deployment, "specification"); @@ -169,7 +181,6 @@ function projectDeployment(value: unknown): SandboxDeployment { return { ...deployment, rollout: projectRollout(deployment.rollout, deployment.mode, Number(resources.allocations) + Number(resources.pending)), reset: projectReset(deployment.reset, Number(resources.allocations) + Number(resources.pending)), resources: { ...resources } as unknown as SandboxDeployment["resources"], suspension: suspension && { ...suspension } as unknown as SandboxDeployment["suspension"], ...(configured ? { specification: projectSpecification(deployment.specification) } : {}), - ...(e2b ? projectE2B(deployment.configuration, deployment.metadata) : {}), } as unknown as SandboxDeployment; } @@ -225,20 +236,15 @@ export class SandboxAdminClient { this.#core = new CoreRequester(options.baseUrl ?? "/core/v1/sandbox", options.token, options.fetch, invalidSandboxResponse); } - async listE2BTemplates(input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise { - const value = await this.#core.json("/providers/e2b/discovery", options, "POST", { configuration: { api_url: input.api_url, domain: input.domain }, credential: { api_key: input.api_key }, query: {} }); - if (!isRecord(value) || !onlyFields(value, new Set(["templates"])) || !Array.isArray(value.templates) || value.templates.length > 200) invalidSandboxResponse(); - return value.templates.map((item) => { - if (!isRecord(item) || !onlyFields(item, new Set(["id", "names"])) || typeof item.id !== "string" || !Array.isArray(item.names) || !item.names.every((name) => typeof name === "string")) invalidSandboxResponse(); - return { id: item.id, names: item.names }; - }); - } - async listE2BReadyBuilds(templateId: string, input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise { - const value = await this.#core.json("/providers/e2b/discovery", options, "POST", { configuration: { api_url: input.api_url, domain: input.domain }, credential: { api_key: input.api_key }, query: { template: templateId } }); - if (!isRecord(value) || !onlyFields(value, new Set(["builds"])) || !Array.isArray(value.builds) || value.builds.length > 200) invalidSandboxResponse(); - return value.builds.map((item) => { - if (!isRecord(item) || !onlyFields(item, new Set(["id", "cpus", "memory_mib"])) || typeof item.id !== "string" || !isNonnegativeInteger(item.cpus) || !isNonnegativeInteger(item.memory_mib)) invalidSandboxResponse(); - return { id: item.id, cpus: item.cpus, memory_mib: item.memory_mib }; + /** The options of one discovered setup field after the values chosen at earlier levels. */ + async discoverConfiguration(provider: SandboxProvider, input: SandboxDiscoveryInput, options?: ReadOptions): Promise { + const value = members(await this.#core.json(`/providers/${encodeURIComponent(provider)}/discovery`, options, "POST", input), configurationOptionsFields); + valid(Array.isArray(value.options) && value.options.length <= 200); + return (value.options as unknown[]).map((entry) => { + const option = members(entry, configurationOptionFields, configurationOptionRequired); + valid(typeof option.value === "string" && typeof option.label === "string" && typeof option.complete === "boolean"); + if (hasOwn(option, "resources")) valid(Object.values(members(option.resources, sandboxResourcesFields, sandboxResourcesRequired)).every(isNonnegativeInteger)); + return { ...option } as unknown as SandboxConfigurationOption; }); } @@ -278,10 +284,10 @@ export class SandboxAdminClient { sandbox_reset_in_progress: "A sandbox reset is in progress.", sandbox_not_configured: "The sandbox deployment is not configured.", sandbox_in_use: "Hosted sandbox resources still belong to this deployment.", - sandbox_credential_ownership: "This E2B key cannot manage the retained deployment. Reset before changing teams.", - sandbox_credential_invalid: "The E2B API key was rejected.", - sandbox_configuration_invalid: "Select a ready immutable E2B template build with matching resources.", - sandbox_verification_unconfirmed: "E2B verification could not be confirmed. Refresh before submitting again.", + sandbox_credential_ownership: "This credential cannot manage the retained deployment. Reset before changing accounts.", + sandbox_credential_invalid: "The sandbox provider credential was rejected.", + sandbox_configuration_invalid: "Select a ready immutable provider configuration with matching resources.", + sandbox_verification_unconfirmed: "Sandbox provider verification could not be confirmed. Refresh before submitting again.", }; if (error.code && Object.hasOwn(messages, error.code)) { // Credential-bearing errors expose fixed local copy and allowlisted @@ -298,7 +304,7 @@ export class SandboxAdminClient { // This code has one fixed Core meaning. Never forward its raw message or // param: an omitted key cannot be used to detect a reflected stored key. if (error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error") { - throw new AgentCoreError("E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", 409, "sandbox_configuration_error", null); + throw new AgentCoreError("This sandbox provider reaches Core over the internet. Set an HTTPS public URL that is not loopback.", 409, "sandbox_configuration_error", null); } // Any other credential-bearing rejection may reflect the key in any error field. throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, sandboxConfigurationUnconfirmed); diff --git a/packages/agents-client/src/sandbox-e2b-error-params.test.ts b/packages/agents-client/src/sandbox-e2b-error-params.test.ts index 608a1d372..42b831784 100644 --- a/packages/agents-client/src/sandbox-e2b-error-params.test.ts +++ b/packages/agents-client/src/sandbox-e2b-error-params.test.ts @@ -3,9 +3,9 @@ import { AgentCoreError } from "./client"; import { SandboxAdminClient } from "./sandbox-client"; const cases = [ - { status: 400, code: "sandbox_credential_invalid", param: "credential", message: "The E2B API key was rejected." }, - { status: 400, code: "sandbox_configuration_invalid", param: "configuration", message: "Select a ready immutable E2B template build with matching resources." }, - { status: 409, code: "sandbox_credential_ownership", param: "credential", message: "This E2B key cannot manage the retained deployment. Reset before changing teams." }, + { status: 400, code: "sandbox_credential_invalid", param: "credential", message: "The sandbox provider credential was rejected." }, + { status: 400, code: "sandbox_configuration_invalid", param: "configuration", message: "Select a ready immutable provider configuration with matching resources." }, + { status: 409, code: "sandbox_credential_ownership", param: "credential", message: "This credential cannot manage the retained deployment. Reset before changing accounts." }, ] as const; const currentKey = "current-secret-canary"; const storedKey = "stored-secret-canary"; diff --git a/services/core/cmd/server/managed_setup.go b/services/core/cmd/server/managed_setup.go index a0aee5c87..26d83a1f5 100644 --- a/services/core/cmd/server/managed_setup.go +++ b/services/core/cmd/server/managed_setup.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "sync/atomic" @@ -56,7 +55,7 @@ type generationAllocations interface { // DiscoverConfiguration asks a Provider which configuration values its // credential can use, with this installation's process paths. -func (s *managedSetup) DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (s *managedSetup) DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { return s.registry.DiscoverConfiguration(ctx, provider, input, s.processPaths) } diff --git a/services/core/internal/api/admin_runtime.go b/services/core/internal/api/admin_runtime.go index 77f96d043..b03d361df 100644 --- a/services/core/internal/api/admin_runtime.go +++ b/services/core/internal/api/admin_runtime.go @@ -18,8 +18,8 @@ type AdminRuntimeObservation struct { // project observation shape, which stays unchanged. type AdminRuntimeObservationDetail struct { v1.RuntimeObservation - // Current Runtime disk usage and capacity. E2B reports them; Docker and - // microsandbox observations return null. + // Current Runtime disk usage and capacity. Providers that cannot report + // them return null. Disk *RuntimeDiskObservation `json:"disk" extensions:"x-nullable" binding:"required"` } diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index c5f7f7e65..f70accccc 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -172,10 +172,10 @@ func (f *fakeArtifactsReader) ReadSessionArtifact(a0 context.Context, a1 string, type fakeConfigurationDiscovery struct { t testing.TB - discoverConfiguration func(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) + discoverConfiguration func(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) } -func (f *fakeConfigurationDiscovery) DiscoverConfiguration(a0 context.Context, a1 string, a2 sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (f *fakeConfigurationDiscovery) DiscoverConfiguration(a0 context.Context, a1 string, a2 sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { if f.discoverConfiguration == nil { unexpectedCall(f.t, "DiscoverConfiguration") } diff --git a/services/core/internal/api/sandbox_configuration_discovery.go b/services/core/internal/api/sandbox_configuration_discovery.go index 95a4f3e59..c0820d641 100644 --- a/services/core/internal/api/sandbox_configuration_discovery.go +++ b/services/core/internal/api/sandbox_configuration_discovery.go @@ -2,7 +2,6 @@ package api import ( "context" - "encoding/json" "net/http" "time" @@ -14,18 +13,18 @@ import ( // ConfigurationDiscovery asks a Sandbox Provider which configuration values // its credential can use. type ConfigurationDiscovery interface { - DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) + DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) } // @Summary Discover sandbox provider configuration -// @Description Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns safe catalog metadata. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. +// @Description Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns the options of one Discovered setup field. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. // @Tags Sandbox Manager // @Accept json // @Produce json // @Security DeploymentAdminAuth // @Param provider path string true "Registered provider kind" // @Param body body sandbox.ConfigurationDiscoveryInput true "Transient provider connection and query" -// @Success 200 {object} map[string]interface{} +// @Success 200 {object} sandbox.ConfigurationOptions // @Failure 400,401,500,503 {object} CoreErrorResponse // @Router /core/v1/sandbox/providers/{provider}/discovery [post] func (h *Handler) discoverSandboxConfiguration(w http.ResponseWriter, r *http.Request) { diff --git a/services/core/internal/api/sandbox_configuration_discovery_test.go b/services/core/internal/api/sandbox_configuration_discovery_test.go index 4111a5bf7..10ea9b264 100644 --- a/services/core/internal/api/sandbox_configuration_discovery_test.go +++ b/services/core/internal/api/sandbox_configuration_discovery_test.go @@ -2,7 +2,6 @@ package api import ( "context" - "encoding/json" "net/http" "net/http/httptest" "strings" @@ -14,7 +13,7 @@ import ( func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { deps, fakes := sandboxFakes(t) calls := 0 - discover := func(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { + discover := func(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { calls++ if kind != "e2b" || !strings.Contains(string(input.Credential), "private-test-key") { t.Fatal("wrong request") @@ -22,13 +21,10 @@ func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { if _, ok := ctx.Deadline(); !ok { t.Fatal("unbounded discovery") } - if strings.Contains(string(input.Query), "bad") { - return nil, sandbox.ErrConfigurationUnconfirmed + if len(input.Query.Path) == 1 && input.Query.Path[0] == "bad" { + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationUnconfirmed } - if strings.Contains(string(input.Query), "tpl_123") { - return json.RawMessage(`{"builds":[]}`), nil - } - return json.RawMessage(`{"templates":[]}`), nil + return sandbox.ConfigurationOptions{Options: []sandbox.ConfigurationOption{}}, nil } fakes.configurationDiscovery.discoverConfiguration = discover h := newTestHandler(t, deps) @@ -36,11 +32,11 @@ func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { token, path, body string status int }{ - {"caller", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{}}`, 401}, + {"caller", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":[]}}`, 401}, {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"unexpected":true}`, 400}, - {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{}}`, 200}, - {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"template":"tpl_123"}}`, 200}, - {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"template":"bad"}}`, 503}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":[]}}`, 200}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":["tpl_123"]}}`, 200}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":["bad"]}}`, 503}, } { req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) req.Header.Set("Authorization", "Bearer "+tc.token) diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index 6e0ba893e..4aa58c496 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -14,8 +14,9 @@ import ( type SandboxDeploymentInput struct { ExpectedGeneration *uint64 `json:"expected_generation" binding:"required"` - // Per-sandbox limits, required for Docker and microsandbox. E2B may omit - // them; Core then uses the validated template build's cpus and memory_mib. + // Per-sandbox limits, required for a Provider that declares default + // resources. Another Provider may omit them; Core then uses the cpus and + // memory_mib of the configuration it validated. Resources sandbox.Resources `json:"resources"` Provider string `json:"provider"` Configuration json.RawMessage `json:"configuration" swaggertype:"object"` @@ -50,7 +51,7 @@ type DeploymentReset interface { } // @Summary Initialize the deployment sandbox provider -// @Description Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. +// @Description Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -83,7 +84,7 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req } // @Summary Change the sandbox deployment configuration -// @Description Requires the observed generation, the same backend type and no active reset. E2B same-team changes apply online: allocations retain immutable generation and current credentials; omitted api_key preserves it, explicit submission including the same key verifies and advances generation. Other teams require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. +// @Description Requires the observed generation, the same backend type and no active reset. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth diff --git a/services/core/internal/api/sandbox_manager.go b/services/core/internal/api/sandbox_manager.go index b9934fd99..29a754533 100644 --- a/services/core/internal/api/sandbox_manager.go +++ b/services/core/internal/api/sandbox_manager.go @@ -33,7 +33,7 @@ type SandboxMutationResponse struct { type SandboxEnrollmentTokenRequest struct { // An omitted limit is derived from the node's host when it enrolls. MaxActive *int `json:"max_active,omitempty"` - // Docker never suspends, so Core replaces this with max_active; microsandbox uses both limits. + // Providers without checkpoint support never suspend, so Core replaces this with max_active; the others use both limits. MaxRetained *int `json:"max_retained,omitempty"` } @@ -87,7 +87,7 @@ func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { } // @Summary Retrieve sandbox deployment -// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. Provider metadata is what Core read when the selection was saved; this read does not call the Provider. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth diff --git a/services/core/internal/api/sandbox_node_configuration.go b/services/core/internal/api/sandbox_node_configuration.go index afab10889..43c67cd0c 100644 --- a/services/core/internal/api/sandbox_node_configuration.go +++ b/services/core/internal/api/sandbox_node_configuration.go @@ -9,7 +9,7 @@ import ( ) // @Summary Read the active configuration for node installation -// @Description Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. +// @Description Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose Provider credentials. Node files cannot override this specification. // @Tags Sandbox Node // @Produce json // @Security NodeEnrollmentAuth diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index 4a0a94418..eeb2b27e1 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -29,9 +29,20 @@ var resourceContract = []resourceRule{ } // ProviderProjection is one registered Provider in the generated projections. +// Setup reaches the TypeScript client only: the node installer needs no setup copy. type ProviderProjection struct { Mode DeploymentMode `json:"mode"` DeploymentPolicy + Setup *ProviderSetup `json:"setup,omitempty"` +} + +// ProviderSetup is a Provider's setup declaration with the configuration +// requirements the console needs beside it. +type ProviderSetup struct { + SetupDeclaration + Credential Requirement `json:"credential"` + PublicOrigin Requirement `json:"public_origin"` + Discovery bool `json:"discovery"` } // DeploymentContract generates the installer and TypeScript client projections @@ -59,14 +70,22 @@ func DeploymentContract(providers map[string]ProviderProjection) (python, typesc for field := range workspaceType.NumField() { workspaceFields = append(workspaceFields, strings.Split(workspaceType.Field(field).Tag.Get("json"), ",")[0]) } - raw, _ := json.Marshal(struct { - WorkspaceFields []string `json:"workspace_fields"` - Resources []resourceRule `json:"resources"` - Providers map[string]ProviderProjection `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{workspaceFields, resourceContract, providers, minimumDiskMiB}) - return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT", - "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + string(raw) + " as const;\n" + render := func(providers map[string]ProviderProjection) string { + raw, _ := json.Marshal(struct { + WorkspaceFields []string `json:"workspace_fields"` + Resources []resourceRule `json:"resources"` + Providers map[string]ProviderProjection `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{workspaceFields, resourceContract, providers, minimumDiskMiB}) + return string(raw) + } + installer := make(map[string]ProviderProjection, len(providers)) + for kind, p := range providers { + p.Setup = nil + installer[kind] = p + } + return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", render(installer)) + ")\n# END GENERATED DEPLOYMENT CONTRACT", + "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + render(providers) + " as const;\n" } func resourceNames() []string { var names []string diff --git a/services/core/internal/sandbox/docker/provider.go b/services/core/internal/sandbox/docker/provider.go index b56906eb5..f97c8070e 100644 --- a/services/core/internal/sandbox/docker/provider.go +++ b/services/core/internal/sandbox/docker/provider.go @@ -39,7 +39,7 @@ func New(c *client.Client, config Config) (*Provider, error) { return nil, sandbox.ErrInvalid } if config.Resources != nil { - if ValidateResources(*config.Resources) != nil { + if config.Resources.ValidatePolicy("docker", Policy()) != nil { return nil, sandbox.ErrInvalid } resources := *config.Resources diff --git a/services/core/internal/sandbox/docker/selection.go b/services/core/internal/sandbox/docker/selection.go index 80c83f758..12a29c724 100644 --- a/services/core/internal/sandbox/docker/selection.go +++ b/services/core/internal/sandbox/docker/selection.go @@ -8,7 +8,17 @@ func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} } -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("docker", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { - return s.ValidatePolicy("docker", Policy()) +// Setup declares how the console connects Docker nodes. +func Setup() sandbox.SetupDeclaration { + return sandbox.SetupDeclaration{ + Label: "Docker", + Summary: sandbox.Text{ + EN: "Runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.", + ZHCN: "每个沙箱是一个共用主机内核的容器:能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。", + }, + Cautions: []sandbox.Text{ + {EN: "Containers share the host's kernel, so a container escape reaches the host.", ZHCN: "容器共用主机内核,一旦容器逃逸就能触及主机。"}, + {EN: "The node's service account joins the docker group, which is equivalent to root on that host.", ZHCN: "节点的服务账号会加入 docker 组,这在那台主机上等同于 root 权限。"}, + }, + } } diff --git a/services/core/internal/sandbox/e2b/configuration.go b/services/core/internal/sandbox/e2b/configuration.go index 5a00ed53e..5e7865a5f 100644 --- a/services/core/internal/sandbox/e2b/configuration.go +++ b/services/core/internal/sandbox/e2b/configuration.go @@ -37,7 +37,7 @@ func (ConfigurationAdapter) Requirements() sandbox.ConfigurationRequirements { } func (ConfigurationAdapter) DecodeInput(public, secret json.RawMessage) (sandbox.Configuration, error) { var p publicConfiguration - if err := sandbox.DecodeConfigurationObject(public, &p, "template", "api_url", "domain"); err != nil { + if err := sandbox.DecodeConfigurationObject(public, &p, fieldNames(false)...); err != nil { return nil, err } c := &DeploymentConfiguration{Template: p.Template, APIURL: p.APIURL, Domain: p.Domain} @@ -45,7 +45,7 @@ func (ConfigurationAdapter) DecodeInput(public, secret json.RawMessage) (sandbox var credential struct { APIKey string `json:"api_key"` } - if err := sandbox.DecodeConfigurationObject(secret, &credential, "api_key"); err != nil { + if err := sandbox.DecodeConfigurationObject(secret, &credential, fieldNames(true)...); err != nil { return nil, err } if credential.APIKey == "" { @@ -81,7 +81,7 @@ func (ConfigurationAdapter) Encode(value sandbox.Configuration) (sandbox.Configu func (ConfigurationAdapter) Decode(record sandbox.ConfigurationRecord) (sandbox.Configuration, error) { var p publicConfiguration var m buildMetadata - if sandbox.DecodeConfigurationObject(record.Public, &p, "template", "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(record.Metadata, &m, "template_build") != nil { + if sandbox.DecodeConfigurationObject(record.Public, &p, fieldNames(false)...) != nil || sandbox.DecodeConfigurationObject(record.Metadata, &m, "template_build") != nil { return nil, sandbox.ErrInvalid } c := &DeploymentConfiguration{Template: p.Template, APIURL: p.APIURL, Domain: p.Domain, APIKey: string(record.Secret)} diff --git a/services/core/internal/sandbox/e2b/configuration_discovery.go b/services/core/internal/sandbox/e2b/configuration_discovery.go index e3902e66c..7c24bd9ba 100644 --- a/services/core/internal/sandbox/e2b/configuration_discovery.go +++ b/services/core/internal/sandbox/e2b/configuration_discovery.go @@ -2,14 +2,15 @@ package e2b import ( "context" - "encoding/json" "errors" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (json.RawMessage, error) { +// DiscoverConfiguration lists templates for the declared template field, then +// the ready builds of the template chosen at the first level. +func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { var connection struct { APIURL string `json:"api_url"` Domain string `json:"domain"` @@ -17,31 +18,37 @@ func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input san var credential struct { APIKey string `json:"api_key"` } - var query struct { - Template string `json:"template,omitempty"` - } - if sandbox.DecodeConfigurationObject(input.Configuration, &connection, "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(input.Credential, &credential, "api_key") != nil || sandbox.DecodeConfigurationObject(input.Query, &query, "template") != nil { - return nil, sandbox.ErrInvalid + path := input.Query.Path + if sandbox.DecodeConfigurationObject(input.Configuration, &connection, "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(input.Credential, &credential, "api_key") != nil || len(path) > 1 { + return sandbox.ConfigurationOptions{}, sandbox.ErrInvalid } binary, _, err := InstalledPaths(paths) if err != nil { - return nil, sandbox.ErrConfigurationUnconfirmed + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationUnconfirmed } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - out, err := Discover(ctx, &ProcessCaller{}, binary, credential.APIKey, connection.APIURL, connection.Domain, query.Template) + template := "" + if len(path) == 1 { + template = path[0] + } + out, err := Discover(ctx, &ProcessCaller{}, binary, credential.APIKey, connection.APIURL, connection.Domain, template) if err != nil { if errors.Is(err, sandbox.ErrInvalid) { - return nil, sandbox.ErrConfigurationSelection + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationSelection + } + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationUnconfirmed + } + options := []sandbox.ConfigurationOption{} + for _, t := range out.Templates { + label := t.ID + if len(t.Names) > 0 { + label = t.Names[0] + " · " + t.ID } - return nil, sandbox.ErrConfigurationUnconfirmed + options = append(options, sandbox.ConfigurationOption{Value: t.ID, Label: label}) } - if query.Template == "" { - return json.Marshal(struct { - Templates []TemplateSummary `json:"templates"` - }{out.Templates}) + for _, b := range out.Builds { + options = append(options, sandbox.ConfigurationOption{Value: template + ":" + b.ID, Label: b.ID, Resources: &sandbox.Resources{CPUs: b.CPUs, MemoryMiB: b.MemoryMiB}, Complete: true}) } - return json.Marshal(struct { - Builds []ReadyBuild `json:"builds"` - }{out.Builds}) + return sandbox.ConfigurationOptions{Options: options}, nil } diff --git a/services/core/internal/sandbox/e2b/deployment.go b/services/core/internal/sandbox/e2b/deployment.go index 8fcd9c285..40d2ba04e 100644 --- a/services/core/internal/sandbox/e2b/deployment.go +++ b/services/core/internal/sandbox/e2b/deployment.go @@ -15,8 +15,40 @@ func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{} } -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { return s.ValidatePolicy("e2b", Policy()) } +// Setup declares how the console connects E2B and compatible services. The +// adapter reads its configuration and credential field names from here. +func Setup() sandbox.SetupDeclaration { + return sandbox.SetupDeclaration{ + Label: "E2B", + Summary: sandbox.Text{ + EN: "Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.", + ZHCN: "沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。", + }, + Fields: []sandbox.SetupField{ + {Name: "api_key", Secret: true, Label: sandbox.Text{EN: "API key", ZHCN: "API 密钥"}}, + {Name: "template", Discovered: true, Label: sandbox.Text{EN: "Template build", ZHCN: "模板构建"}, + Help: sandbox.Text{EN: "Core validates the exact ready build again when you save.", ZHCN: "保存时 Core 会再次校验这个已就绪的构建。"}}, + {Name: "api_url", Optional: true, Label: sandbox.Text{EN: "Sandbox API URL", ZHCN: "沙箱 API 地址"}, + Help: sandbox.Text{EN: "The selected service supplies a default. You can edit it for a compatible endpoint.", ZHCN: "所选服务会提供默认值,兼容的端点可以自行修改。"}}, + {Name: "domain", Optional: true, Label: sandbox.Text{EN: "Sandbox data-plane domain", ZHCN: "沙箱数据面域名"}}, + }, + Presets: []sandbox.SetupPreset{ + {Label: "SandBase Sandbox", Values: map[string]string{"api_url": "https://sandbox.sandbase.ai", "domain": "sandbox.sandbase.ai"}, CredentialURL: "https://www.sandbase.ai/console/keys"}, + {Label: "E2B", Values: map[string]string{"api_url": "https://api.e2b.app", "domain": "e2b.app"}, CredentialURL: "https://e2b.dev/dashboard?tab=api-keys"}, + }, + } +} + +// fieldNames lists the declared secret or non-secret field names. +func fieldNames(secret bool) []string { + var names []string + for _, f := range Setup().Fields { + if f.Secret == secret { + names = append(names, f.Name) + } + } + return names +} func ValidateConfiguration(c *DeploymentConfiguration) error { if c == nil || c.APIKey == "" || len(c.APIKey) > 4096 || strings.IndexFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { @@ -39,13 +71,13 @@ func ValidateConfiguration(c *DeploymentConfiguration) error { } // NormalizeSelection accepts omitted candidate resources only until live build -// discovery. Persistence requires ValidateSpecification after preparation. +// discovery. Persistence requires a valid specification after preparation. func NormalizeSelection(s sandbox.Selection) (sandbox.Selection, error) { if s.Workspace != nil { return s, sandbox.ErrInvalid } if s.Resources != (sandbox.Resources{}) { - if err := ValidateSpecification(s.DeploymentSpec); err != nil { + if err := s.DeploymentSpec.ValidatePolicy("e2b", Policy()); err != nil { return s, err } } @@ -92,7 +124,7 @@ func (ConfigurationAdapter) DiscoverSelection(ctx context.Context, c sandbox.Dir recorded.RootDiskMiB = &disk } s := WithTemplateBuild(c.Selection, recorded) - if err := ValidateSpecification(s.DeploymentSpec); err != nil { + if err := s.DeploymentSpec.ValidatePolicy("e2b", Policy()); err != nil { return sandbox.Selection{}, &sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"} } return s, nil diff --git a/services/core/internal/sandbox/e2b/installed_paths_test.go b/services/core/internal/sandbox/e2b/installed_paths_test.go index 5012e0d97..41635e915 100644 --- a/services/core/internal/sandbox/e2b/installed_paths_test.go +++ b/services/core/internal/sandbox/e2b/installed_paths_test.go @@ -36,8 +36,8 @@ func TestConfigurationDiscoveryUsesSuppliedProcessPaths(t *testing.T) { } input := sandbox.ConfigurationDiscoveryInput{Credential: json.RawMessage(`{"api_key":"synthetic-key"}`)} result, err := (ConfigurationAdapter{}).DiscoverConfiguration(t.Context(), input, paths) - if err != nil || string(result) != `{"templates":[]}` { - t.Fatalf("configured discovery: %s %v", result, err) + if err != nil || len(result.Options) != 0 { + t.Fatalf("configured discovery: %v %v", result, err) } if _, err := (ConfigurationAdapter{}).DiscoverConfiguration(t.Context(), input, sandbox.ProcessPaths{}); !errors.Is(err, sandbox.ErrConfigurationUnconfirmed) { t.Fatalf("missing roots: %v", err) diff --git a/services/core/internal/sandbox/e2b/provider.go b/services/core/internal/sandbox/e2b/provider.go index 1b77b990c..e420a406c 100644 --- a/services/core/internal/sandbox/e2b/provider.go +++ b/services/core/internal/sandbox/e2b/provider.go @@ -117,7 +117,7 @@ func validReference(r sandbox.Reference) bool { return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) } func (c Config) Validate() error { - if c.Resources != nil && ValidateResources(*c.Resources) != nil { + if c.Resources != nil && c.Resources.ValidatePolicy("e2b", Policy()) != nil { return sandbox.ErrInvalid } if !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 { diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go index bd471f9d3..fbdea40b6 100644 --- a/services/core/internal/sandbox/microsandbox/deployment.go +++ b/services/core/internal/sandbox/microsandbox/deployment.go @@ -10,7 +10,14 @@ func Policy() sandbox.DeploymentPolicy { DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192}} } -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { - return s.ValidatePolicy("microsandbox", Policy()) +// Setup declares how the console connects microsandbox nodes. +func Setup() sandbox.SetupDeclaration { + return sandbox.SetupDeclaration{ + Label: "microsandbox", + Recommended: true, + Summary: sandbox.Text{ + EN: "Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.", + ZHCN: "每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。", + }, + } } diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go index 20f4a9edf..4357b136a 100644 --- a/services/core/internal/sandbox/providers/config.go +++ b/services/core/internal/sandbox/providers/config.go @@ -48,7 +48,7 @@ func (r *Registry) Build(config sandbox.NodeConfig, options sandbox.LocalOptions if config.Specification.Workspace != nil && options.Workspace == nil { return nil, closeProvider, sandbox.ErrInvalid } - if err := adapter.ValidateSpecification(config.Specification); err != nil { + if err := config.Specification.ValidatePolicy(config.Provider, adapter.Policy); err != nil { return nil, closeProvider, err } id, err := uuid.Parse(config.InstallationID) diff --git a/services/core/internal/sandbox/providers/configuration.go b/services/core/internal/sandbox/providers/configuration.go index 7658c0668..fd8b08c99 100644 --- a/services/core/internal/sandbox/providers/configuration.go +++ b/services/core/internal/sandbox/providers/configuration.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -100,17 +101,20 @@ func (r *Registry) WithCredential(owner, candidate sandbox.Selection) (sandbox.S owner.Configuration, e = a.Configuration.WithCredential(owner.Configuration, candidate.Configuration) return owner, e } -func (r *Registry) DiscoverConfiguration(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (json.RawMessage, error) { +func (r *Registry) DiscoverConfiguration(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { a, err := r.Lookup(kind) if err != nil { - return nil, err + return sandbox.ConfigurationOptions{}, err } if err := a.Configuration.Requirements().Discovery.Check("DiscoverConfiguration"); err != nil { - return nil, err + return sandbox.ConfigurationOptions{}, err + } + if !slices.ContainsFunc(a.Setup.Fields, func(f sandbox.SetupField) bool { return f.Discovered && f.Name == input.Query.Field }) { + return sandbox.ConfigurationOptions{}, sandbox.ErrInvalid } result, err := a.Configuration.DiscoverConfiguration(ctx, input, paths) if err != nil { - return nil, declaredResult("DiscoverConfiguration", err) + return sandbox.ConfigurationOptions{}, declaredResult("DiscoverConfiguration", err) } return result, nil } @@ -158,7 +162,8 @@ func (nodeConfiguration) HasCredential() bool { return false } func (nodeConfiguration) ReplacesCredential() bool { return false } type nodeConfigurationAdapter struct { - validate func(sandbox.DeploymentSpec) error + kind string + policy sandbox.DeploymentPolicy } func (nodeConfigurationAdapter) Requirements() sandbox.ConfigurationRequirements { @@ -192,7 +197,7 @@ func (a nodeConfigurationAdapter) Normalize(s sandbox.Selection) (sandbox.Select return s, err } s.Configuration = nodeConfiguration{} - return s, a.validate(s.DeploymentSpec) + return s, s.ValidatePolicy(a.kind, a.policy) } func (a nodeConfigurationAdapter) ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { return a.Normalize(next) @@ -209,8 +214,8 @@ func (a nodeConfigurationAdapter) Equal(x, y sandbox.Configuration) (bool, error } return true, nil } -func (nodeConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { - return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} +func (nodeConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { + return sandbox.ConfigurationOptions{}, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} } func (nodeConfigurationAdapter) DiscoverSelection(context.Context, sandbox.DirectConfig) (sandbox.Selection, error) { return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "node_configuration_has_no_catalog"} diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index 1f1f095f6..037daac32 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -74,8 +74,8 @@ func (a regionalCodec) Equal(x, y sandbox.Configuration) (bool, error) { _, err = a.Encode(y) return x == y, err } -func (regionalCodec) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { - return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} +func (regionalCodec) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { + return sandbox.ConfigurationOptions{}, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} } func (regionalCodec) DiscoverSelection(context.Context, sandbox.DirectConfig) (sandbox.Selection, error) { return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "node_configuration_has_no_catalog"} diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 86722aa1c..017965442 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -9,8 +9,9 @@ import ( ) // ValidateRegistration checks wiring before configuration parsing or construction. -// Only configuration requirements and operation declarations are read, and the -// resource validator only for a declared default size, after every other check. +// Only configuration requirements, the setup declaration and operation +// declarations are read, and the policy only for a declared default size, after +// every other check. func ValidateRegistration(a Adapter) error { invalid := func(field string) error { return fmt.Errorf("%w: invalid registration %s", providercontract.ErrContract, field) @@ -33,15 +34,12 @@ func ValidateRegistration(a Adapter) error { default: return invalid("mode") } - if a.ValidateSpecification == nil { - return invalid("specification validator") - } - if a.ValidateResources == nil { - return invalid("resource validator") - } if err := validateConfigurationAdapter(a.Configuration); err != nil { return err } + if a.Setup.Validate(a.Configuration.Requirements()) != nil { + return invalid("setup declaration") + } if a.Policy.Workspace != nil && a.Policy.Workspace.Attachment != workspacefs.AttachmentHostDirectory { return invalid("workspace attachment requirements") } @@ -57,7 +55,7 @@ func ValidateRegistration(a Adapter) error { if operations["Initial"].State == providercontract.Supported && a.Mode != sandbox.DeploymentNodes { return invalid("checkpoint support outside nodes mode") } - if a.Policy.DefaultResources != nil && a.ValidateResources(*a.Policy.DefaultResources) != nil { + if a.Policy.DefaultResources != nil && a.Policy.DefaultResources.ValidatePolicy("", a.Policy) != nil { return invalid("default resources") } return nil diff --git a/services/core/internal/sandbox/providers/registration_configuration_test.go b/services/core/internal/sandbox/providers/registration_configuration_test.go index 4a456d31e..61677895a 100644 --- a/services/core/internal/sandbox/providers/registration_configuration_test.go +++ b/services/core/internal/sandbox/providers/registration_configuration_test.go @@ -2,7 +2,6 @@ package providers import ( "context" - "encoding/json" "errors" "reflect" "testing" @@ -82,6 +81,9 @@ func TestConfigurationRequirementsDoNotInventDependencies(t *testing.T) { requirements := a.Configuration.Requirements() requirements.Credential, requirements.PublicOrigin = credential, public a.Configuration = registrationConfiguration{requirements: requirements} + if credential == sandbox.Required { + a.Setup.Fields = []sandbox.SetupField{{Name: "key", Secret: true, Label: sandbox.Text{EN: "Key", ZHCN: "密钥"}}} + } if err := ValidateRegistration(a); err != nil { t.Fatal(err) } @@ -122,9 +124,9 @@ func TestUnsupportedSetupOperationsMatchAuthoredReasons(t *testing.T) { for kind, a := range registry.adapters { requirements := a.Configuration.Requirements() direct := sandbox.DirectConfig{Selection: sandbox.Selection{Provider: kind}} - discover := func(read func() (json.RawMessage, error)) func() error { + discover := func(read func() (sandbox.ConfigurationOptions, error)) func() error { return func() error { - if result, err := read(); result != nil { + if result, err := read(); result.Options != nil { return errors.New("fabricated catalog") } else { return err @@ -137,10 +139,10 @@ func TestUnsupportedSetupOperationsMatchAuthoredReasons(t *testing.T) { calls []func() error }{ {"DiscoverConfiguration", requirements.Discovery, []func() error{ - discover(func() (json.RawMessage, error) { + discover(func() (sandbox.ConfigurationOptions, error) { return a.Configuration.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}) }), - discover(func() (json.RawMessage, error) { + discover(func() (sandbox.ConfigurationOptions, error) { return registry.DiscoverConfiguration(t.Context(), kind, sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}) }), }}, @@ -170,8 +172,8 @@ func TestUnsupportedSetupOperationsMatchAuthoredReasons(t *testing.T) { // reports each as Unsupported. type contradictingConfiguration struct{ registrationConfiguration } -func (contradictingConfiguration) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { - return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "not_ready"} +func (contradictingConfiguration) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { + return sandbox.ConfigurationOptions{}, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "not_ready"} } func (contradictingConfiguration) DiscoverSelection(context.Context, sandbox.DirectConfig) (sandbox.Selection, error) { return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "not_ready"} diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 455670bb4..9a9a99659 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -35,8 +35,7 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { return nil, nil } }}, - {"missing specification validator", func(a *Adapter) { a.ValidateSpecification = nil }}, - {"missing resource validator", func(a *Adapter) { a.ValidateResources = nil }}, + {"invalid setup declaration", func(a *Adapter) { a.Setup.Summary.ZHCN = "" }}, {"missing configuration", func(a *Adapter) { a.Configuration = nil }}, {"typed nil configuration", func(a *Adapter) { var c *registrationConfiguration; a.Configuration = c }}, {"missing configuration requirement", func(a *Adapter) { a.Configuration = registrationConfiguration{} }}, @@ -60,8 +59,6 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { t.Fatal("called local constructor") return nil, nil } - a.ValidateSpecification = func(sandbox.DeploymentSpec) error { t.Fatal("called specification validator"); return nil } - a.ValidateResources = func(sandbox.Resources) error { t.Fatal("called resource validator"); return nil } a.Configuration = registrationConfiguration{requirements: a.Configuration.Requirements()} tc.mutate(&a) registry.adapters[kind] = a @@ -174,3 +171,26 @@ func TestRegistrationCheckpointRequiresNodes(t *testing.T) { t.Fatal("direct checkpoint registration accepted", err) } } + +func TestSetupDeclarationMustMatchRequirements(t *testing.T) { + registry := Builtin() + for name, change := range map[string]func(*Adapter){ + "secret field without credential": func(a *Adapter) { + a.Setup.Fields = []sandbox.SetupField{{Name: "key", Secret: true, Label: sandbox.Text{EN: "Key", ZHCN: "密钥"}}} + }, + "missing locale": func(a *Adapter) { a.Setup.Summary.ZHCN = "" }, + "preset value for unknown field": func(a *Adapter) { + a.Setup.Presets = []sandbox.SetupPreset{{Label: "x", Values: map[string]string{"unknown": "x"}}} + }, + } { + a := registry.adapters["docker"] + change(&a) + if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) { + t.Fatalf("%s: %v", name, err) + } + } + registry.adapters["second"] = registry.adapters["microsandbox"] + if _, _, err := registry.DeploymentContract(); !errors.Is(err, providercontract.ErrContract) { + t.Fatalf("two recommended providers in one mode: %v", err) + } +} diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index a39c0429f..a9c0598ba 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -16,15 +16,14 @@ import ( // Adapter describes configuration and transport independently of compute operations. // Native operation support comes from the adapter-owned complete declaration. type Adapter struct { - NodeArtifacts []providerassets.Artifact - Policy sandbox.DeploymentPolicy - Configuration sandbox.ConfigurationAdapter - BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) - BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) - Mode sandbox.DeploymentMode - Operations func() providercontract.Operations - ValidateSpecification func(sandbox.DeploymentSpec) error - ValidateResources func(sandbox.Resources) error + NodeArtifacts []providerassets.Artifact + Policy sandbox.DeploymentPolicy + Setup sandbox.SetupDeclaration + Configuration sandbox.ConfigurationAdapter + BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) + BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) + Mode sandbox.DeploymentMode + Operations func() providercontract.Operations } // Registry holds the registered adapters. Core and the node program each build @@ -39,20 +38,17 @@ func Builtin() *Registry { return &Registry{adapters: map[string]Adapter{ "docker": { NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, - Policy: docker.Policy(), Operations: docker.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: docker.BuildNode, - ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, - Configuration: nodeConfigurationAdapter{docker.ValidateSpecification}, + Policy: docker.Policy(), Setup: docker.Setup(), Operations: docker.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: docker.BuildNode, + Configuration: nodeConfigurationAdapter{"docker", docker.Policy()}, }, "microsandbox": { NodeArtifacts: append([]providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, microsandbox.NodeArtifacts...), - Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: microsandbox.BuildNode, - ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, - Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, + Policy: microsandbox.Policy(), Setup: microsandbox.Setup(), Operations: microsandbox.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: microsandbox.BuildNode, + Configuration: nodeConfigurationAdapter{"microsandbox", microsandbox.Policy()}, }, "e2b": { - Policy: e2b.Policy(), Operations: e2b.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: e2b.BuildDirect, - Configuration: e2b.ConfigurationAdapter{}, - ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, + Policy: e2b.Policy(), Setup: e2b.Setup(), Operations: e2b.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: e2b.BuildDirect, + Configuration: e2b.ConfigurationAdapter{}, }, }} } @@ -114,14 +110,14 @@ func (r *Registry) ValidateSpecification(kind string, s sandbox.DeploymentSpec) if e != nil { return e } - return a.ValidateSpecification(s) + return s.ValidatePolicy(kind, a.Policy) } func (r *Registry) ValidateResources(kind string, s sandbox.Resources) error { a, e := r.Lookup(kind) if e != nil { return e } - return a.ValidateResources(s) + return s.ValidatePolicy(kind, a.Policy) } // Describe derives the description once for both preview and persistence. @@ -138,18 +134,37 @@ func (r *Registry) Describe(kind, installation string) (sandbox.Description, err return sandbox.Description{Mode: a.Mode, BackendFingerprint: sandbox.BackendFingerprint(kind, namespace+":"+installation)}, nil } -// DeploymentContract projects the registered modes and policies into the node -// installer and the TypeScript client; no second provider list exists in -// another language. +// DeploymentContract projects the registered modes, policies and setup +// declarations into the node installer and the TypeScript client; no second +// provider list exists in another language. At most one Provider per mode is +// recommended. func (r *Registry) DeploymentContract() (python, typescript string, err error) { providers := make(map[string]sandbox.ProviderProjection, len(r.adapters)) + recommended := map[sandbox.DeploymentMode]bool{} for kind := range r.adapters { a, err := r.Lookup(kind) if err != nil { return "", "", err } - providers[kind] = sandbox.ProviderProjection{Mode: a.Mode, DeploymentPolicy: a.Policy} + if a.Setup.Recommended { + if recommended[a.Mode] { + return "", "", fmt.Errorf("%w: several recommended %s providers", providercontract.ErrContract, a.Mode) + } + recommended[a.Mode] = true + } + requirements := a.Configuration.Requirements() + setup := a.Setup + setup.Cautions, setup.Fields, setup.Presets = nonNil(setup.Cautions), nonNil(setup.Fields), nonNil(setup.Presets) + providers[kind] = sandbox.ProviderProjection{Mode: a.Mode, DeploymentPolicy: a.Policy, Setup: &sandbox.ProviderSetup{SetupDeclaration: setup, + Credential: requirements.Credential, PublicOrigin: requirements.PublicOrigin, Discovery: requirements.Discovery.State == providercontract.Supported}} } python, typescript = sandbox.DeploymentContract(providers) return python, typescript, nil } + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 571ce6bb6..2e0a563a4 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -309,6 +309,77 @@ type ConfigurationRequirements struct { CredentialVerification providercontract.Support } +// Text is operator-facing copy in every supported locale. +type Text struct { + EN string `json:"en"` + ZHCN string `json:"zh-CN"` +} + +func (t Text) valid() bool { return t.EN != "" && t.ZHCN != "" } + +// SetupField is one value an operator enters to connect the Provider. A Secret +// field is submitted as the credential, any other as configuration. An Optional +// field may stay empty and the Provider supplies its default. A Discovered field +// takes its value from DiscoverConfiguration. Help may be empty in both locales. +type SetupField struct { + Name string `json:"name"` + Secret bool `json:"secret"` + Optional bool `json:"optional"` + Discovered bool `json:"discovered"` + Label Text `json:"label"` + Help Text `json:"help"` +} + +// SetupPreset is a named set of field values, such as one hosted service. +// CredentialURL, when set, is where the operator creates the credential. +type SetupPreset struct { + Label string `json:"label"` + Values map[string]string `json:"values"` + CredentialURL string `json:"credential_url,omitempty"` +} + +// SetupDeclaration is everything the console needs to set up one Provider. +// Label is the Provider's proper noun. Cautions are risks an operator confirms +// before choosing it. Recommended marks the default among Providers of one +// deployment mode, and at most one does. +type SetupDeclaration struct { + Label string `json:"label"` + Summary Text `json:"summary"` + Cautions []Text `json:"cautions"` + Recommended bool `json:"recommended"` + Fields []SetupField `json:"fields"` + Presets []SetupPreset `json:"presets"` +} + +// Validate checks the declaration against the Provider's configuration +// requirements: secret fields exist exactly when a credential is required, +// discovered fields exactly when discovery is supported, and every text has +// both locales. +func (d SetupDeclaration) Validate(r ConfigurationRequirements) error { + secret, discovered := false, false + ok := d.Label != "" && d.Summary.valid() + names := map[string]bool{} + for _, f := range d.Fields { + ok = ok && f.Name != "" && !names[f.Name] && f.Label.valid() && (f.Help == Text{} || f.Help.valid()) + names[f.Name] = true + secret, discovered = secret || f.Secret, discovered || f.Discovered + ok = ok && !(f.Secret && (f.Optional || f.Discovered)) + } + for _, c := range d.Cautions { + ok = ok && c.valid() + } + for _, p := range d.Presets { + ok = ok && p.Label != "" + for name := range p.Values { + ok = ok && names[name] + } + } + if !ok || secret != (r.Credential == Required) || discovered != (r.Discovery.State == providercontract.Supported) { + return ErrInvalid + } + return nil +} + // DeploymentPolicy is the deployment declaration. Workspace, when present, means // the Provider runs every Environment on external storage; absent means its own // disk. Disk declares independent disk limits. DefaultResources is the size setup @@ -395,12 +466,33 @@ type Built struct { Quiescent func() bool } -// ConfigurationDiscoveryInput is a transient read-only request. Query is typed -// and validated by the adapter; it cannot select a compute mutation. +// ConfigurationDiscoveryQuery selects the options of one Discovered field. Path +// holds the values chosen at earlier levels, empty for the first level. +type ConfigurationDiscoveryQuery struct { + Field string `json:"field"` + Path []string `json:"path"` +} + +// ConfigurationDiscoveryInput is a transient read-only request. The adapter +// validates Configuration and Credential; Query cannot select a compute mutation. type ConfigurationDiscoveryInput struct { - Configuration json.RawMessage `json:"configuration" swaggertype:"object"` - Credential json.RawMessage `json:"credential" swaggertype:"object"` - Query json.RawMessage `json:"query" swaggertype:"object"` + Configuration json.RawMessage `json:"configuration" swaggertype:"object"` + Credential json.RawMessage `json:"credential" swaggertype:"object"` + Query ConfigurationDiscoveryQuery `json:"query"` +} + +// ConfigurationOption is one choice for a Discovered field. A Complete option's +// Value is the field's value; any other is a Path element for the next level. +type ConfigurationOption struct { + Value string `json:"value" binding:"required"` + Label string `json:"label" binding:"required"` + Resources *Resources `json:"resources,omitempty"` + Complete bool `json:"complete" binding:"required"` +} + +// ConfigurationOptions is the result of one discovery level. +type ConfigurationOptions struct { + Options []ConfigurationOption `json:"options" binding:"required"` } // ConfigurationAdapter owns all interpretation of provider configuration. @@ -422,7 +514,7 @@ type ConfigurationAdapter interface { Equal(a, b Configuration) (bool, error) // DiscoverConfiguration lists the native catalog a credential can use, // without a saved deployment. - DiscoverConfiguration(context.Context, ConfigurationDiscoveryInput, ProcessPaths) (json.RawMessage, error) + DiscoverConfiguration(context.Context, ConfigurationDiscoveryInput, ProcessPaths) (ConfigurationOptions, error) // DiscoverSelection resolves a candidate's omitted native values before // commit. Loading retained ownership never calls it. DiscoverSelection(context.Context, DirectConfig) (Selection, error) diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index e815d9ff6..8d26a015a 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -2,7 +2,6 @@ package integration import ( "context" - "encoding/json" "net/http" "testing" @@ -252,9 +251,9 @@ func (s strictStandIn) WriteEnvironmentFile(context.Context, sessions.Environmen return 0, nil } -func (s strictStandIn) DiscoverConfiguration(context.Context, string, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (s strictStandIn) DiscoverConfiguration(context.Context, string, sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { s.unexpected("DiscoverConfiguration") - return nil, nil + return sandbox.ConfigurationOptions{}, nil } func (s strictStandIn) InitializeSandboxDeployment(context.Context, sandbox.Selection) (deployment.View, error) { From 3236ec06bba4399bbf5a58b84c8fc758b6fb6782 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 23 Oct 2026 19:54:07 +0000 Subject: [PATCH 2/7] Drop remaining Provider names from fleet and metrics views --- apps/web/src/features/metrics/SandboxMetricsPage.tsx | 2 +- apps/web/src/features/overview/FleetOverview.tsx | 5 ++--- apps/web/src/features/overview/OverviewPage.tsx | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index a039dfd29..d63f84329 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -373,7 +373,7 @@ function RuntimeDialog({ row, showNode, onClose }: { row: HostedRuntimeRow | nul
{t("sandbox.status")}
{t("sandbox.configurationGeneration")}
{shown.deploymentGeneration ?? MISSING}
{t("sandbox.uptime")}
{formatDuration(shown.uptimeSeconds)}
- {/* Only E2B reports a sandbox's disk. */} + {/* Disk is shown only when the provider reports it. */} {observation.disk ?
{t("sandbox.disk")}
{formatBytes(observation.disk.usage_bytes)} / {formatBytes(observation.disk.limit_bytes)}
: null}
{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 ); })}
-