diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index cb946e049..84caac947 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -54,7 +54,9 @@ workbench. - `/console/config` reports the node installer (`node_installer`, `node_installer_sha256`) and the self-hosted executor installer (`self_hosted_installer`, `self_hosted_installer_sha256`); an installer is - offered only with a 64-hex digest. Signing in grants administration, so sandbox + offered only with a 64-hex digest. It also lists the providers it has node files + for (`node_artifacts`); without the deployment's provider, Add node says so and + issues no command. Signing in grants administration, so sandbox administration is available unless the console explicitly reports `sandbox_admin: false`; then the Nodes page explains that it is not configured and the fleet figures show as unavailable. diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 56fb49e97..a43ac4b8c 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -14,13 +14,14 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; * with a node enrolled with an earlier address), `credentials: "none"` a Core without a * credential encryption key, which cannot store a model provider's key, and * `installers: "none"` a console without its node installation payload, so it serves neither - * the node nor the self-hosted installer. + * the node nor the self-hosted installer. `nodeArtifacts` lists the providers the console has + * node files for, both by default; as in the console, microsandbox needs Docker's files too. */ -export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none"; installers?: "none" } +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] } /** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) { - await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}${options.credentials ? `&credentials=${options.credentials}` : ""}${options.installers ? `&installers=${options.installers}` : ""}`); + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}${options.credentials ? `&credentials=${options.credentials}` : ""}${options.installers ? `&installers=${options.installers}` : ""}${options.nodeArtifacts ? `&artifacts=${options.nodeArtifacts.join(",")}` : ""}`); } /** Opens the console already signed in, in English. */ @@ -36,8 +37,8 @@ export async function failNext(request: APIRequestContext, failure: { method: st await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); } -/** Registers a node, or changes one, as a host running an enrollment command would. */ -export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string }) { +/** Registers a node, or changes one, as a host running the last enrollment command (or, with its `enrollment_id`, another one) would. */ +export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string; enrollment_id?: string }) { await request.post(`${fixture}/__fixture/node`, { data: node }); } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index cfd786059..58b80cf44 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -82,7 +82,7 @@ function e2bDeployment() { return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; } -function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true) { +function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true, artifacts = "docker,microsandbox") { // Self-hosted Sessions get their remote_url from public_url, as in Core. const base = buildDemo(undefined, address === "local" ? LOCAL_URL : PUBLIC_URL); const now = Math.floor(Date.now() / 1000); @@ -114,10 +114,13 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d deployment: null, // Whether the console has its node installation payload, and so serves both installers. installers, + // The providers whose node files the console serves (/console/config node_artifacts). + nodeArtifacts: artifacts.split(",").filter(Boolean), }; state.deployment = sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(); // Each node reports the address it enrolled with; in "stale" mode the first one enrolled before public_url changed. - state.nodes.forEach((node, index) => { node.core_url = address === "stale" && index === 0 ? OLD_URL : publicUrl(); }); + // The seeded nodes enrolled before Core recorded enrollment IDs. + state.nodes.forEach((node, index) => { node.core_url = address === "stale" && index === 0 ? OLD_URL : publicUrl(); node.enrollment_id = null; }); if (sandbox === "e2b") Object.assign(state, { nodes: [], allocations: [] }); } reset(); @@ -188,10 +191,12 @@ async function consoleRoute(request, response, url) { } if (url.pathname === "/console/config") { // As Core's console: signing in grants administration, so it reports only its installers, - // both served from the node installation payload, and without that payload neither. + // both served from the node installation payload, and without that payload neither, and the + // providers whose node files that payload holds (always a list, empty without it). const served = state.installers; return send(response, 200, { node_installer: served, node_installer_sha256: served ? "a".repeat(64) : "", + node_artifacts: served ? state.nodeArtifacts : [], self_hosted_installer: served, self_hosted_installer_sha256: served ? SELF_HOSTED_INSTALLER_SHA256 : "", }); } @@ -331,12 +336,14 @@ async function sandboxRoute(request, response, path) { } if (path === "/nodes") return send(response, 200, { data: state.nodes }); if (path === "/enrollment-tokens" && request.method === "POST") { - // As Core: a one-time token valid for ten minutes, whose limits the node it enrolls takes; + // As Core: a one-time token valid for ten minutes, whose limits and enrollment ID the node it enrolls takes; // only microsandbox keeps a retained limit above the active one. const input = await body(request); const maxActive = input.max_active ?? 1; - state.enrollmentLimits = { max_active: maxActive, max_retained: state.deployment?.provider === "microsandbox" ? input.max_retained ?? maxActive : maxActive }; - return send(response, 201, { token: `enroll_fixture_${state.nextId++}`, expires_at: new Date(Date.now() + 10 * 60_000).toISOString() }); + const serial = state.nextId++; + const enrollmentId = `00000000-0000-4000-8000-${String(serial).padStart(12, "0")}`; + state.enrollment = { max_active: maxActive, max_retained: state.deployment?.provider === "microsandbox" ? input.max_retained ?? maxActive : maxActive, enrollment_id: enrollmentId }; + return send(response, 201, { token: `enroll_fixture_${serial}`, expires_at: new Date(Date.now() + 10 * 60_000).toISOString(), enrollment_id: enrollmentId }); } let m; if ((m = path.match(/^\/nodes\/([^/]+)\/allocations$/))) return send(response, 200, { data: state.allocations.filter((entry) => entry.node_id === m[1]) }); @@ -405,12 +412,12 @@ async function executorCredentialRoute(request, response, projectId, environment /** * A node as it first registers with the last enrollment command: not yet connected, - * provider not ready, with the token's limits. Like Core, it has no diagnostic - * field until one is reported. + * provider not ready, with the token's limits and enrollment ID. Like Core, it has no + * diagnostic field until one is reported. */ function registeredNode(nodeId) { - const limits = state.enrollmentLimits ?? { max_active: 1, max_retained: 1 }; - return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; + const { enrollment_id = null, ...limits } = state.enrollment ?? { max_active: 1, max_retained: 1 }; + return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), enrollment_id, online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; } const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-provider$/; @@ -488,7 +495,7 @@ async function harnessRoute(request, response, path) { async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("credentials") ?? "configured", url.searchParams.get("installers") !== "none"); + reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("credentials") ?? "configured", url.searchParams.get("installers") !== "none", url.searchParams.get("artifacts") ?? undefined); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { @@ -496,8 +503,8 @@ async function fixtureRoute(request, response, url) { return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/node" && request.method === "POST") { - // { id, ...fields }: registers the node on first use, then applies the fields (online, provider_ready, diagnostic); - // an empty diagnostic removes the field, as Core omits it. + // { id, ...fields }: registers the node on first use, then applies the fields (online, provider_ready, diagnostic, + // or another command's enrollment_id); an empty diagnostic removes the field, as Core omits it. const { id: nodeId, ...fields } = await body(request); let node = state.nodes.find((entry) => entry.id === nodeId); if (!node) state.nodes.push(node = registeredNode(nodeId)); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 234e73263..6d637cd00 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -4,7 +4,7 @@ import { expectManagementBoundary, openConsole, setNode, writes } from "./consol test.afterEach(async ({ request }) => expectManagementBoundary(request)); -test("adds a node: host requirements, a countdown, the same command after closing, a new one after expiry, then registration", async ({ page, request }) => { +test("adds a node: host requirements, a countdown, the same command after closing, a new one after expiry, then its own node's registration", async ({ page, request }) => { await page.clock.install(); // Core counts a token's ten minutes on its own clock; the page's clock stands in for it, so fast-forwarding expires a command. await page.route("**/core/v1/sandbox/enrollment-tokens", async (route) => { @@ -19,6 +19,7 @@ test("adds a node: host requirements, a countdown, the same command after closin await expect(add.getByText("Docker at /var/run/docker.sock for that user, enforcing CPU and memory limits")).toBeVisible(); await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeVisible(); await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB")).toBeVisible(); + await expect(add.getByText(/^Can reach http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes must reach https:\/\/core\.example\.com$/)).toBeVisible(); await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0); // The fixture console runs on loopback, where another machine can't download from it. await expect(add.getByRole("note")).toContainText("other machines can't reach"); @@ -34,12 +35,16 @@ test("adds a node: host requirements, a countdown, the same command after closin const progress = add.getByRole("status", { name: "Registration progress" }); await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/); - // Closing keeps the command for the next opening. + // Closing keeps the command for the next opening. Meanwhile another command's node, with the + // same limits, registers: it reports another enrollment ID, so it is not this command's node. const first = await field.inputValue(); await add.getByRole("button", { name: "Close dialog" }).click(); await expect(add).toBeHidden(); + await setNode(request, { id: "node-other", name: "edge-05", enrollment_id: "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a" }); await page.getByRole("button", { name: "Add node" }).click(); await expect(field).toHaveValue(first); + await expect(page.getByRole("table", { name: "Sandbox nodes" })).toContainText("edge-05"); + await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/); // Once expired, a new command is issued only on request, for the same limits. await page.clock.fastForward("10:30"); @@ -52,7 +57,7 @@ test("adds a node: host requirements, a countdown, the same command after closin await expect(field).not.toHaveValue(first); // The node registers while the dialog is closed and the command lapses: reopening reads the - // node list, and the command's node outranks its expiry. + // node list, and the node reporting the command's enrollment ID outranks its expiry. await add.getByRole("button", { name: "Close dialog" }).click(); await expect(add).toBeHidden(); await setNode(request, { id: "node-new", name: "edge-04" }); @@ -90,6 +95,16 @@ test("adds a node: host requirements, a countdown, the same command after closin await expect(add.getByLabel("Sandboxes at once")).toHaveValue("2"); }); +test("says the console has no node files for the provider and issues no command", async ({ page, request }) => { + // A thin bundle: the console holds no node files at all. + await openConsole(page, request, "nodes", { nodeArtifacts: [] }); + await page.getByRole("button", { name: "Add node" }).click(); + const add = page.getByRole("dialog", { name: "Add node" }); + await expect(add.getByRole("status")).toHaveText("This console has no node files for Docker. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh."); + await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); + expect(await writes(request)).toEqual([]); +}); + test("removes a node after confirmation", async ({ page, request }) => { await openConsole(page, request, "nodes"); await page.getByRole("button", { name: "Remove edge-03" }).click(); diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index 1b4b13270..a2eafe282 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -39,7 +39,7 @@ export function node(id: string, overrides: Partial = {}): SandboxN cpu_count: 8, available_memory_bytes: 1024, available_disk_bytes: 2048, running: 1, snapshots: 0, last_seen_at: "2026-09-24T00:00:00Z", max_active: 4, max_retained: 8, active: 1, reserved: 0, retained: 0, cleanup_pending: 0, - created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example", + created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example", enrollment_id: null, ...overrides, }; } diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 88ff352bf..685a4d018 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,7 +1,7 @@ import { createPortal } from "react-dom"; import { Check, Copy, Terminal, TriangleAlert } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; -import type { SandboxAdminClient, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@agents-core-web/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; @@ -10,9 +10,9 @@ import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxRequestError } from "../../lib/sandbox-labels"; import { useCopy } from "../api-keys/IssuedKey"; import { sandboxCoreOrigin, sandboxSetupOrigin } from "./core-origin"; -import type { SandboxConsoleConfig } from "./console-config"; +import { nodeFilesAvailable, type SandboxConsoleConfig } from "./console-config"; import { nodeInstallCommand, nodeLogCommand } from "./enrollment-command"; -import { enrolledNode, enrollmentProgress, formatCountdown, hostPrerequisites, progressSteps, USER_MANAGER_RESTART, type EnrollmentTarget, type StepState } from "./node-enrollment"; +import { enrolledNode, enrollmentProgress, formatCountdown, hostPrerequisites, progressSteps, USER_MANAGER_RESTART, type StepState } from "./node-enrollment"; /** The host requirements open by default until this browser has shown them once. */ const REQUIREMENTS_SEEN = "agents-core-web.node-requirements-seen"; @@ -27,25 +27,20 @@ function rememberRequirementsSeen() { const DEFAULT_ACTIVE = "2"; const DEFAULT_RETAINED = "8"; -/** A command, with the nodes registered before it and the limits it approved, which identify its node. */ -interface Enrollment extends EnrollmentTarget { - token: string; - expires_at: string; -} - /** * 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. + * sandboxes it runs at once. A console that reports no node files for the + * deployment's provider (`node_artifacts`) says so instead and issues no command. * * The page keeps this dialog mounted, so a command survives closing it: it is * shown again until it expires or its node connects. An expired command is * replaced only when the administrator asks. After the command, the dialog - * follows the new node through the node list: read on each opening, every few - * seconds while open, and once more at expiry, since a node registered by the - * command outranks its expiry. + * follows the node that reports the command's `enrollment_id` through the node + * list: read on each opening, every few seconds while open, and once more at + * expiry, since a node registered by the command outranks its expiry. */ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, fresh, onClose, onRefresh }: { client: SandboxAdminClient; @@ -64,7 +59,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const [active, setActive] = useState(DEFAULT_ACTIVE); const [retained, setRetained] = useState(DEFAULT_RETAINED); const [busy, setBusy] = useState(false); - const [enrollment, setEnrollment] = useState(null); + const [enrollment, setEnrollment] = useState(null); const [appeared, setAppeared] = useState<{ id: string; at: number } | null>(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); @@ -79,6 +74,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const sourceUrl = sandboxCoreOrigin(window.location.origin); const coreUrl = sandboxCoreOrigin(deployment.core_url || window.location.origin); const available = Boolean(consoleConfig.node_installer && sourceUrl && coreUrl); + // Without the provider's node files the installer would fail on the host, so no command is issued. + const nodeFiles = nodeFilesAvailable(consoleConfig, deployment.provider); // The command downloads from this console's own address. A loopback one (or any // address sandboxSetupOrigin refuses for guests) resolves to the node host itself. const localOnly = sourceUrl !== null && sandboxSetupOrigin(sourceUrl) === null; @@ -93,7 +90,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : !inRange(retainedLimit) ? t("Enter a whole number from 1 to 1,000,000.") : inRange(activeLimit) && retainedLimit < activeLimit ? t("Enter at least the number of sandboxes at once.") : null; const limitsReady = !activeProblem && !retainedProblem; - const node = enrollment ? enrolledNode(nodes, enrollment, suspends) : null; + const node = enrollment ? enrolledNode(nodes, enrollment) : null; const progress = enrollmentProgress(node, node && appeared?.id === node.id ? appeared.at : undefined, now); // Registration uses the token, so from then on its expiry no longer matters; rerunning // the command on that host resumes with the node's retained identity. @@ -157,25 +154,25 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, setEnrollment(null); setAppeared(null); setError(null); setCopied(false); setCopyFailed(false); } async function generate() { - if (request.current || !available || !limitsReady || activeLimit === null || retainedLimit === null) return; + if (request.current || !available || !nodeFiles || !limitsReady || activeLimit === null || retainedLimit === null) return; const capacity = { max_active: activeLimit, max_retained: retainedLimit }; generation.current++; const controller = new AbortController(); request.current = controller; setBusy(true); setError(null); setCopied(false); setCopyFailed(false); try { - // Capture the current node set before issuing a one-time enrollment token. - const current = await client.listNodes({ signal: controller.signal }); - if (controller.signal.aborted) return; // A node the previous command enrolled since the last read is that command's success: - // follow it instead of folding it into a new command's known nodes. - if (enrollment && enrolledNode(current.data, enrollment, suspends)) { - await onRefresh(); - return; + // follow it instead of issuing another command. + if (enrollment) { + const current = await client.listNodes({ signal: controller.signal }); + if (controller.signal.aborted) return; + if (enrolledNode(current.data, enrollment)) { + await onRefresh(); + return; + } } - const known = new Set(current.data.map((entry) => entry.id)); const result = await client.createEnrollment({ signal: controller.signal }, capacity); if (!controller.signal.aborted) { - setEnrollment({ token: result.token, expires_at: result.expires_at, known, ...capacity }); setAppeared(null); setNow(Date.now()); + setEnrollment({ token: result.token, expires_at: result.expires_at, enrollment_id: result.enrollment_id }); setAppeared(null); setNow(Date.now()); // Seen with the limits; the command comes first now. setRequirementsOpen(false); } @@ -214,7 +211,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, ) : null; const limitsForm = `${id}-limits`; - const footer = !available ? undefined + const footer = !available || (!enrollment && !nodeFiles) ? undefined : !enrollment ? <> @@ -223,7 +220,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : registered ? : <> - {expired ? : null} + {expired && nodeFiles ? : null} ; // Tense tells a step's state: done in the past, the current one waiting, later ones as plain nouns. // Readiness from an unconfirmed read still counts as waiting. @@ -234,14 +231,17 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, ]; const tones: Record = { done: "ok", current: progress.problem ? "warning" : "pending", future: "neutral" }; const steps = progressSteps(progress.stage === "ready" ? "connected" : progress.stage).map((state, index) => ({ state, label: labels[index]![state], tone: tones[state] })); - const problem = progress.problem === "not_connected" - ? { label: t("Not connected yet"), advice: t("The node registered but its service hasn't reached Core.") } + const problem: { label: string; advice: string; help?: string } | null = progress.problem === "not_connected" + ? { label: t("Not connected yet"), advice: t("The node registered but its service hasn't reached Core."), + help: t("The node service keeps retrying while Core is unavailable, and stops once the node is removed.") } : sandboxDiagnosticMessage(progress.problem, locale); return createPortal(
{!available ?

{consoleConfig.node_installer && coreUrl && !sourceUrl ? t("Open this console over HTTPS to add a node: the installer downloads only over HTTPS.") - : t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}

: !enrollment ? ( + : t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}

+ : !enrollment && !nodeFiles ?

{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 })}

+ : !enrollment ? (
{ event.preventDefault(); void generate(); }}>

{t("Set the sandbox limits for the host you want to add.")}

{localOnly ?

: null} @@ -281,7 +281,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, }
{problem && !ready ?
-

{problem.label} {problem.advice}

+

{problem.label} {problem.advice}{problem.help ? {problem.help} : null}

{t("Check the log on the host:")}
: null} {!fresh ?

{t("Connection status unavailable. Refresh to check your node.")}

: null} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index fc8c81a94..d242b4de0 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -70,6 +70,7 @@ .sandbox-add-node-warning svg { flex-shrink: 0; margin-top: 3px; color: var(--warning); } .sandbox-enrollment-problem { display: grid; gap: 6px; } .sandbox-add-node .sandbox-enrollment-problem p { color: var(--fg); } +.sandbox-enrollment-problem p .help-tip { margin-left: 2px; vertical-align: -4px; } .sandbox-log-hint { display: grid; gap: 4px; color: var(--fg-muted); font-size: 12px; } .sandbox-enrollment-progress { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0; padding: 0; list-style: none; } .sandbox-enrollment-progress .future .status-dot { color: var(--fg-muted); } diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts index bc87baec6..37895afdd 100644 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ b/apps/web/src/features/sandbox/console-config.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { sandboxConsoleConfig } from "./console-config"; +import { nodeFilesAvailable, sandboxConsoleConfig } from "./console-config"; afterEach(() => vi.unstubAllGlobals()); describe("bundled console capabilities", () => { @@ -33,6 +33,25 @@ describe("bundled console capabilities", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Bad gateway", { status: 502 }))); await expect(sandboxConsoleConfig(new AbortController().signal)).rejects.toThrow(); }); + it("blocks a provider's command only when the console reports no node files for it", async () => { + const read = async (body: object) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64), ...body })))); + return (await sandboxConsoleConfig(new AbortController().signal))!; + }; + // An older console doesn't report them: nothing is blocked. + const older = await read({}); + expect(older.node_artifacts).toBeUndefined(); + expect(nodeFilesAvailable(older, "docker")).toBe(true); + const docker = await read({ node_artifacts: ["docker"] }); + expect(nodeFilesAvailable(docker, "docker")).toBe(true); + expect(nodeFilesAvailable(docker, "microsandbox")).toBe(false); + // null, like any malformed value, reports none. + for (const node_artifacts of [null, "docker", { docker: true }]) { + const config = await read({ node_artifacts }); + expect(config.node_artifacts).toEqual([]); + expect(nodeFilesAvailable(config, "docker")).toBe(false); + } + }); it("disables sandbox administration only when the console says so", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ sandbox_admin: false })))); expect((await sandboxConsoleConfig(new AbortController().signal))?.sandbox_admin).toBe(false); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index 39ff9cf9c..b90512eb0 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -1,3 +1,6 @@ +/** A provider whose nodes install from files this console serves. */ +export type NodeArtifactProvider = "docker" | "microsandbox"; + export interface SandboxConsoleConfig { sandbox_admin: boolean; node_installer: boolean; @@ -5,6 +8,12 @@ export interface SandboxConsoleConfig { /** The self-hosted executor installer (`/node-install/self-hosted-install.pyz`); false without a verified digest. */ self_hosted_installer: boolean; self_hosted_installer_sha256: string; + /** + * The providers whose node files this console serves. Absent when the console + * does not report them (an older console), which blocks nothing; a reported + * null or malformed value reads as none. + */ + node_artifacts?: NodeArtifactProvider[]; } const SHA256 = /^[a-f0-9]{64}$/; @@ -12,7 +21,8 @@ const SHA256 = /^[a-f0-9]{64}$/; /** * The console's capability flags. Signing in with the Core key grants * administration, so Core reports only its installers (node and self-hosted - * executor, each with its digest) and sandbox administration is available + * executor, each with its digest) and the providers it has node files for; + * sandbox administration is available * unless the console says `sandbox_admin: false`. An installer is offered only * with a well-formed SHA-256 digest. * An absent endpoint (404, an older console) means no sandbox administration; @@ -23,12 +33,23 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise; + const config = await response.json() as Partial> & { node_artifacts?: unknown }; return { sandbox_admin: config.sandbox_admin !== false, node_installer: config.node_installer === true && SHA256.test(config.node_installer_sha256 ?? ""), node_installer_sha256: config.node_installer_sha256 ?? "", self_hosted_installer: config.self_hosted_installer === true && SHA256.test(config.self_hosted_installer_sha256 ?? ""), self_hosted_installer_sha256: config.self_hosted_installer_sha256 ?? "", + ...(config.node_artifacts === undefined ? {} : { node_artifacts: nodeArtifacts(config.node_artifacts) }), }; } + +/** A reported list keeps the providers it names; null or any other value means none. */ +function nodeArtifacts(value: unknown): NodeArtifactProvider[] { + return Array.isArray(value) ? value.filter((entry): entry is NodeArtifactProvider => entry === "docker" || entry === "microsandbox") : []; +} + +/** Whether a node of this provider can install from the console's files; true when the console doesn't report them. */ +export function nodeFilesAvailable(config: SandboxConsoleConfig, provider: string): boolean { + return config.node_artifacts === undefined || config.node_artifacts.some((entry) => entry === provider); +} diff --git a/apps/web/src/features/sandbox/node-enrollment.test.ts b/apps/web/src/features/sandbox/node-enrollment.test.ts index 126993bac..093d3136e 100644 --- a/apps/web/src/features/sandbox/node-enrollment.test.ts +++ b/apps/web/src/features/sandbox/node-enrollment.test.ts @@ -13,15 +13,19 @@ describe("node enrollment", () => { expect(formatCountdown(3_723_000)).toBe("1:02:03"); }); - it("follows the newest new node from registered to ready, and reports it once the installer's wait has passed", () => { - const target = { known: new Set(["old"]), max_active: 4, max_retained: 8 }; - const fresh = node("new", { online: false, provider_ready: false, created_at: "2026-09-25T00:00:02Z" }); - expect(enrolledNode([node("old"), node("earlier", { created_at: "2026-09-25T00:00:01Z" }), fresh], target, true)?.id).toBe("new"); - // A newer node from another command, with other limits, is not this command's. - const other = node("other", { max_active: 3, created_at: "2026-09-25T00:00:03Z" }); - expect(enrolledNode([fresh, other], target, true)?.id).toBe("new"); - expect(enrolledNode([node("retains", { max_retained: 5, created_at: "2026-09-25T00:00:03Z" }), fresh], target, true)?.id).toBe("new"); - expect(enrolledNode([node("docker", { max_retained: 4, created_at: "2026-09-25T00:00:03Z" }), fresh], target, false)?.id).toBe("docker"); + it("follows only the node that reports the command's enrollment ID", () => { + const command = { enrollment_id: "3b0c1f4e-8a2d-4c6b-9e7f-1a2b3c4d5e6f" }; + const fresh = node("new", { enrollment_id: command.enrollment_id, created_at: "2026-09-25T00:00:02Z" }); + // A newer node from another command with the same limits is not this command's, nor is an older node without an ID. + const other = node("other", { enrollment_id: "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a", created_at: "2026-09-25T00:00:03Z" }); + expect(enrolledNode([node("old"), fresh, other], command)?.id).toBe("new"); + expect(enrolledNode([node("old"), other], command)).toBeNull(); + // A command without an ID matches nothing, not even nodes without one. + expect(enrolledNode([node("old"), node("absent", { enrollment_id: undefined })], { enrollment_id: "" })).toBeNull(); + }); + + it("follows the node from registered to ready, and reports it once the installer's wait has passed", () => { + const fresh = node("new", { online: false, provider_ready: false }); expect(enrollmentProgress(null, undefined, 0)).toEqual({ stage: "waiting", problem: "" }); expect(enrollmentProgress(fresh, 0, NODE_READY_WAIT_MS - 1)).toEqual({ stage: "registered", problem: "" }); expect(enrollmentProgress(fresh, 0, NODE_READY_WAIT_MS)).toEqual({ stage: "registered", problem: "not_connected" }); diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 663ceedfe..a6dd7047c 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -1,4 +1,4 @@ -import type { SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxEnrollment, SandboxNode } from "@agents-core-web/agents-client"; import type { MessageKey } from "../../lib/locale-strings"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; @@ -37,10 +37,10 @@ export interface HostPrerequisite { * (probe.go:43 and 75, config/capacity.go:9), else the node reports capacity_insufficient; * - node_install.py:70 needs the user's own systemd session (`systemctl --user`), * which sudo -u and su don't provide; - * - network: node files from the console (node_install.py:94-106), artifacts from - * the manifest's artifact_base_url, the console by default (node_install.py:139-140, - * distribution.py:150-154), Core's /api/v1 (node_spec.py:89, node_install.py:385), - * and sandboxes reach Core as well (node_install.py:213, 220-233). + * - network: node files and artifacts only from the console (`fetch` and + * `metadata` in node_install.py, which never use a release URL), Core's /api/v1 + * (node_spec.py:89, the `register` call in node_install.py), and sandboxes reach + * Core as well (`provider_config` in node_install.py). * `sized` says whether the deployment's sandbox size is known for the capacity item. */ export function hostPrerequisites(provider: "docker" | "microsandbox", sized: boolean): HostPrerequisite[] { @@ -58,7 +58,7 @@ export function hostPrerequisites(provider: "docker" | "microsandbox", sized: bo ...microsandbox, { label: sized ? "CPUs and memory for at least one sandbox: {{size}}" : "CPUs and memory for at least one sandbox" }, { label: "Run the command signed in as that user: over SSH, or with", command: "sudo machinectl shell NODE_USER@" }, - { label: "Can reach {{console}}, {{core}} and the release downloads; sandboxes must reach {{core}}" }, + { label: "Can reach {{console}} and {{core}}; sandboxes must reach {{core}}" }, ]; } @@ -74,27 +74,14 @@ export function formatCountdown(milliseconds: number): string { return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`; } -/** What identifies the node a command enrolls: nodes that existed before it, and the limits it approved. */ -export interface EnrollmentTarget { - known: ReadonlySet; - max_active: number; - max_retained: number; -} - /** - * The newest node registered since the command was issued with the command's - * limits, which Core copies onto the node it enrolls - * (services/agents-api/internal/store/runtime_nodes.go:166); a node from another - * command with other limits is not this one. Only microsandbox keeps its own - * retained limit; Core sets Docker's to the active one. + * The node this command registered: Core copies the command's `enrollment_id` + * onto the node it enrolls. A node enrolled before Core recorded it reports null + * and never matches, nor does anything for a command without an ID. */ -export function enrolledNode(nodes: readonly SandboxNode[], target: EnrollmentTarget, suspends: boolean): SandboxNode | null { - let newest: SandboxNode | null = null; - for (const node of nodes) { - if (target.known.has(node.id) || node.max_active !== target.max_active || (suspends && node.max_retained !== target.max_retained)) continue; - if (!newest || Date.parse(node.created_at) > Date.parse(newest.created_at)) newest = node; - } - return newest; +export function enrolledNode(nodes: readonly SandboxNode[], command: Pick): SandboxNode | null { + if (!command.enrollment_id) return null; + return nodes.find((node) => node.enrollment_id === command.enrollment_id) ?? null; } export type EnrollmentStage = "waiting" | "registered" | "connected" | "ready"; diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 43cf070c5..a43e4d2ec 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -30,6 +30,7 @@ export const chinese = { "Status unconfirmed": "状态待确认", "Maintenance is enabled. New sandbox placement is paused.": "维护模式已开启,暂停分配新沙箱。", "Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.": "节点安装暂不可用,请联系部署管理员在此控制台启用节点安装程序。", + "This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.": "这个控制台没有 {{provider}} 的节点文件。请用离线包安装 Core,或补齐发布制品后重新运行 ./install.sh。", "Run on the host you want to add.": "在需要添加的主机上运行。", "Preparing your command…": "正在准备命令…", "Terminal": "终端", @@ -52,7 +53,7 @@ export const chinese = { "CPUs and memory for at least one sandbox: {{size}}": "CPU 和内存至少够一个沙箱:{{size}}", "CPUs and memory for at least one sandbox": "CPU 和内存至少够一个沙箱", "Run the command signed in as that user: over SSH, or with": "以该用户登录后运行命令:通过 SSH,或使用", - "Can reach {{console}}, {{core}} and the release downloads; sandboxes must reach {{core}}": "能访问 {{console}}、{{core}} 和版本下载地址;沙箱也要能访问 {{core}}", + "Can reach {{console}} and {{core}}; sandboxes must reach {{core}}": "能访问 {{console}} 和 {{core}};沙箱也要能访问 {{core}}", "After a group change, sign in again as that user. If its systemd manager was already running, restart it (or reboot):": "改完用户组后请以该用户重新登录;如果它的 systemd 管理器已在运行,请重启它(或重启主机):", "Copy {{command}}": "复制 {{command}}", "Select the command and copy it manually.": "请选中命令手动复制。", @@ -71,6 +72,7 @@ export const chinese = { "{{backend}} ready": "{{backend}} 已就绪", "Not connected yet": "尚未连接", "The node registered but its service hasn't reached Core.": "节点已注册,但节点服务还没有连上 Core。", + "The node service keeps retrying while Core is unavailable, and stops once the node is removed.": "Core 不可用时,节点服务会一直重试;节点被移除后才会停止。", "Check the log on the host:": "在主机上查看日志:", "Advanced network settings": "高级网络设置", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 700cf3bad..dd8139bb9 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -143,10 +143,10 @@ consumed; the list carries each provider. | --- | --- | --- | | Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), maintenance state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows Core's message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | | Maintenance | `PATCH /core/v1/sandbox/deployment/maintenance` | Enter or leave maintenance to change the provider | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again. **Add node** follows only the node whose `enrollment_id` equals its command's; a node enrolled before Core recorded it reports null and never matches | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range | | Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. Under microsandbox, a node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase and, while suspended, about when Core reclaims it (that time plus the deployment's `suspension.retention_seconds`); a null time shows a dash | -| Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum | +| Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum, with the command's `enrollment_id`, which the node it registers reports. When `/console/config` lists `node_artifacts` without the deployment's provider (null reads as none; an absent field blocks nothing), the dialog says this console has no node files for it and requests no token | | Update node | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**: the name and sandbox limits together (the retained limit only for microsandbox; under Docker, Core sets it to the active limit) | | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion | | Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) |