From f18fae4a3afca9ac9bde6f1923ab21089a5890c8 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 19:32:07 +0000 Subject: [PATCH] Read Provider declarations in Web --- apps/web/e2e/nodes.spec.ts | 6 +- apps/web/src/features/fleet/fleet-model.ts | 5 +- .../features/metrics/SandboxMetricsPage.tsx | 21 ++++--- .../overview/FleetOverview.render.test.tsx | 1 + .../src/features/overview/FleetOverview.tsx | 12 ++-- .../src/features/overview/OverviewPage.tsx | 5 +- .../features/overview/getting-started.test.ts | 2 +- .../src/features/overview/getting-started.ts | 12 ++-- .../features/sandbox/NodeCleanupDialog.tsx | 6 +- apps/web/src/features/sandbox/NodeDetail.tsx | 8 +-- .../src/features/sandbox/NodeEditDialog.tsx | 9 +-- .../src/features/sandbox/NodeEnrollment.tsx | 19 ++++--- .../sandbox/SandboxDeploymentPage.tsx | 6 +- .../sandbox/SandboxDeploymentSettings.tsx | 2 +- .../features/sandbox/SandboxManagerView.tsx | 11 ++-- .../features/sandbox/SandboxSetupWizard.tsx | 57 +++++++++++-------- .../src/features/sandbox/console-config.ts | 9 +-- .../sandbox/deployment-specification.test.ts | 21 +++---- .../sandbox/deployment-specification.ts | 11 ++-- .../features/sandbox/enrollment-command.ts | 4 +- .../src/features/sandbox/node-commands.tsx | 6 +- .../src/features/sandbox/node-enrollment.ts | 17 ++---- .../sandbox/sandbox-page-ownership.test.tsx | 2 +- .../features/sandbox/sandbox-queries.test.ts | 2 +- .../src/features/sandbox/sandbox-queries.ts | 2 +- .../src/features/sandbox/standard-sizes.json | 12 ---- .../src/features/sandbox/standard-sizes.md | 23 -------- apps/web/src/lib/locale-strings.ts | 5 +- contracts/agents-api/sandbox-deployment.md | 2 +- contracts/agents-api/zh/sandbox-deployment.md | 4 +- deploy/node/node_spec.py | 2 +- docs/configuration.md | 2 +- docs/getting-started/install-options.md | 2 +- docs/sandbox-provider.md | 10 ++-- docs/zh/configuration.md | 4 +- docs/zh/getting-started/install-options.md | 4 +- docs/zh/sandbox-provider.md | 12 ++-- .../agents-client/src/deployment-contract.ts | 2 +- packages/agents-client/src/sandbox-client.ts | 8 +-- .../core/cmd/specification-contract/main.go | 4 +- .../internal/sandbox/deployment_contract.go | 48 ++++++---------- .../core/internal/sandbox/docker/selection.go | 4 +- .../sandbox/microsandbox/deployment.go | 5 +- .../providers/deployment_contract_test.go | 4 +- .../sandbox/providers/registration.go | 6 +- .../sandbox/providers/registration_test.go | 14 ++++- .../internal/sandbox/providers/registry.go | 16 +++--- .../core/internal/sandbox/sandbox_provider.go | 11 ++++ 48 files changed, 220 insertions(+), 240 deletions(-) delete mode 100644 apps/web/src/features/sandbox/standard-sizes.json delete mode 100644 apps/web/src/features/sandbox/standard-sizes.md diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index f404a5de6..5f32831da 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -15,14 +15,12 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await openConsole(page, request, "nodes"); await page.getByRole("button", { name: "Add node" }).click(); const add = page.getByRole("dialog", { name: "Add node" }); - // What a Docker host needs for the default command, which installs the node with sudo. - await expect(add.getByText("Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)")).toBeVisible(); + // What a host needs for the default command, which installs the node with sudo. + await expect(add.getByText("What the sandbox backend needs on the host; the installer checks it and names anything missing")).toBeVisible(); await expect(add.getByText("SELinux is not enforcing")).toBeVisible(); await expect(add.getByText("In sudo mode, one Core per host: a host already running a sudo-mode node for another Core is refused.")).toBeVisible(); await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB; about 2 GB of disk for the Runtime image")).toBeVisible(); await expect(add.getByText("Reaches https://core.example.com, as do its sandboxes")).toBeVisible(); - await expect(add.getByText("oac-node joins the docker group, which is equivalent to root on this host.")).toBeVisible(); - await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0); // Node installation only offers a system service; there is no user-mode alternative. await expect(add.getByText("No sudo on this host?")).toHaveCount(0); await expect(add.getByLabel("One-time enrollment command without sudo", { exact: true })).toHaveCount(0); diff --git a/apps/web/src/features/fleet/fleet-model.ts b/apps/web/src/features/fleet/fleet-model.ts index 08abf927c..c35e58747 100644 --- a/apps/web/src/features/fleet/fleet-model.ts +++ b/apps/web/src/features/fleet/fleet-model.ts @@ -29,14 +29,15 @@ export interface CapacitySummary { maxRetained: number; reserved: number; cleanupPending: number; - /** Sandboxes held suspended (microsandbox only): placed, but not counted as active. */ + /** Sandboxes held suspended (where the Provider declares checkpoint support): placed, but not counted as active. */ suspended: number; } /** * Sandboxes a node holds suspended: Core counts every unreleased placement as * retained and only the ones not suspended as active, so the difference is - * what sleeps as a snapshot. Only microsandbox suspends; elsewhere it is 0. + * what sleeps as a snapshot. Only a Provider that declares checkpoint support + * suspends; elsewhere it is 0. */ export function suspendedSandboxes(node: SandboxNode): number { return Math.max(0, node.retained - node.active); diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index e3b70c6a8..66d59f109 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -108,10 +108,10 @@ export function SandboxMetricsPage() { const [openNode, setOpenNode] = useState(null); const [openRuntime, setOpenRuntime] = useState(null); const rows = useMemo(() => (runtimeState.load ? hostedRuntimeRows(runtimeState.load, "", fleet) : []), [fleet, runtimeState.load]); - // E2B runs sandboxes in its cloud: no machines, so no node table, node column or node dialog. - const cloud = fleet?.deployment.provider === "e2b"; - // Only microsandbox suspends sandboxes into snapshots; its nodes also show how many sleep. - const suspends = fleet?.deployment.provider === "microsandbox"; + // A direct Provider runs sandboxes in its cloud: no machines, so no node table, node column or node dialog. + const cloud = fleet?.deployment.mode === "direct"; + // Only a Provider that declares checkpoint support suspends sandboxes; its nodes also show how many sleep. + const suspends = Boolean(fleet?.deployment.suspension); return (
@@ -205,6 +205,7 @@ export function SandboxMetricsPage() { stale={fleetObservationStale(fleetState)} load={runtimeState.load} range={range} + suspends={suspends} onClose={() => setOpenNode(null)} /> row.observation.session_id === openRuntime) ?? null} showNode={!cloud} onClose={() => setOpenRuntime(null)} /> @@ -310,7 +311,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R {durable ? : history.isError ?

{t("sandbox.charts.historyFailed", { reason: history.error instanceof Error ? history.error.message : "" })}

: history.isFetched ?

{t("sandbox.charts.historyUnavailable")}

: null} - + ); } @@ -322,7 +323,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R {t("sandbox.runtimeSection")} {usage?.hosted ? ( - {t(fleet?.deployment.provider === "microsandbox" ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", { + {t(fleet?.deployment.suspension ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", { n: formatInteger(usage.hosted, locale), sleeping: formatInteger(usage.sleeping, locale), cpu: usage.cpuUsageCores === null ? MISSING : t("sandbox.cores", { value: formatCores(usage.cpuUsageCores, locale) }), @@ -448,8 +449,10 @@ function useLast(value: T | null): T | null { * A node in a dialog: the host figures it reports with each heartbeat, and * CPU and memory of the hosted sandboxes placed on it over the page's range. */ -function NodeDialog({ node, rows, load, range, stale, onClose }: { +function NodeDialog({ node, rows, load, range, stale, suspends, onClose }: { stale: boolean; + /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ + suspends: boolean; node: SandboxNode | null; rows: readonly HostedRuntimeRow[]; load: HostedRuntimeLoad | null; @@ -503,8 +506,8 @@ function NodeDialog({ node, rows, load, range, stale, onClose }: {
{t("sandbox.targetPreparation")}
{t("sandbox.servingGeneration")}
{shown.rollout.ready_generation ?? MISSING}{t("sandbox.servingGenerationHelp")}
{t("sandbox.slots")}
{formatInteger(shown.active, locale)} / {formatInteger(shown.max_active, locale)}
- {shown.provider === "microsandbox" ?
{t("sandbox.suspended")}
{formatInteger(suspendedSandboxes(shown), locale)}
: null} - {shown.provider === "microsandbox" ?
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
: null} + {suspends ?
{t("sandbox.suspended")}
{formatInteger(suspendedSandboxes(shown), locale)}
: null} + {suspends ?
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
: null}
{t("sandbox.cpus")}
{cpu}
{t("sandbox.memory")}
{memory}
{t("sandbox.freeDiskColumn")}
{online ? formatBytes(shown.available_disk_bytes) : MISSING}
diff --git a/apps/web/src/features/overview/FleetOverview.render.test.tsx b/apps/web/src/features/overview/FleetOverview.render.test.tsx index 2b363ee88..7604690bc 100644 --- a/apps/web/src/features/overview/FleetOverview.render.test.tsx +++ b/apps/web/src/features/overview/FleetOverview.render.test.tsx @@ -10,6 +10,7 @@ describe("fleet overview popovers", () => { const html = renderToStaticMarkup( void; coreLabel: string; coreTone: Tone; @@ -124,7 +126,7 @@ export function FleetOverview({ nodes, cloud, coreLabel, coreTone, stale, onOpen } > - + ); })} @@ -141,7 +143,7 @@ function Fact({ label, children }: { label: string; children: ReactNode }) { } /** A node at a glance: reachability (with the reason a degraded provider is not ready), sandbox slots and what the node has left. */ -function NodeGlance({ node, health, stale }: { node: SandboxNode; health: NodeHealth; stale: boolean }) { +function NodeGlance({ node, health, stale, suspends }: { node: SandboxNode; health: NodeHealth; stale: boolean; suspends: boolean }) { const { t, i18n } = useTranslation("overview"); const locale = i18n.resolvedLanguage; const now = Math.floor(Date.now() / 1000); @@ -160,7 +162,7 @@ function NodeGlance({ node, health, stale }: { node: SandboxNode; health: NodeHe {node.rollout.ready_generation ?? MISSING}{t("fleet.servingGenerationHelp")} {seen === null ? t("fleet.facts.never") : formatRelative(seen, now, locale)} {count(node.active)}/ {count(node.max_active)} - {node.provider === "microsandbox" ? {count(suspendedSandboxes(node))} : null} + {suspends ? {count(suspendedSandboxes(node))} : null} {node.cpu_count === null ? MISSING : t("fleet.facts.cores", { count: node.cpu_count })} {formatBytes(node.available_memory_bytes)} {formatBytes(node.available_disk_bytes)} diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index e0e366b71..0b8975260 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -206,7 +206,7 @@ export function OverviewPage() { help={t("kpi.slotsHelp")} value={capacity ? <>/ {formatInteger(capacity.maxActive, locale)} : MISSING} sub={capacity - ? fleet?.deployment.provider === "microsandbox" + ? fleet?.deployment.suspension ? t("tiles.nodesOnlineSuspended", { online: capacity.online, total: capacity.nodes, suspended: capacity.suspended }) : t("tiles.nodesOnline", { online: capacity.online, total: capacity.nodes }) : fleetDetail(fleetState, t)} @@ -313,7 +313,7 @@ function fleetDetail(state: FleetState, t: TFunction<"overview">): string { } function cloudHost(fleet: FleetSnapshot | null): CloudHost | null { - if (fleet?.deployment.provider !== "e2b") return 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 }; } @@ -345,6 +345,7 @@ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; co navigate("system", { id: "sandbox" })} coreLabel={t(`coreStatus.${core}`)} coreTone={coreTone[core]} diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 56a9990a7..0194079ae 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -54,7 +54,7 @@ describe("Getting started steps", () => { }); it("keeps local-only installations to do even with a ready node or cloud deployment", () => { for (const provider of ["docker", "e2b"] as const) { - const steps = gettingStartedSteps({ sandboxReset: false, fleet: fleet(deployment({ provider })), projects: [], sessions: 1, harnesses: [], localOnly: true }); + const steps = gettingStartedSteps({ sandboxReset: false, fleet: fleet(deployment({ provider, mode: provider === "e2b" ? "direct" : "nodes" })), projects: [], sessions: 1, harnesses: [], localOnly: true }); expect(steps.sandboxes).toMatchObject({ state: "todo", action: "nodes", cloud: provider === "e2b" }); } }); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 8a781e398..915595455 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -41,10 +41,10 @@ export function gettingStartedSteps(input: { const { sessions } = input; return { sandboxes: input.sandboxReset !== false - ? { state: input.sandboxReset === "failed" ? "unknown" : input.sandboxReset ? "todo" : null, action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.provider === "e2b" } + ? { state: input.sandboxReset === "failed" ? "unknown" : input.sandboxReset ? "todo" : null, action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.mode === "direct" } : input.localOnly === undefined || input.localOnly === "failed" ? { state: input.localOnly === "failed" ? "unknown" : null, action: "nodes", cloud: false } - : input.localOnly ? { state: "todo", action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.provider === "e2b" } : sandboxStep(input.fleet), + : input.localOnly ? { state: "todo", action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.mode === "direct" } : sandboxStep(input.fleet), model: modelStep(input.harnesses), key: keyStep(input.projects), session: { @@ -56,8 +56,8 @@ export function gettingStartedSteps(input: { /** * Own machines are ready once the deployment is saved and a node is online - * with its provider ready; E2B once the deployment is saved, since Core admits - * only a ready template build. Only a build Core reports as not ready leaves + * 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. */ @@ -65,10 +65,10 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { if (fleet.status !== "ready") { return { state: fleet.status === "failed" ? "unknown" : null, action: "nodes", cloud: false }; } - if (fleet.error) return { state: "unknown", action: "nodes", cloud: fleet.snapshot.deployment.provider === "e2b" }; + if (fleet.error) return { state: "unknown", action: "nodes", cloud: fleet.snapshot.deployment.mode === "direct" }; const { deployment, nodes } = fleet.snapshot; if (!deployment.provider) return { state: "todo", action: "setup", cloud: false }; - if (deployment.provider === "e2b") { + if (deployment.mode === "direct") { return { state: templateBuildStatus(deployment.metadata?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true }; } if (nodes.some(nodeServingReady)) return { state: "done", action: "nodes", cloud: false }; diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx index fcd1bbf38..a4de4b299 100644 --- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -12,7 +12,6 @@ export interface NodeCleanup { name: string; installationId: string; scriptDigest: string; - provider: string; /** The Core address the node enrolled with, when it is no longer the deployment's; else null. */ oldAddress: string | null; } @@ -23,7 +22,7 @@ export interface NodeCleanup { * 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. Nothing deletes sandboxes, volumes or - * images. Like Add node's, the command downloads from the installation's public + * images; 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 * dialog says so in place of the command. It never opens empty. @@ -49,8 +48,7 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle : cleanup ?

{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}

-

{join(t("It never deletes sandboxes, volumes or images."), - ...(cleanup.provider === "microsandbox" ? [t("It keeps microsandbox's image store and sandbox data, and prints how to remove them by hand.")] : []))}

+

{t("It never deletes sandboxes, volumes or images.")}

{cleanup.oldAddress !== null ?
{t("Old Core address gone?")}
diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index c5bad4f34..9483c0879 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -44,7 +44,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale coreUrl: string; targetGeneration?: number; stale: boolean; - /** The deployment's idle suspension policy; only microsandbox has one. */ + /** The deployment's idle suspension policy; null unless its Provider declares checkpoint support. */ suspension: SandboxDeployment["suspension"]; }) { const { t, i18n } = useTranslation("sandbox"); @@ -53,8 +53,8 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale const now = Math.floor(Date.now() / 1000); const own = allocations.filter((allocation) => allocation.node_id === node.id); const reporting = !stale && node.online; - // Only microsandbox suspends sandboxes into snapshots; Docker retains nothing. - const suspends = node.provider === "microsandbox"; + // Only a Provider that declares checkpoint support suspends sandboxes; the node shares the deployment's. + const suspends = suspension !== null; const state = nodeState(node, own, stale, coreUrl); // As in the list, an old address is the status to act on; the node's health would only distract. const diagnostic = stale || state === "old_address" ? "" : nodeDiagnostic(node); @@ -108,7 +108,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale {t("Configuration generation")} {t("Recorded state")} {t("Recorded compute")} - {/* Only microsandbox changes compute phase; under Docker it is always disabled. */} + {/* Only a Provider with a suspension policy changes compute phase; elsewhere it is always disabled. */} {suspension ? {t("In this state")}{t("How long the sandbox has been in its compute state. For a suspended one, the reclaim time is estimated from when it was suspended and the deployment's retention; Core reclaims it around then. Older allocations show a dash until their state next changes.")} : null} {t("Issue")} {t("Created")} diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 3e562e68e..0cfe46ec8 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -13,17 +13,19 @@ import { sandboxesThatFit } from "./deployment-specification"; /** * A node's name and sandbox limits (`PATCH /core/v1/sandbox/nodes/{id}`). Core - * takes all three together. Only microsandbox suspends sandboxes, so only it - * shows the retained limit; for Docker the saved one is kept, raised to at least + * takes all three together. Only a deployment whose Provider suspends sandboxes + * shows the retained limit; otherwise the saved one is kept, raised to at least * the active limit because Core requires it. Under the limit, the host's CPUs * and memory from the node's last heartbeat, each sandbox's size and how many * of those the host holds. */ -export function NodeEditDialog({ client, node, size, onClose, onSaved }: { +export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved }: { client: SandboxAdminClient; node: SandboxNode | null; /** Each sandbox's CPUs and memory, from the deployment. */ size: SandboxResources | null; + /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ + suspends: boolean; onClose: () => void; onSaved: () => void; }) { @@ -36,7 +38,6 @@ export function NodeEditDialog({ client, node, size, onClose, onSaved }: { const [retained, setRetained] = useState(String(node?.max_retained ?? 8)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const suspends = node?.provider === "microsandbox"; const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); const activeLimit = whole(active); const retainedLimit = suspends ? whole(retained) : Math.max(node?.max_retained ?? 0, activeLimit ?? 0); diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 8bcc30e87..9a1ef808a 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -10,7 +10,7 @@ import { formatBytes } from "../../lib/format"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { installationQuery } from "../../lib/installation"; import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; -import { sandboxRequestError } from "../../lib/sandbox-labels"; +import { sandboxProviderLabel, sandboxRequestError } from "../../lib/sandbox-labels"; import { checklistOpenFor, modelStep, nextStepAfterNode } from "../overview/getting-started"; import { harnessesQuery } from "../system/harness-queries"; import { nodeSourceUrl } from "./core-origin"; @@ -36,10 +36,11 @@ const DEFAULT_RETAINED = "8"; /** * Add node: the administrator sets the node's sandbox limits, then Core issues a * one-time enrollment command that approves them - * (`POST /core/v1/sandbox/enrollment-tokens`). Only microsandbox suspends - * sandboxes, so only it asks for a retained limit; Docker retains exactly the - * sandboxes it runs at once. The command downloads the installer from the - * installation's public URL, never the browser's address, and runs it with + * (`POST /core/v1/sandbox/enrollment-tokens`). Only a deployment with a + * suspension policy, which its Provider's checkpoint support declares, asks for + * a retained limit; otherwise a node retains exactly the sandboxes it runs at + * once. The command downloads the installer from the installation's public + * URL, never the browser's address, and runs it with * sudo (or directly as root), which installs the node as a system service. * The log hint names that system service. No command is issued until the installation * is read: one whose public URL other machines can't use (loopback, as @@ -91,8 +92,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // The deployment's core_url is the same address, but the installation is read again on each opening, so a fix shows at once. const publicUrl = installation.data ? nodeSourceUrl(installation.data) : null; const available = consoleConfig.node_installer; - const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null; - const backend = provider === "microsandbox" ? "microsandbox" : "Docker"; + const provider = deployment.mode === "nodes" && deployment.provider ? deployment.provider : null; + const backend = sandboxProviderLabel(deployment.provider, locale); // Nodes and their sandboxes reach Core at its public URL, so a loopback one serves no other machine; // and without the provider's node files the installer would fail on the host. Either way no command // is issued, nor before the installation is read: a failed read (an older Core, say) proves nothing. @@ -108,7 +109,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, ? { text: t("This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.", { provider: backend }) } : null; // Core takes whole numbers from 1 to a million, with the retained limit at least the active one. - const suspends = deployment.provider === "microsandbox"; + const suspends = deployment.suspension !== null; const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); const inRange = (limit: number | null): limit is number => limit !== null && limit >= 1 && limit <= 1_000_000; const activeLimit = whole(active); @@ -236,7 +237,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, size: size ? t("{{cpus}} CPU · {{memory}}", { cpus: size.cpus, memory: formatBytes(size.memory_mib * 2 ** 20) }) : "", }; const requirements = provider ? <> - + : null; const limitsForm = `${id}-limits`; const footer = !available || (!enrollment && blocker) ? undefined diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index 64c4d616a..6b071309a 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; -import type { InitializeSandboxDeployment, UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@oac/agents-client"; +import { deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxDeployment, type StartSandboxReset } from "@oac/agents-client"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -80,7 +80,7 @@ function DeploymentConfiguration() { return false; } async function initialize(input: InitializeSandboxDeployment) { - if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && input.provider !== "e2b" && !installation.data?.local_only) navigate("nodes", {}, "add-node"); + if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && deploymentContract.providers[input.provider].mode === "nodes" && !installation.data?.local_only) navigate("nodes", {}, "add-node"); } async function update(input: UpdateSandboxDeployment) { return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true, true); @@ -94,7 +94,7 @@ function DeploymentConfiguration() { return <> - {snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b" ? : null} + {snapshot?.deployment.mode === "nodes" ? : null} } />
diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 692432f6a..94e585299 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -43,7 +43,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset

{t("Deployment provider")}

{t("One provider serves this deployment. Reset it before choosing a different backend.")} - {deployment.provider === "e2b" ? ` ${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 E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""}
{spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} {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} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 00feda0eb..ace738101 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -111,7 +111,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const { deployment } = snapshot; setCleanup({ node: { name: target.name || target.id, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, - provider: deployment.provider, oldAddress: onOldAddress(target, deployment.core_url) ? target.core_url : null, + oldAddress: onOldAddress(target, deployment.core_url) ? target.core_url : null, }, open: true }); } } @@ -124,7 +124,9 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const nodesConfirmed = confirmed && compatible && !inventoryOlder && !query.isError && !snapshot?.nodesError; const nodes = snapshot?.nodes ?? []; const allocations = snapshot?.allocations ?? []; - const hostedNodes = Boolean(snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b"); + const hostedNodes = snapshot?.deployment.mode === "nodes"; + // Nodes share the deployment's Provider, which declares whether sandboxes suspend. + const suspends = Boolean(snapshot?.deployment.suspension); // Getting started asks for Add node on arrival. A request the first settled read cannot // serve (no own-machines deployment, active reset, a failed read) is dropped, so the // dialog never opens later on its own. @@ -198,6 +200,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig client={client} node={editTarget} size={snapshot ? sandboxSize(snapshot.deployment) : null} + suspends={suspends} onClose={() => setEditTarget(null)} onSaved={() => { const saved = editTarget; @@ -223,7 +226,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{status} - {snapshot && !hostedNodes ? navigate("system", { id: "sandbox" })}>{tSandboxNav("open")}} /> : null} + {snapshot && !hostedNodes ? navigate("system", { id: "sandbox" })}>{tSandboxNav("open")}} /> : null} {snapshot?.deployment.reset && hostedNodes ?

{tSandboxNav("reset")}

: null} {snapshot?.deployment.provider ? <> {staleNodes.length ?

{staleNodes.length === 1 @@ -231,7 +234,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: new Intl.ListFormat(i18n.resolvedLanguage, { type: "conjunction" }).format(staleNodes) })}

: null} {hostedNodes ?
{nodes.length - ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> + ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> : nodesConfirmed ? : null}
: null} : null} diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 79a29f2d7..c05c26921 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -14,7 +14,7 @@ import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; -import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; +import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; import { sandboxAdmin } from "./sandbox-queries"; @@ -66,11 +66,13 @@ export function validEndpoint(apiURL: string, domain: string): boolean { } /** - * Per-sandbox presets around the deployment default: half and double of it. - * Disks apply to microsandbox only, the one provider that enforces them. + * Per-sandbox presets around the Provider's declared default size: half and + * double of it, disks included where the default declares them. A Provider + * whose configuration selects the size declares none and has no presets. */ -function presets(provider: SandboxProvider): Record { +function presets(provider: SandboxProvider): Record | null { const standard = defaultSandboxResources(provider); + if (!standard) return null; const scale = (factor: number): SandboxResources => ({ cpus: Math.max(1, standard.cpus * factor), memory_mib: standard.memory_mib * factor, @@ -92,15 +94,17 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese const same = (a: SandboxResources, b: SandboxResources) => a.cpus === b.cpus && a.memory_mib === b.memory_mib && (a.root_disk_mib ?? 0) === (b.root_disk_mib ?? 0) && (a.environment_disk_mib ?? 0) === (b.environment_disk_mib ?? 0); const all = presets(provider); - return (Object.keys(all) as Preset[]).find((key) => same(all[key], resources)) ?? null; + return all ? (Object.keys(all) as Preset[]).find((key) => same(all[key], resources)) ?? null : null; } /** * 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 (own machines only: E2B sandboxes take the template - * build's size), then a review. Advanced settings hold the complete form. The Runtime - * release comes from this console's distribution manifest when it serves one. + * 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, disk and Runtime inputs. Advanced settings + * hold the complete form. The Runtime release comes from this console's + * distribution manifest when it serves one. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size and Runtime; another backend starts from its * defaults and this console's Runtime. E2B updates can retain the saved key. @@ -121,11 +125,15 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const { t, i18n } = useTranslation("sandbox"); const { t: tCommon } = useTranslation("common"); const id = useId(); - const [step, setStep] = useState(editing ? current?.provider === "e2b" ? "e2b" : "size" : "where"); - const [where, setWhere] = useState(current ? (current.provider === "e2b" ? "direct" : "nodes") : null); + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null; + const [step, setStep] = useState(editing ? currentMode === "direct" ? "e2b" : "size" : "where"); + const [where, setWhere] = useState(currentMode); const [provider, setProvider] = useState(current?.provider ?? null); + const policy = provider ? deploymentContract.providers[provider] : null; const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null; - const [resources, setResources] = useState(current?.specification?.resources ?? defaultSandboxResources("docker")); + // 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(""); const [replacementRequested, setReplacementRequested] = useState(false); @@ -190,14 +198,14 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const matched = useQuery({ queryKey: ["sandbox-runtime-release"], queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), staleTime: Infinity, retry: false }); const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; - const needsRuntime = provider === "docker" || provider === "microsandbox"; + const needsRuntime = policy?.runtime ?? false; const runtimeReady = !needsRuntime || isRuntimeRelease(release); // 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)); - // Core sizes E2B sandboxes from the template build, so E2B sends no resources. - const sized = provider !== null && provider !== "e2b"; + // 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)); const ready = provider !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; @@ -205,12 +213,13 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); const back = () => setStep(step === "advanced" ? "review" : order[Math.max(0, index - 1)]!); - // The saved backend keeps its size and Runtime; another starts from its standard size and this console's Runtime. + // The saved backend keeps its size and Runtime; another starts from its declared default size and this console's Runtime. function choose(next: SandboxProvider) { if (next !== provider) { setRejection(null); const kept = current ? savedSpecification(next, current.provider, current.specification) : null; - setResources(kept?.resources ?? presets(next).standard); + const proposed = kept?.resources ?? defaultSandboxResources(next); + if (proposed) setResources(proposed); setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); setRuntime({}); } @@ -238,7 +247,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab else await onSubmit({ ...selection, ...(provider === "e2b" ? { credential: { api_key: apiKey.trim() }, configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); } catch (error) { // A configuration Core rejected is explained here; the page reports every other failure. - const reason = sandboxConfigurationRejection(error, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); + const reason = sandboxConfigurationRejection(error, locale); if (reason === null) throw error; setRejection(reason); setFieldRejection(error); @@ -263,7 +272,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ setWhere("direct"); choose("e2b"); setStep("e2b"); }} /> - { setWhere("nodes"); setApiKey(""); if (provider === null || provider === "e2b") choose("microsandbox"); setStep("backend"); }} /> + { setWhere("nodes"); setApiKey(""); if (policy?.mode !== "nodes") choose("microsandbox"); setStep("backend"); }} />
); @@ -333,15 +342,15 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ); } else if (step === "size") { - const options = presets(provider ?? "docker"); + const options = provider ? presets(provider) : null; // A saved size outside the presets stays on offer as the current one. const kept = saved && provider && presetOf(provider, saved.resources) === null ? saved.resources : null; - const disks = (value: SandboxResources) => (provider === "microsandbox" ? diskLabel(value) : undefined); + const disks = (value: SandboxResources) => (policy?.disk ? diskLabel(value) : undefined); page = (
{kept ? { setSize("current"); setResources(kept); setStep("review"); }} /> : null} - {(Object.keys(options) as Preset[]).map((key) => ( + {options && (Object.keys(options) as Preset[]).map((key) => (
-
{t("Sandboxes run on")}
{where === "direct" ? t("E2B cloud") : `${t("Own machines")} · ${provider === "docker" ? "Docker" : "microsandbox"}`}
-
{t("Each sandbox")}
{sized ? sizeLabel(resources) : t("From the template build")}{provider === "microsandbox" ? {diskLabel(resources)} : null}
+
{t("Sandboxes run on")}
{where === "direct" ? t("E2B cloud") : `${t("Own machines")} · ${sandboxProviderLabel(provider ?? "", locale)}`}
+
{t("Each sandbox")}
{sized ? sizeLabel(resources) : t("From the template build")}{policy?.disk ? {diskLabel(resources)} : null}
{provider === "e2b" ?
{t("Template build")}
{template || "—"}
: null} {provider === "e2b" ?
{t("Sandbox API URL")}
{apiURL || "https://api.e2b.app"}
: null} {provider === "e2b" ?
{t("Sandbox data-plane domain")}
{domain || "e2b.app"}
: null} @@ -418,7 +427,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ setSize("custom"); setResources({ ...resources, cpus }); setFieldRejection(null); }} /> { setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} /> - {provider === "microsandbox" ? <> + {policy?.disk ? <> { setResources({ ...resources, root_disk_mib }); setFieldRejection(null); }} /> { setResources({ ...resources, environment_disk_mib }); setFieldRejection(null); }} /> : null} diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index cf9188f5f..eb36c2d8e 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -1,11 +1,8 @@ -/** A provider whose nodes install from files this console serves. */ -export type NodeArtifactProvider = "docker" | "microsandbox"; - export interface SandboxConsoleConfig { node_installer: boolean; node_installer_sha256: string; /** The providers whose node files this console serves; a null or malformed value reads as none. */ - node_artifacts: NodeArtifactProvider[]; + node_artifacts: string[]; } const SHA256 = /^[a-f0-9]{64}$/; @@ -27,8 +24,8 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise entry === "docker" || entry === "microsandbox") : []; +function nodeArtifacts(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; } /** Whether a node of this provider can install from the console's files. */ diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index eac961bff..89f333bc8 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultSandboxResources, distributionRuntime, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; -import standardSizes from "./standard-sizes.json"; -import type { SandboxSpecification } from "@oac/agents-client"; +import { deploymentContract, type SandboxProvider, type SandboxSpecification } from "@oac/agents-client"; const manifest = { platform: "linux/amd64", source_commit: "0".repeat(40), images: { runtime: `sha256:${"a".repeat(64)}` }, image_manifest_digests: { runtime: `sha256:${"b".repeat(64)}` }, runtime_ref: `oac-runtime@sha256:${"b".repeat(64)}`, @@ -27,20 +26,18 @@ describe("deployment resources and Runtime", () => { expect(savedSpecification("e2b", "docker", current)).toBeNull(); expect(savedSpecification("e2b", "e2b", { resources: current.resources })).toEqual({ resources: current.resources }); }); - it("keeps the installer's Standard sizes structure", () => { - expect(Object.keys(standardSizes).sort()).toEqual(["docker", "microsandbox"]); - expect(Object.keys(standardSizes.docker).sort()).toEqual(["cpus", "memory_mib"]); - expect(Object.keys(standardSizes.microsandbox).sort()).toEqual(["cpus", "environment_disk_mib", "memory_mib", "root_disk_mib"]); - for (const provider of ["docker", "microsandbox"] as const) { - for (const value of Object.values(standardSizes[provider])) expect(Number.isInteger(value) && value > 0).toBe(true); + it("proposes a copy of each declared default size, which the declared bounds accept", () => { + for (const provider of Object.keys(deploymentContract.providers) as SandboxProvider[]) { + const declared = deploymentContract.providers[provider].default_resources; const resources = defaultSandboxResources(provider); - expect(resources).toEqual(standardSizes[provider]); - expect(resources).not.toBe(standardSizes[provider]); - expect(validSandboxResources(provider, resources)).toBe(true); + expect(resources).toEqual(declared); + if (resources) { + expect(resources).not.toBe(declared); + expect(validSandboxResources(provider, resources)).toBe(true); + } } }); it("respects CPU, memory and supported disk bounds", () => { - for (const provider of ["docker", "microsandbox", "e2b"] as const) expect(validSandboxResources(provider, defaultSandboxResources(provider))).toBe(true); expect(validSandboxResources("docker", { cpus: 0, memory_mib: 2048 })).toBe(false); expect(validSandboxResources("e2b", { cpus: 256, memory_mib: 2048 })).toBe(false); expect(validSandboxResources("docker", { cpus: 2, memory_mib: 511 })).toBe(false); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 8859539f3..af0d3393c 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,5 +1,4 @@ import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; -import standardSizes from "./standard-sizes.json"; interface Manifest { platform?: string; @@ -10,14 +9,16 @@ interface Manifest { microsandbox?: { runtime_sha256?: string; firmware_sha256?: string }; } -export function defaultSandboxResources(provider: SandboxProvider): SandboxResources { - return { ...(provider === "microsandbox" ? standardSizes.microsandbox : standardSizes.docker) }; +/** The size the Provider declares for setup to propose; null when its configuration selects the size. */ +export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null { + const size: SandboxResources | null = deploymentContract.providers[provider].default_resources; + return size && { ...size }; } -/** Core's resource rule: optional disk fields stay zero unless the provider supports disk limits. */ +/** Core's resource rule: optional disk fields stay zero unless the Provider declares disk limits. */ export function validSandboxResources(provider: SandboxProvider, resources: SandboxResources): boolean { return deploymentContract.resources.every((rule) => { - const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : provider === "microsandbox" ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; + const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : deploymentContract.providers[provider].disk ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; const value = resources[rule.name] ?? 0; return Number.isInteger(value) && value >= min && value <= max; }); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index 2663c1792..a81e7046b 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -1,3 +1,5 @@ +import type { SandboxProvider } from "@oac/agents-client"; + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; /** @@ -43,7 +45,7 @@ const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HT * environment or sudo's command line. */ export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest }: { - token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string; + token: string; coreUrl: string; sourceUrl: string; provider: SandboxProvider; installationId: string; scriptDigest: string; }): string { return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`; } diff --git a/apps/web/src/features/sandbox/node-commands.tsx b/apps/web/src/features/sandbox/node-commands.tsx index 8d0763c64..9841556ae 100644 --- a/apps/web/src/features/sandbox/node-commands.tsx +++ b/apps/web/src/features/sandbox/node-commands.tsx @@ -67,8 +67,7 @@ function PrerequisiteList({ items, values }: { items: HostPrerequisite[]; values } /** What the host needs for the default command, which installs the node as a system service. */ -export function HostRequirements({ provider, sized, values, open, onToggle }: { - provider: "docker" | "microsandbox"; +export function HostRequirements({ sized, values, open, onToggle }: { sized: boolean; values: RequirementValues; open: boolean; @@ -77,10 +76,9 @@ export function HostRequirements({ provider, sized, values, open, onToggle }: { const { t } = useTranslation("sandbox"); return
onToggle(event.currentTarget.open)}> {t("Host requirements")} - +

{t("The command creates the oac-node service user and a system service. It installs no software; if something is missing it stops and says what to install.")}

- {provider === "docker" ?

{t("oac-node joins the docker group, which is equivalent to root on this host.")}

: null}
; } diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 7e2f6678a..33b2e3ff5 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -23,14 +23,9 @@ export interface HostPrerequisite { * systemd as the init system, and SELinux not enforcing; `other_node` refuses a * host that already runs a sudo-mode node for another installation, since * sudo-mode nodes share the oac-node account; - * - Docker: `provider_group` needs rootful Docker Engine running, its socket - * group-accessible (0660) and, through `device_group`, owned by the docker - * group, which the service user joins; and CPU and memory limits enforced (the - * node is ready only then: services/core/internal/sandbox/providers/probe.go). - * It installs nothing; - * - microsandbox: `provider_group` needs /dev/kvm, readable and writable by all or - * group-accessible in the kvm group, and `prepare_runtime` the libraries its - * binaries link (the ldd check); + * - the sandbox backend: `provider_group`, `device_group` and `prepare_runtime` + * check what the deployment's Provider needs on the host, name what is missing + * and install nothing; * - `host_capacity`: the host's CPUs and memory hold one sandbox of the * deployment's size (else the node reports capacity_insufficient); the Runtime * image needs about 2 GB of disk; @@ -40,14 +35,12 @@ export interface HostPrerequisite { * (`provider_config`). * `sized` says whether the deployment's sandbox size is known for the capacity item. */ -export function hostRequirements(provider: "docker" | "microsandbox", sized: boolean): HostPrerequisite[] { +export function hostRequirements(sized: boolean): HostPrerequisite[] { return [ { label: "Linux amd64 with systemd; Python 3.9+, curl, sha256sum and flock; root or sudo" }, { label: "SELinux is not enforcing" }, { label: "In sudo mode, one Core per host: a host already running a sudo-mode node for another Core is refused." }, - provider === "docker" - ? { label: "Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)" } - : { label: "/dev/kvm in the kvm group (hardware or nested virtualization) and the libraries microsandbox links (glibc)" }, + { label: "What the sandbox backend needs on the host; the installer checks it and names anything missing" }, { label: sized ? "CPUs and memory for at least one sandbox: {{size}}; about 2 GB of disk for the Runtime image" : "CPUs and memory for at least one sandbox; about 2 GB of disk for the Runtime image" }, { label: "Reaches {{core}}, as do its sandboxes" }, ]; diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index db2fc3937..4931b8c91 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -18,7 +18,7 @@ function cache(provider: SandboxDeployment["provider"]) { const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false, gcTime: Infinity } } }); const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "install", owner_epoch: 1, generation: 2, provider, core_url: "https://core.example", - mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, + mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; const config: SandboxConsoleConfig = { node_installer: false, node_installer_sha256: "", node_artifacts: [] }; diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts index 3c14e9dc9..643c2fab4 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.test.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts @@ -83,7 +83,7 @@ describe("sandbox reset reads", () => { const nodes = vi.spyOn(sandboxAdmin, "listNodes"); const client = cache(); for (const generation of [0, 3]) { - read.mockResolvedValueOnce(deployment({ provider: "", reset: null, generation })); + read.mockResolvedValueOnce(deployment({ provider: "", mode: "", reset: null, generation })); expect((await client.fetchQuery(sandboxSnapshotQuery)).deployment.generation).toBe(generation); } expect(nodes).not.toHaveBeenCalled(); diff --git a/apps/web/src/features/sandbox/sandbox-queries.ts b/apps/web/src/features/sandbox/sandbox-queries.ts index 00266196a..81029f64e 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.ts @@ -73,7 +73,7 @@ export const sandboxSnapshotQuery = queryOptions({ signal.throwIfAborted(); let nodes: SandboxNode[] = []; try { - if (deployment.provider && deployment.provider !== "e2b") nodes = (await sandboxAdmin.listNodes({ signal })).data; + if (deployment.mode === "nodes") nodes = (await sandboxAdmin.listNodes({ signal })).data; const allocations = await Promise.all(nodes.map((node) => sandboxAdmin.listAllocations(node.id, { signal }))); signal.throwIfAborted(); return { deployment, nodes, allocations: allocations.flatMap((page) => page.data), nodesError: null, readAt }; diff --git a/apps/web/src/features/sandbox/standard-sizes.json b/apps/web/src/features/sandbox/standard-sizes.json deleted file mode 100644 index 4461dbf50..000000000 --- a/apps/web/src/features/sandbox/standard-sizes.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "docker": { - "cpus": 2, - "memory_mib": 2048 - }, - "microsandbox": { - "cpus": 2, - "memory_mib": 4096, - "root_disk_mib": 8192, - "environment_disk_mib": 8192 - } -} diff --git a/apps/web/src/features/sandbox/standard-sizes.md b/apps/web/src/features/sandbox/standard-sizes.md deleted file mode 100644 index 8c39ef49b..000000000 --- a/apps/web/src/features/sandbox/standard-sizes.md +++ /dev/null @@ -1,23 +0,0 @@ -# Standard sandbox sizes - -`standard-sizes.json` is the single source of truth for the default Standard size of each sandbox on a self-hosted backend. The console setup wizard offers it as Standard and derives Small (half) and Large (double) from it. - -## Structure and units - -```json -{ - "docker": { "cpus": 2, "memory_mib": 2048 }, - "microsandbox": { "cpus": 2, "memory_mib": 4096, "root_disk_mib": 8192, "environment_disk_mib": 8192 } -} -``` - -- `cpus`: whole CPUs, a positive integer. -- `memory_mib`, `root_disk_mib`, `environment_disk_mib`: MiB, positive integers. -- `docker` has exactly `cpus` and `memory_mib`; it has no disk fields. -- `microsandbox` has exactly all four fields. - -The values must stay within the bounds that Core and `validSandboxResources` accept. - -## Readers - -The Web setup wizard is the only reader, through `defaultSandboxResources` in `deployment-specification.ts`. `deployment-specification.test.ts` pins the structure so that an accidental change fails. diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index e6080a26d..fd23a23c3 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -147,13 +147,11 @@ export const chinese = { "SELinux is not enforcing": "SELinux 不是 enforcing 模式", "In sudo mode, one Core per host: a host already running a sudo-mode node for another Core is refused.": "sudo 模式下,每台主机只能接入一个 Core:已为其他 Core 运行 sudo 模式节点的主机会被拒绝。", "If the command is interrupted or the download stalls, run it again: unfinished downloads restart, and verified files are reused.": "如果命令被中断或下载卡住,重新运行即可:未完成的下载会从头开始,已校验的完整文件会复用。", - "Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)": "Docker Engine 以 rootful 模式运行,套接字属于 docker 组、权限 0660,且能限制 CPU 和内存(cgroup v2)", - "/dev/kvm in the kvm group (hardware or nested virtualization) and the libraries microsandbox links (glibc)": "属于 kvm 组的 /dev/kvm(硬件或嵌套虚拟化),以及 microsandbox 链接的库(glibc)", + "What the sandbox backend needs on the host; the installer checks it and names anything missing": "沙箱后端在主机上所需的条件;安装程序会检查并指出缺少的项", "Copy command with --force": "复制命令(--force)", "Checking this installation's public URL…": "正在检查这个安装的公网地址…", "The installation couldn't be read, so no command can be issued.": "无法读取安装信息,暂时不能生成命令。", "It never deletes sandboxes, volumes or images.": "它不会删除任何沙箱、卷或镜像。", - "It keeps microsandbox's image store and sandbox data, and prints how to remove them by hand.": "它会保留 microsandbox 的镜像存储和沙箱数据,并打印手动删除的方法。", "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。", @@ -162,7 +160,6 @@ export const chinese = { "CPUs and memory for at least one sandbox; about 2 GB of disk for the Runtime image": "CPU 和内存至少够一个沙箱;Runtime 镜像约需 2 GB 磁盘", "Reaches {{core}}, as do its sandboxes": "能访问 {{core}},它的沙箱也要能访问", "The command creates the oac-node service user and a system service. It installs no software; if something is missing it stops and says what to install.": "命令会创建 oac-node 服务用户和一个系统服务。它不安装任何软件;缺少什么时会停下并说明要装什么。", - "oac-node joins the docker group, which is equivalent to root on this host.": "oac-node 会加入 docker 组,这在这台主机上等同于 root 权限。", "Set a public address other machines can reach before adding nodes.": "添加节点前,请先设置其他机器能访问的公开地址。", "Clean up the host": "清理主机", "{{name}} is removed from Core. To remove its service and files from the host, run:": "{{name}} 已从 Core 移除。要删除它在主机上的服务和文件,请运行:", diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index ba0db8e65..bd1ff3424 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -224,6 +224,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, provider requirements, release patterns and canonical field order; `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 patterns in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or patterns. 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, release patterns 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, patterns and Provider declarations in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits, patterns 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 `runtime` when the provider requires it. Resource and Runtime fields follow the contract's declaration order; zero optional disk fields are omitted and required fields stay present. Release identities are lowercase ASCII, and 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 ae1d9a97b..3bc42898a 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: 7a1bc927bb14e9599e800833f10a5fd123621e3f95bfcbdffa75e7a63654ffe3 +source_hash: 68aabafdb8983280a3bb29e7398042f9e843310d1784acd4a9a94e9647fd3919 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness;部署可以保持未配置状态,既无节点,也不接受托管准入。 @@ -226,6 +226,6 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 ## 规范的节点规格 {#canonical-node-specification} -`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`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`负责资源边界、发行版模式和规范字段顺序,每个已注册 Provider 的 `sandbox.DeploymentPolicy` 声明其要求;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界、模式和 Provider 声明,因此不存在第二套限制、模式或提供商列表。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 规范摘要是紧凑 UTF-8 JSON 的 SHA-256,其中 `provider` 位于首位,其次是 `resources`,然后在提供商需要时放置 `runtime`。资源和 Runtime 字段遵循契约的声明顺序;值为零的可选磁盘字段会被省略,必填字段则保持存在。发行版标识采用小写 ASCII,摘要绝不会受传入字段顺序或空白字符影响。`services/core/internal/sandbox/testdata/deployment-contract.json`保存共享验收用例、精确的规范字节和摘要,Go 与 Python 测试都会使用这些内容。 diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index a74b79cfe..9d59d41cb 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -27,7 +27,7 @@ def release(manifest): # BEGIN GENERATED DEPLOYMENT CONTRACT # Generated from sandbox/deployment_contract.go; do not edit. -_CONTRACT = json.loads("{\"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}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"disk\":false,\"runtime\":true},\"e2b\":{\"disk\":false,\"runtime\":false},\"microsandbox\":{\"disk\":true,\"runtime\":true}},\"minimum_disk\":1024}") +_CONTRACT = json.loads("{\"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}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"runtime\":false,\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"disk\":true,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}") # END GENERATED DEPLOYMENT CONTRACT diff --git a/docs/configuration.md b/docs/configuration.md index 5ff249aff..1a248b7e3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,7 +79,7 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa | Setting | Where in Web | Core API | Notes | | --- | --- | --- | --- | | Sandbox backend: Docker, microsandbox or E2B | **System** → **Manage sandbox configuration**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation, chosen after the first sign-in. Another backend needs **Reset deployment** first; see [change the sandbox configuration](./getting-started/nodes.md#change-the-sandbox-configuration) | -| Sandbox size, Runtime release, E2B key and template build | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the sizes in [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json). Existing sandboxes keep their size and release. The E2B key is write-only and encrypted | +| Sandbox size, Runtime release, E2B key and template build | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the [default size the Provider declares](./sandbox-provider.md#register-the-provider-kind). Existing sandboxes keep their size and release. The E2B key is write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**; **Edit node** and **Remove node** on a node's page | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and the [nodes guide](./getting-started/nodes.md) | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | | Default model per harness | **System** → **Default model configuration**: **Set** | `/core/v1/harnesses/{harness}/model-configuration` | See [Default models](#default-models) | diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index 3a626ae1f..074c37754 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -66,7 +66,7 @@ Several installations can share a machine when they use distinct installation di ## Sandbox backend -The installer saves no sandbox backend. After signing in, open **System** → **Manage sandbox configuration** and choose Docker, microsandbox or E2B; Web proposes the Standard size in [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json). The choice is stored in Core's database. To change it later, [reset the deployment](./nodes.md#change-the-sandbox-configuration). Docker shares each node's kernel with its sandboxes, and its node service account is [root-equivalent](./nodes.md#what-the-installer-sets-up). E2B needs a public HTTPS URL that is not loopback, because E2B's sandboxes call Core from E2B's cloud. Prepare an E2B template with the [E2B guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md). +The installer saves no sandbox backend. After signing in, open **System** → **Manage sandbox configuration** and choose Docker, microsandbox or E2B; Web proposes the [default size the Provider declares](../sandbox-provider.md#register-the-provider-kind) as Standard. The choice is stored in Core's database. To change it later, [reset the deployment](./nodes.md#change-the-sandbox-configuration). Docker shares each node's kernel with its sandboxes, and its node service account is [root-equivalent](./nodes.md#what-the-installer-sets-up). E2B needs a public HTTPS URL that is not loopback, because E2B's sandboxes call Core from E2B's cloud. Prepare an E2B template with the [E2B guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md). ## Listeners and access diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 4cfb26d3a..904f2d349 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -102,26 +102,26 @@ Hosted and self-hosted Environments use the same Runtime preparation; a provider ## Register the provider kind -`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. +`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), its `sandbox.DeploymentPolicy` (disk limits, the Runtime input and the default size setup proposes), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. 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. -4. 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 installer's projection combines the registered policies with the shared field bounds in `sandbox/deployment_contract.go`; regenerate it with `go run ./services/core/cmd/specification-contract -write`. +4. 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. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. -**Known design gap:** Web's setup views carry provider-specific options, such as E2B's views. Exposing another provider through that surface currently requires a shared Web edit. This coupling does not meet [Complexity stays in the adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. +**Known design gap:** Web's setup wizard still names providers in its backend choice, the E2B configuration step (service presets and fields) and the Docker confirmation, because the protocol declares no configuration fields yet. Exposing another provider through that surface currently requires a shared Web edit. This coupling does not meet [Complexity stays in the adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. `providers.Build` passes persisted node configuration and ephemeral `LocalOptions` to `BuildLocal`. 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. ### Registration validation -`providers.ValidateRegistration` is the single wiring check. Lookup, constructor binding and the installer projection run it before any configuration callback or constructor. An unknown provider name stays invalid input; a malformed registration returns a safe `providercontract.ErrContract` that includes no submitted configuration or native diagnostics. +`providers.ValidateRegistration` is the single wiring check. Lookup, constructor binding and the generated projections run it before any configuration callback or constructor. An unknown provider name stays invalid input; a malformed registration returns a safe `providercontract.ErrContract` that includes no submitted configuration or native diagnostics. - A `nodes` registration has only `BuildLocal`, and a `direct` registration only `BuildDirect`; missing, mixed or unknown modes are rejected. -- The specification and resource validators, the configuration adapter and a complete operation declaration are mandatory, so an incomplete registration cannot publish a partial installer projection. +- The specification and resource validators, the configuration adapter and a complete operation declaration are mandatory, so an incomplete registration cannot publish a partial projection. A declared default size must pass the adapter's resource validator. - The Runtime input policy either accepts the pinned Runtime or gives the adapter's fixed reason for rejecting it, never both. - Checkpoint is admitted only for a `nodes` registration, because the common lifecycle suspends only node allocations; registration rejects a `direct` Provider that declares it. A registration carries no suspension values: Core applies its one [suspension policy](#suspension) to every Provider that declares checkpoint support. diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index a8c1afe1c..fcde3f949 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: b0946948a110778123f52e065f24da9cfa7cfb8a1aeb6321526fb5064537a9a8 +source_hash: 95519fc0cac3ac31f2cd08cdfadf20be92a03d998093a651411240c95d29176d --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -83,7 +83,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | 设置 | Web 中的位置 | Core API | 注意事项 | | --- | --- | --- | --- | | 沙箱后端:Docker、microsandbox 或 E2B | **System** → **Manage sandbox configuration**:设置向导,最后点击 **Save configuration** | `/core/v1/sandbox/deployment` | 每个安装只能使用一个后端,在首次登录后选择。要改用其他后端,必须先执行 **Reset deployment**;请参阅[更改沙箱配置](getting-started/nodes.md#change-the-sandbox-configuration) | -| 沙箱大小、Runtime 发行版、E2B 密钥和模板构建 | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web 会在 [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json) 中推荐可用大小。现有沙箱会保留其大小和发行版。E2B 密钥仅可写入,并且已加密 | +| 沙箱大小、Runtime 发行版、E2B 密钥和模板构建 | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web 会推荐 [Provider 声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小和发行版。E2B 密钥仅可写入,并且已加密 | | 节点及其容量 | **Nodes**:**Add node**;在节点页面上使用 **Edit node** 和 **Remove node** | `/core/v1/sandbox/enrollment-tokens`、`/core/v1/sandbox/nodes` | 请参阅[节点容量](#node-capacity)和[节点指南](getting-started/nodes.md) | | 项目和 API 密钥 | **Projects and keys**:**Create project**、**Rename**、**Issue key**、**Revoke**、**Archive** | `/core/v1/projects` | 密钥只显示一次;Core 存储其摘要 | | 每个 Harness 的默认模型 | **System** → **Default model configuration**:**Set** | `/core/v1/harnesses/{harness}/model-configuration` | 请参阅[默认模型](#default-models) | diff --git a/docs/zh/getting-started/install-options.md b/docs/zh/getting-started/install-options.md index bd5cd674c..a4254a418 100644 --- a/docs/zh/getting-started/install-options.md +++ b/docs/zh/getting-started/install-options.md @@ -1,7 +1,7 @@ --- title: "安装选项" source: docs/getting-started/install-options.md -source_hash: 6acbb4205e95aa5ad2f36fbfb3656a785b8d420c2ff754b81ae9cc7e07f10482 +source_hash: 698909d30bf0ebdff2656deb6a6cb00c18181e835e2c1a7972de0d1e7ca47733 --- [默认安装](install.md)无需任何选项。本页介绍安装选项、Compose 部署和反向代理配置。 @@ -68,7 +68,7 @@ docker compose exec web oac-web core-key ## 沙箱后端 {#sandbox-backend} -安装程序不保存沙箱后端。登录后,打开 **System** → **Manage sandbox configuration**,选择 Docker、microsandbox 或 E2B;Web 会按 [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json) 推荐 Standard 尺寸。该选择保存在 Core 的数据库中。以后要更改,请[重置部署](nodes.md#change-the-sandbox-configuration)。Docker 沙箱与每个节点共用该节点的内核,其节点服务账户[等效于 root](nodes.md#what-the-installer-sets-up)。E2B 需要一个非回环的公共 HTTPS URL,因为 E2B 沙箱会从 E2B 云端调用 Core。按照 [E2B 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md)准备模板。 +安装程序不保存沙箱后端。登录后,打开 **System** → **Manage sandbox configuration**,选择 Docker、microsandbox 或 E2B;Web 会把 [Provider 声明的默认大小](../sandbox-provider.md#register-the-provider-kind)推荐为 Standard 尺寸。该选择保存在 Core 的数据库中。以后要更改,请[重置部署](nodes.md#change-the-sandbox-configuration)。Docker 沙箱与每个节点共用该节点的内核,其节点服务账户[等效于 root](nodes.md#what-the-installer-sets-up)。E2B 需要一个非回环的公共 HTTPS URL,因为 E2B 沙箱会从 E2B 云端调用 Core。按照 [E2B 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md)准备模板。 ## 监听器与访问 {#listeners-and-access} diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 2b0bd3a3f..c4019b6b1 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "添加 Sandbox Provider" source: docs/sandbox-provider.md -source_hash: 159c2a7366edd784386ca7bd5748752451fd0a3ea543d813cefd29921014d294 +source_hash: c4d127115021894c676b53b678919ef6c3d584afbcf6783a33e55e0494959e51 --- **Sandbox Provider** 为 Core 管理的 Environment 提供 Runtime daemon 运行所需的外层计算资源,以及启动 daemon 的有界引导流程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -104,26 +104,26 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` ## 注册 provider kind {#register-the-provider-kind} -`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 +`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、`sandbox.DeploymentPolicy`(磁盘限制、Runtime 输入,以及 setup 推荐的默认大小)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 新 provider 执行以下步骤: 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 provider 仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 -4. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。installer 投影组合已注册 policy 与 `sandbox/deployment_contract.go` 中的共享 field bound;通过 `go run ./services/core/cmd/specification-contract -write` 重新生成。 +4. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即 provider kind,也用于标记该 Provider 的观测;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 读取这些声明,而不比较 provider kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 5. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。 -**已知设计缺口:** Web 的 setup view 携带 provider 专有选项,如 E2B 的 view。通过该界面提供另一 provider 目前需要修改共享的 Web。此耦合不符合[复杂性留在 adapter 内](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter);新集成必须通过协议表达配置,把厂商专有行为留在 adapter。不得添加 Session 或 Turn 调度路径、厂商专有 column 或 API field,或 store 中的厂商 switch。 +**已知设计缺口:** Web 的 setup 向导仍在后端选择、E2B 配置步骤(服务预设与字段)和 Docker 确认中指名 provider,因为协议尚未声明配置字段。通过该界面提供另一 provider 目前需要修改共享的 Web。此耦合不符合[复杂性留在 adapter 内](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter);新集成必须通过协议表达配置,把厂商专有行为留在 adapter。不得添加 Session 或 Turn 调度路径、厂商专有 column 或 API field,或 store 中的厂商 switch。 `providers.Build` 将持久化 node 配置与临时 `LocalOptions` 传给 `BuildLocal`。调用方通过 `Standalone` 明确选择独立注册或单 provider 执行,或通过 `GenerationStateDirectory` 中的规范绝对 node state directory 选择 generation 拥有的执行。context 缺失或混合时拒绝。adapter 负责 generation 特有的原生 preparation 和 readiness 检查。Microsandbox 将 helper lease 绑定到安装实例、generation 和 specification digest,然后在平台、容量和 artifact readiness 后检查固定 image。 ### 注册验证 {#registration-validation} -`providers.ValidateRegistration` 是唯一 wiring 检查。lookup、constructor binding 和 installer projection 在任何 configuration callback 或 constructor 前运行它。未知 provider name 保持为无效输入;格式错误的注册返回安全 `providercontract.ErrContract`,不包含提交的配置或原生诊断。 +`providers.ValidateRegistration` 是唯一 wiring 检查。lookup、constructor binding 和生成的投影在任何 configuration callback 或 constructor 前运行它。未知 provider name 保持为无效输入;格式错误的注册返回安全 `providercontract.ErrContract`,不包含提交的配置或原生诊断。 - `nodes` 注册仅有 `BuildLocal`,`direct` 注册仅有 `BuildDirect`;缺失、混合或未知 mode 被拒绝。 -- specification 和 resource validator、configuration adapter 与完整 operation 声明都是必需项,因此不完整注册不能发布部分 installer projection。 +- specification 和 resource validator、configuration adapter 与完整 operation 声明都是必需项,因此不完整注册不能发布部分投影。声明的默认大小必须通过 adapter 的 resource validator。 - Runtime input policy 要么接受固定 Runtime,要么给出 adapter 拒绝它的固定原因,不能两者兼有。 - Checkpoint 仅准入 `nodes` 注册,因为公共 lifecycle 只暂停 node allocation;声明 checkpoint 的 `direct` Provider 会被注册拒绝。注册不携带 suspension 数值:Core 对每个声明 checkpoint 支持的 Provider 应用同一个 [suspension policy](#suspension)。 diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 77ccf9d18..1106413a9 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 = {"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}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"minimum_disk":1024} as const; +export const deploymentContract = {"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}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"providers":{"docker":{"mode":"nodes","disk":false,"runtime":true,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"runtime":false,"default_resources":null},"microsandbox":{"mode":"nodes","disk":true,"runtime":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}}},"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 256ee7001..81735f221 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -25,7 +25,8 @@ export function normalizeSandboxNodeDiagnostic(value: string): Exclude : "provider_unavailable"; } -export type SandboxProvider = "docker" | "microsandbox" | "e2b"; +/** A registered Provider kind; deploymentContract.providers holds each one's declaration. */ +export type SandboxProvider = keyof typeof deploymentContract.providers; /** 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(["runtime", ...deploymentContract.resources.map(({ name }) => `resources.${name}`)]); @@ -88,7 +89,7 @@ export interface SandboxDeployment { configuration?: { template?: string; api_url?: string; domain?: string }; metadata?: { template_build?: SandboxE2BTemplateBuild }; credential_configured: boolean; - /** Idle suspension policy; microsandbox only, otherwise null. */ + /** Idle suspension policy; null unless the Provider declares checkpoint support. */ suspension: { idle_seconds: number; retention_seconds: number } | null; } /** The fixed E2B build as Core read it when the selection was saved; unknown values are null. */ @@ -198,7 +199,6 @@ const measure = (value: unknown) => typeof value === "number" && Number.isFinite const nullable = (test: (value: unknown) => boolean) => (value: unknown) => value === null || test(value); const strings = (value: Record, fields: readonly string[]) => fields.every((field) => typeof value[field] === "string"); -const providers = new Set(["", "docker", "microsandbox", "e2b"]); const modes = new Set(["", "nodes", "direct"]); const releaseFields = ["source_commit", "image_id", "image_manifest_digest", "microsandbox_ref", "runtime_sha256", "firmware_sha256"]; function projectSpecification(value: unknown): SandboxSpecification { @@ -264,7 +264,7 @@ function projectDeployment(value: unknown): SandboxDeployment { const resources = members(deployment.resources, ["allocations", "pending"]); const suspension = deployment.suspension === null ? null : members(deployment.suspension, ["idle_seconds", "retention_seconds"]); const configured = hasOwn(deployment, "specification"); - valid(strings(deployment, ["installation_id", "core_url"]) && providers.has(deployment.provider as string) && modes.has(deployment.mode as string) && + valid(strings(deployment, ["installation_id", "core_url"]) && (deployment.provider === "" || (typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider))) && modes.has(deployment.mode as string) && [deployment.owner_epoch, deployment.generation, resources.allocations, resources.pending].every(isNonnegativeInteger) && (suspension === null || [suspension.idle_seconds, suspension.retention_seconds].every(isNonnegativeInteger)) && configured === hasOwn(deployment, "specification_digest") && (!configured || (typeof deployment.specification_digest === "string" && deployment.specification_digest !== ""))); diff --git a/services/core/cmd/specification-contract/main.go b/services/core/cmd/specification-contract/main.go index c79213dda..5e5956190 100644 --- a/services/core/cmd/specification-contract/main.go +++ b/services/core/cmd/specification-contract/main.go @@ -5,7 +5,6 @@ package main import ( "flag" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "os" "strings" @@ -14,12 +13,11 @@ import ( func main() { write := flag.Bool("write", false, "update deploy/node/node_spec.py and packages/agents-client/src/deployment-contract.ts from the repository root") flag.Parse() - projection, err := providers.Builtin().PythonDeploymentContract() + projection, typescript, err := providers.Builtin().DeploymentContract() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - typescript := sandbox.TypeScriptDeploymentContract() if !*write { fmt.Println(projection) fmt.Print(typescript) diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index e79ea2f40..da40c7ca3 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -24,13 +24,6 @@ type runtimeRule struct { Pattern string `json:"pattern"` } -// DeploymentPolicy is declared by an adapter and projected to the installer. -type DeploymentPolicy struct { - RuntimeError string `json:"-"` - Disk bool `json:"disk"` - Runtime bool `json:"runtime"` -} - var resourceContract = []resourceRule{ {"cpus", 1, 255, false, "cpus must be 1..255 and memory_mib must be 512..1048576"}, {"memory_mib", 512, 1048576, false, "cpus must be 1..255 and memory_mib must be 512..1048576"}, @@ -46,31 +39,16 @@ var runtimeContract = []runtimeRule{ {"firmware_sha256", "[0-9a-f]{64}"}, } -// PythonDeploymentContract generates the installer projection. -func PythonDeploymentContract(policies map[string]DeploymentPolicy) string { - raw := projectDeploymentContract(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - Providers map[string]DeploymentPolicy `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, policies, 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" -} - -// TypeScriptDeploymentContract generates the client projection of the bounds -// and patterns; provider policies are not part of it. -func TypeScriptDeploymentContract() string { - raw := projectDeploymentContract(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, minimumDiskMiB}) - return "// 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" +// ProviderProjection is one registered Provider in the generated projections. +type ProviderProjection struct { + Mode string `json:"mode"` + DeploymentPolicy } -// projectDeploymentContract encodes a projection. Struct order is checked -// first because it also defines Go's canonical JSON bytes. -func projectDeploymentContract(projection any) []byte { +// DeploymentContract generates the installer and TypeScript client projections +// of one contract. Struct order is checked first because it also defines Go's +// canonical JSON bytes. +func DeploymentContract(providers map[string]ProviderProjection) (python, typescript string) { for _, item := range []struct { value any names []string @@ -87,8 +65,14 @@ func projectDeploymentContract(projection any) []byte { } } } - raw, _ := json.Marshal(projection) - return raw + raw, _ := json.Marshal(struct { + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + Providers map[string]ProviderProjection `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, 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" } func resourceNames() []string { var names []string diff --git a/services/core/internal/sandbox/docker/selection.go b/services/core/internal/sandbox/docker/selection.go index 426cf6005..3a7ec247a 100644 --- a/services/core/internal/sandbox/docker/selection.go +++ b/services/core/internal/sandbox/docker/selection.go @@ -4,7 +4,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Runtime: true} } +func Policy() sandbox.DeploymentPolicy { + return sandbox.DeploymentPolicy{Runtime: true, DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} +} func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("docker", Policy()) } func ValidateSpecification(s sandbox.DeploymentSpec) error { diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go index a5063e214..a97091d9d 100644 --- a/services/core/internal/sandbox/microsandbox/deployment.go +++ b/services/core/internal/sandbox/microsandbox/deployment.go @@ -4,7 +4,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Disk: true, Runtime: true} } +func Policy() sandbox.DeploymentPolicy { + return sandbox.DeploymentPolicy{Disk: true, Runtime: true, + DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}} +} func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } func ValidateSpecification(s sandbox.DeploymentSpec) error { diff --git a/services/core/internal/sandbox/providers/deployment_contract_test.go b/services/core/internal/sandbox/providers/deployment_contract_test.go index ea6477748..2f9d9798e 100644 --- a/services/core/internal/sandbox/providers/deployment_contract_test.go +++ b/services/core/internal/sandbox/providers/deployment_contract_test.go @@ -19,11 +19,11 @@ func TestDeploymentContractProjectionsAreCurrent(t *testing.T) { if err != nil { t.Fatal(err) } - expected, err := registry.PythonDeploymentContract() + expectedPython, expectedTypeScript, err := registry.DeploymentContract() if err != nil { t.Fatal(err) } - if !strings.Contains(string(python), expected) || string(typescript) != sandbox.TypeScriptDeploymentContract() { + if !strings.Contains(string(python), expectedPython) || string(typescript) != expectedTypeScript { t.Fatal("deployment contract projection is stale; regenerate with go run ./services/core/cmd/specification-contract -write") } } diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 3962f0f6e..2cf638f56 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -8,7 +8,8 @@ import ( ) // ValidateRegistration checks wiring before configuration parsing or construction. -// Only configuration requirements and operation declarations are read. +// Only configuration requirements and operation declarations are read, and the +// resource validator 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) @@ -55,5 +56,8 @@ func ValidateRegistration(a Adapter) error { if operations["Initial"].State == providercontract.Supported && a.Mode != "nodes" { return invalid("checkpoint support outside nodes mode") } + if a.Policy.DefaultResources != nil && a.ValidateResources(*a.Policy.DefaultResources) != nil { + return invalid("default resources") + } return nil } diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index b8121f38d..f2b595a45 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -102,8 +102,8 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { {"direct build", func() error { _, err := registry.BuildDirect(sandbox.DirectConfig{Selection: selection}); return err }}, {"binding", func() error { return ValidateBinding(a, &docker.Provider{}) }}, {"projection", func() error { - text, err := registry.PythonDeploymentContract() - if text != "" { + python, typescript, err := registry.DeploymentContract() + if python != "" || typescript != "" { t.Fatal("partial invalid projection") } return err @@ -160,7 +160,15 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { if err != nil || p == nil || calls != 2 { t.Fatalf("credential-free direct build: %v calls=%d", err, calls) } - if _, err := registry.PythonDeploymentContract(); err != nil { + if _, _, err := registry.DeploymentContract(); err != nil { + t.Fatal(err) + } +} + +func TestRegistrationRejectsInvalidDefaultResources(t *testing.T) { + a := Builtin().adapters["docker"] + a.Policy.DefaultResources = &sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 1024} + if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) { t.Fatal(err) } } diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index d7bf67337..51eb5aaaf 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -142,16 +142,18 @@ func (r *Registry) Describe(kind, installation string) (sandbox.Description, err return sandbox.Description{Mode: a.Mode, BackendFingerprint: BackendFingerprint(kind, namespace+":"+installation)}, nil } -// PythonDeploymentContract projects the same registered adapter policies into -// the node installer; no second provider list exists in another language. -func (r *Registry) PythonDeploymentContract() (string, error) { - policies := make(map[string]sandbox.DeploymentPolicy, len(r.adapters)) +// DeploymentContract projects the registered modes and policies into the node +// installer and the TypeScript client; no second provider list exists in +// another language. +func (r *Registry) DeploymentContract() (python, typescript string, err error) { + providers := make(map[string]sandbox.ProviderProjection, len(r.adapters)) for kind := range r.adapters { a, err := r.Lookup(kind) if err != nil { - return "", err + return "", "", err } - policies[kind] = a.Policy + providers[kind] = sandbox.ProviderProjection{Mode: a.Mode, DeploymentPolicy: a.Policy} } - return sandbox.PythonDeploymentContract(policies), nil + python, typescript = sandbox.DeploymentContract(providers) + return python, typescript, nil } diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index aac367557..fd5600501 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -249,6 +249,17 @@ type ConfigurationRequirements struct { CredentialVerification providercontract.Support } +// DeploymentPolicy is the deployment declaration. Disk declares independent +// disk limits; Runtime requires a pinned Runtime release, and RuntimeError is +// the fixed reason for rejecting one otherwise. DefaultResources is the size +// setup proposes, or nil when the Provider's configuration selects it. +type DeploymentPolicy struct { + RuntimeError string `json:"-"` + Disk bool `json:"disk"` + Runtime bool `json:"runtime"` + DefaultResources *Resources `json:"default_resources"` +} + // Configuration is an adapter-owned typed value, never a request or response DTO. // Implementations must exclude secrets from JSON and safe diagnostic output. type Configuration interface {