From a5fb90936b51675b3da16b6359861aa3e80621ac Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 22:21:11 +0000 Subject: [PATCH] Follow the added node by its enrollment ID and say when node files are missing Add node now follows only the node whose enrollment_id equals its command's, instead of guessing from the sandbox limits; a node enrolled before Core recorded the ID never matches. 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 issues no command. The host requirements no longer mention release downloads, and the not-connected hint explains that the node service keeps retrying until the node is removed. --- apps/web/PRODUCT.md | 4 +- apps/web/e2e/console.ts | 11 ++-- apps/web/e2e/fixture-console.mjs | 33 ++++++---- apps/web/e2e/nodes.spec.ts | 21 ++++++- .../src/features/overview/test-fixtures.ts | 2 +- .../src/features/sandbox/NodeEnrollment.tsx | 62 +++++++++---------- .../features/sandbox/SandboxManagerView.css | 1 + .../features/sandbox/console-config.test.ts | 21 ++++++- .../src/features/sandbox/console-config.ts | 25 +++++++- .../features/sandbox/node-enrollment.test.ts | 22 ++++--- .../src/features/sandbox/node-enrollment.ts | 37 ++++------- apps/web/src/lib/locale-strings.ts | 4 +- docs/web/protocol-coverage.md | 4 +- 13 files changed, 153 insertions(+), 94 deletions(-) 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) |