From cc4741ddef52d95da3a176c4c92a754961c37746 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 05:21:03 +0000 Subject: [PATCH 01/10] Build node commands from the public URL, not the browser address The install and uninstall commands downloaded the node installer from the browser's own origin, so a console opened over an SSH tunnel or 127.0.0.1 produced commands that fail on every other host. The reverse proxy already serves /node-install/* from the console, so both commands now use the installation's public_url as the download origin and --source-url. Add node already issues no command for a loopback public URL; the clean-up dialog now says no uninstall command can be given in that case. The browser-origin loopback warnings, their strings and sandboxSetupOrigin go. --- apps/web/DESIGN.md | 17 +++++--- apps/web/PRODUCT.md | 5 ++- apps/web/e2e/nodes.spec.ts | 12 +++--- .../features/sandbox/NodeCleanupDialog.tsx | 17 ++++---- .../src/features/sandbox/NodeEnrollment.tsx | 42 +++++++++---------- .../features/sandbox/SandboxManagerView.css | 3 -- .../features/sandbox/SandboxManagerView.tsx | 10 ++--- .../src/features/sandbox/core-origin.test.ts | 17 ++++---- apps/web/src/features/sandbox/core-origin.ts | 25 +++++------ .../src/features/sandbox/node-commands.tsx | 4 +- .../src/features/sandbox/node-enrollment.ts | 9 ++-- apps/web/src/lib/locale-strings.ts | 6 +-- docs/web/protocol-coverage.md | 4 +- 13 files changed, 83 insertions(+), 88 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 9f24ec16a..7c96d0af0 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -544,17 +544,22 @@ request runs. and, for Docker, that the docker group is root-equivalent), and "No sudo on this host?", with what the node's own user needs and the command without sudo. The log command follows the command last copied; after the no-sudo one it adds the - system service's, for a root shell. Until the installation is read, a line says - it is being checked; a failed read, a loopback public URL, or a console without - the provider's node files replaces the limits with one line saying why (the - failed read with Try again), and the footer offers nothing to generate. + system service's, for a root shell. The command downloads from the + installation's public URL, never the browser's address, so it works as shown on + any host. Until the installation is read, a line says it is being checked; a + failed read, a public URL other machines can't use (loopback or not HTTPS), or a + console without the provider's node files replaces the limits with one line + saying why (the failed read with Try again), and the footer offers nothing to + generate. - **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images (and, for microsandbox, keeps its image store and data), and the no-sudo form behind an "Installed without sudo?" disclosure. A node enrolled with an earlier Core address adds an "Old Core address gone?" - disclosure with the `--force` form; a loopback console carries Add node's amber - note. Done dismisses it and focus returns to the page heading. + disclosure with the `--force` form. The command, too, downloads from the public + URL; without one other machines can use, a single line says the service stays on + the host and no command can be given. Done dismisses it and focus returns to the + page heading. - **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists what it gives up, each point a 600 Ink lead over a Graphite line: weaker isolation (containers share the host kernel; microsandbox gives each sandbox diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 4111b9d23..4c4257585 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -88,9 +88,10 @@ workbench. figures and allocations, enrollment, renaming, sandbox limits and removal; Add node asks for the node's sandbox limits before it issues the one-time command, which installs the node with sudo as a system service (a disclosure gives the command without sudo, as a - user service); it issues none before the installation is read, while the public URL is + user service); both commands download from the installation's public URL, never the + browser's address; it issues none before the installation is read, while the public URL is loopback, or when the console lacks the provider's node files; after Remove, a dialog gives - the host's uninstall command), System (the + the host's uninstall command, or says none can be given without a usable public URL), System (the installation's public address, API base URL, installation ID and source commit, read-only; each harness's default model, set, replaced or cleared there beside its read-only startup state; the sandbox configuration every project shares, with a link to Nodes where it diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 8cf826005..010e82db1 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -20,13 +20,11 @@ test("adds a node: host requirements, a sudo command and one without, a countdow await expect(add.getByText("SELinux is not enforcing (otherwise use the no-sudo command)")).toBeVisible(); await expect(add.getByText("One Core per host: a host already running a node for another Core is refused.")).toBeVisible(); await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB; about 2 GB of disk for the Runtime image")).toBeVisible(); - await expect(add.getByText(/^Reaches http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes reach https:\/\/core\.example\.com$/)).toBeVisible(); + await expect(add.getByText("Reaches https://core.example.com, as do its sandboxes")).toBeVisible(); await expect(add.getByText("parsar-node joins the docker group, which is equivalent to root on this host.")).toBeVisible(); await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0); // Preparing a user instead of using sudo waits behind its disclosure. await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeHidden(); - // 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"); await add.getByLabel("Sandboxes at once").fill("3"); const tokenRequest = () => page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); const issued = tokenRequest(); @@ -38,6 +36,9 @@ test("adds a node: host requirements, a sudo command and one without, a countdow // The token goes on stdin to the checked installer, run with sudo unless the shell is root. await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/); await expect(field).toHaveValue(/\| \$s python3 "\$d\/node-install\.pyz" --enrollment-token-stdin /); + // It downloads from, and names as its source, the public URL, not the loopback address this browser uses. + await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' /); + await expect(field).toHaveValue(/ --source-url 'https:\/\/core\.example\.com' --core-url 'https:\/\/core\.example\.com' /); await expect(add.getByText("If the command is interrupted or the download stalls, run the same command again: the download resumes.")).toBeVisible(); await expect(add.getByRole("timer")).toHaveText(/^Expires in (10:00|9:\d\d)$/); const progress = add.getByRole("status", { name: "Registration progress" }); @@ -132,9 +133,8 @@ test("issues no command before the installation is read, for a loopback public U await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); await page.unroute("**/core/v1/installation"); await add.getByRole("button", { name: "Try again" }).click(); - // Nodes on other machines can't reach a loopback public_url; this replaces the note about the browser's address. + // Nodes on other machines can't reach a loopback public_url. await expect(add.getByRole("status")).toHaveText("Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply"); - await expect(add.getByRole("note")).toHaveCount(0); await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); await add.getByRole("button", { name: "Close dialog" }).click(); @@ -160,7 +160,7 @@ test("removes a node after confirmation", async ({ page, request }) => { await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); // The host still runs the node until it is uninstalled there: with sudo, or as the user that installed it. const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); - await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\n[^]*\n\$s python3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s python3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); await cleanup.getByText("Installed without sudo?").click(); await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveValue(/\npython3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); // Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone. diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx index 6ec17d80b..10cbf5eca 100644 --- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -1,16 +1,14 @@ -import { TriangleAlert } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; -import { sandboxSetupOrigin } from "./core-origin"; import { nodeUninstallCommand, type NodeInstallMode } from "./enrollment-command"; import { CommandBlock } from "./node-commands"; /** A node Core has just removed, with what builds its host's uninstall command. */ export interface NodeCleanup { name: string; - /** This console's address, which the command downloads the installer from. */ - sourceUrl: string; + /** The installation's public URL, which the command downloads the installer from; null when other machines can't use it. */ + sourceUrl: string | null; installationId: string; scriptDigest: string; provider: string; @@ -26,19 +24,20 @@ export interface NodeCleanup { * itself, so a node installed without sudo gets its own command, run as that * node's user. A node enrolled with an earlier address may find it gone; then * `--force` skips only that confirmation. Nothing deletes sandboxes, volumes or - * images. + * images. Like Add node's, the command downloads from the installation's public + * URL; while other machines can't use it (loopback, say) the dialog says so instead. */ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCleanup | null; open: boolean; onClose: () => void }) { const { t, i18n } = useTranslation("sandbox"); // Sentences run on with a space in English and without one in Chinese. const join = (...sentences: string[]) => sentences.join(i18n.resolvedLanguage?.startsWith("zh") ? "" : " "); - const command = (mode: NodeInstallMode, force = false) => cleanup + const command = (mode: NodeInstallMode, force = false) => cleanup?.sourceUrl ? nodeUninstallCommand({ sourceUrl: cleanup.sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, mode, force }) : ""; return {t("Done")}}> - {cleanup ?
+ {cleanup && !cleanup.sourceUrl ?
+

{t("{{name}} is removed from Core, but its service and files stay on the host. An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.", { name: cleanup.name })}

+
: cleanup ?

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

- {/* Like Add node's command, this one downloads from the console's own address. */} - {sandboxSetupOrigin(cleanup.sourceUrl) === null ?

: null}

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

diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index ca1b0238f..a8d88fed3 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,5 +1,4 @@ import { createPortal } from "react-dom"; -import { TriangleAlert } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@agents-core-web/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -10,7 +9,7 @@ import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import { sandboxCoreOrigin, sandboxSetupOrigin } from "./core-origin"; +import { nodeSourceUrl, sandboxCoreOrigin } from "./core-origin"; import { nodeFilesAvailable, type SandboxConsoleConfig } from "./console-config"; import { nodeInstallCommand, nodeLogCommand, type NodeInstallMode } from "./enrollment-command"; import { CommandBlock, CopyCommand, HostRequirements, NoSudoGuide } from "./node-commands"; @@ -35,13 +34,15 @@ const DEFAULT_RETAINED = "8"; * one-time enrollment command that approves them * (`POST /core/v1/sandbox/enrollment-tokens`). Only microsandbox suspends * sandboxes, so only it asks for a retained limit; Docker retains exactly the - * sandboxes it runs at once. The command runs the installer with sudo, which - * installs the node as a system service; a disclosure offers the same command - * without sudo, which installs a user service, and the log hint follows the - * command last copied. No command is issued until the installation is read: one - * whose public URL is loopback (`local_only`), an unreadable one, or a console - * that reports no node files for the deployment's provider (`node_artifacts`) - * says so instead. Each opening, and each return to the window while open, reads + * sandboxes it runs at once. The command downloads the installer from the + * installation's public URL, never the browser's address, and runs it with + * sudo, which installs the node as a system service; a disclosure offers the + * same command without sudo, which installs a user service, and the log hint + * follows the command last copied. No command is issued until the installation + * is read: one whose public URL other machines can't use (loopback, as + * `local_only` says, or not HTTPS), an unreadable one, or a console that + * reports no node files for the deployment's provider (`node_artifacts`) says + * so instead. Each opening, and each return to the window while open, reads * the installation and the console again, so a fix on the Core host shows * without a reload. * @@ -84,9 +85,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const reading = useRef(false); const generation = useRef(0); const request = useRef(null); - const sourceUrl = sandboxCoreOrigin(window.location.origin); - const coreUrl = sandboxCoreOrigin(deployment.core_url || window.location.origin); - const available = Boolean(consoleConfig.node_installer && sourceUrl && coreUrl); + // Nodes download from, and reach Core at, the public URL; the browser's address may be a tunnel or loopback. + const sourceUrl = installation.data ? nodeSourceUrl(installation.data) : null; + const coreUrl = sandboxCoreOrigin(deployment.core_url); + const available = consoleConfig.node_installer; const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null; const backend = provider === "microsandbox" ? "microsandbox" : "Docker"; // Nodes and their sandboxes reach Core at its public URL, so a loopback one serves no other machine; @@ -94,14 +96,11 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // is issued, nor before the installation is read: a failed read (an older Core, say) proves nothing. const blocker: { text: string; failed?: boolean } | null = installation.data === undefined ? installation.isError ? { text: t("The installation couldn't be read, so no command can be issued."), failed: true } : { text: t("Checking this installation's public URL…") } - : installation.data.local_only + : !sourceUrl || !coreUrl ? { text: t("Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply") } : !nodeFilesAvailable(consoleConfig, deployment.provider) ? { text: t("This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.", { provider: backend }) } : null; - // 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 consoleLoopback = sourceUrl !== null && sandboxSetupOrigin(sourceUrl) === null; // Core takes whole numbers from 1 to a million, with the retained limit at least the active one. const suspends = deployment.provider === "microsandbox"; const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); @@ -123,8 +122,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const lapsed = Boolean(enrollment && expiresAt <= now); // Expired only once a read begun after the expiry found no node for the command. const expired = lapsed && checkedAt >= expiresAt; - const commandFor = (mode: NodeInstallMode) => enrollment && provider && available && (registered || !expired) && !ready - ? nodeInstallCommand({ token: enrollment.token, coreUrl: coreUrl!, sourceUrl: sourceUrl!, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, mode }) : ""; + const commandFor = (mode: NodeInstallMode) => enrollment && provider && available && sourceUrl && coreUrl && (registered || !expired) && !ready + ? nodeInstallCommand({ token: enrollment.token, coreUrl, sourceUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, mode }) : ""; const command = commandFor("sudo"); const nodeId = node?.id ?? null; const polling = open && enrollment !== null && !ready && (registered || !expired); @@ -223,7 +222,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, } const size = deployment.specification?.resources; const values = { - console: sourceUrl ?? "", core: coreUrl ?? "", + core: coreUrl ?? "", size: size ? t("{{cpus}} CPU · {{memory}}", { cpus: size.cpus, memory: formatBytes(size.memory_mib * 2 ** 20) }) : "", }; const requirements = provider ? <> @@ -257,16 +256,13 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : 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.")}

+ {!available ?

{t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}

: !enrollment && blocker ? blocker.failed ?

{blocker.text}

:

{blocker.text}

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

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

- {consoleLoopback ?

: null}
{t("The most sandboxes Core places on this node at the same time.")} setActive(event.target.value)} aria-invalid={Boolean(activeProblem)} /> diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 7c59df740..60b69ea31 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -56,7 +56,6 @@ /* Add node: the countdown sits beside Copy; requirements fold away once seen. */ .sandbox-command-heading .sandbox-command-expiry { margin-left: auto; color: var(--fg-muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; } -.sandbox-add-node-warning, .sandbox-enrollment-problem { padding: 10px 12px; font-size: 12.5px; @@ -64,8 +63,6 @@ border: 1px solid color-mix(in srgb, var(--warning) 26%, var(--line)); border-radius: 8px; } -.sandbox-add-node .sandbox-add-node-warning { display: flex; gap: 8px; align-items: flex-start; color: var(--fg); } -.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; } diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 0bb303e6a..f4c9986eb 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -20,7 +20,7 @@ import { NodeList } from "./NodeList"; import { NodeDetail } from "./NodeDetail"; import { NodeEditDialog } from "./NodeEditDialog"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; -import { sandboxCoreOrigin } from "./core-origin"; +import { nodeSourceUrl } from "./core-origin"; import "./SandboxManagerView.css"; /** Nodes: the deployment provider, the node list and one node's detail (`#nodes?id=…`). */ @@ -176,12 +176,12 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig setRemoveTarget(null); if (params.id === target.id) navigate("nodes"); refresh(); - // The host still runs the node's service until it is uninstalled there. - const sourceUrl = sandboxCoreOrigin(window.location.origin); - if (consoleConfig.node_installer && sourceUrl && snapshot) { + // The host still runs the node's service until it is uninstalled there. Add node has read the installation. + const installation = queryClient.getQueryData(installationQuery.queryKey); + if (consoleConfig.node_installer && installation && snapshot) { const { deployment } = snapshot; setCleanup({ node: { - name: target.name || target.id, sourceUrl, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, + name: target.name || target.id, sourceUrl: nodeSourceUrl(installation), installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, provider: deployment.provider, oldAddress: target.core_url !== deployment.core_url ? target.core_url : null, }, open: true }); } diff --git a/apps/web/src/features/sandbox/core-origin.test.ts b/apps/web/src/features/sandbox/core-origin.test.ts index e1e47bba8..fbcb8fb13 100644 --- a/apps/web/src/features/sandbox/core-origin.test.ts +++ b/apps/web/src/features/sandbox/core-origin.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sandboxCoreOrigin, sandboxSetupOrigin } from "./core-origin"; +import { nodeSourceUrl, sandboxCoreOrigin } from "./core-origin"; describe("sandbox deployment Core origin", () => { it.each([ @@ -11,14 +11,11 @@ describe("sandbox deployment Core origin", () => { it.each(["", "/v1", "http://remote.example", "http://host.localhost", "https://core.example/v1", "https://core.example/path/..", "https://user:secret@core.example", "https://@core.example", "https://core.example?", "https://core.example#", "https://core.example//", "https://core.example\\path", "https://co\nre.example", "file://core.example"])("rejects unsafe or non-origin input %s", (input) => expect(sandboxCoreOrigin(input)).toBeNull()); }); -describe("hosted sandbox setup origin", () => { - it.each(["http://localhost:8080", "https://localhost:8080", "https://localhost.", "https://host.localhost", "https://127.0.0.1", "https://127.1", "https://127.0.0.2", "https://[::1]", "https://[0:0:0:0:0:0:0:1]", "http://core.example", "https://core.example/v1", "https://0.0.0.0", "https://0", "https://[::]", "https://[0:0:0:0:0:0:0:0]", "https://[::ffff:127.0.0.1]", "https://[::ffff:7f00:2]", "https://[::ffff:0.0.0.0]"])("rejects an origin guests cannot use: %s", (origin) => { - expect(sandboxSetupOrigin(origin)).toBeNull(); - }); - it.each(["https://10.74.84.167:18443", "https://[2001:db8::1]", "https://[::ffff:a4a:54a7]"])("accepts a routable address: %s", (origin) => { - expect(sandboxSetupOrigin(origin)).toBe(origin); - }); - it("keeps normal HTTPS setup automatic", () => { - expect(sandboxSetupOrigin("https://CORE.example:8443/")).toBe("https://core.example:8443"); +describe("node command source", () => { + it("is the installation's public URL, never a loopback, missing or plain HTTP one", () => { + expect(nodeSourceUrl({ public_url: "https://Core.example.com/", local_only: false })).toBe("https://core.example.com"); + expect(nodeSourceUrl({ public_url: "http://127.0.0.1:8091", local_only: true })).toBeNull(); + expect(nodeSourceUrl({ public_url: null, local_only: false })).toBeNull(); + expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false })).toBeNull(); }); }); diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index e6a74b613..d63dd7536 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -1,4 +1,6 @@ -import { isLoopbackHostname, isValidDirectCoreBaseUrl } from "../../lib/connection"; +import type { CoreInstallation } from "@agents-core-web/agents-client"; + +import { isValidDirectCoreBaseUrl } from "../../lib/connection"; export function sandboxCoreOrigin(value: string): string | null { const candidate = value.trim(); @@ -8,15 +10,14 @@ export function sandboxCoreOrigin(value: string): string | null { return url.origin; } -export function sandboxSetupOrigin(value: string): string | null { - const origin = sandboxCoreOrigin(value); - if (!origin) return null; - const url = new URL(origin); - const hostname = url.hostname.replace(/\.$/, ""); - const mappedIPv4 = /^\[::ffff:([0-9a-f]+):([0-9a-f]+)\]$/.exec(hostname); - const mappedHigh = mappedIPv4 ? Number.parseInt(mappedIPv4[1] ?? "", 16) : null; - const mappedLow = mappedIPv4 ? Number.parseInt(mappedIPv4[2] ?? "", 16) : null; - const unusable = isLoopbackHostname(hostname) || hostname === "0.0.0.0" || hostname === "[::]" - || (mappedHigh !== null && (mappedHigh >>> 8 === 127 || (mappedHigh === 0 && mappedLow === 0))); - return url.protocol === "https:" && !unusable ? origin : null; +/** + * Where the node commands download the installer, and the `--source-url` they + * pass it: the installation's public URL, whose reverse proxy sends + * `/node-install/*` to this console. Unlike the browser's address, it is the + * same from every machine. Null when other machines can't use it: loopback + * (`local_only`), missing, or not an HTTPS origin. + */ +export function nodeSourceUrl(installation: Pick): string | null { + if (installation.local_only || !installation.public_url) return null; + return sandboxCoreOrigin(installation.public_url); } diff --git a/apps/web/src/features/sandbox/node-commands.tsx b/apps/web/src/features/sandbox/node-commands.tsx index 26be2c95c..e475c089f 100644 --- a/apps/web/src/features/sandbox/node-commands.tsx +++ b/apps/web/src/features/sandbox/node-commands.tsx @@ -52,8 +52,8 @@ export function CopyCommand({ value }: { value: string }) { ; } -/** The addresses and sandbox size the requirement labels name. */ -export type RequirementValues = { console: string; core: string; size: string }; +/** The public URL and sandbox size the requirement labels name. */ +export type RequirementValues = { core: string; size: string }; function PrerequisiteList({ items, values }: { items: HostPrerequisite[]; values: RequirementValues }) { const { t } = useTranslation("sandbox"); diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 9bc1d8bd3..e1f6b0d51 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -36,9 +36,10 @@ export interface HostPrerequisite { * - `host_capacity`: the host's CPUs and memory hold one sandbox of the * deployment's size (else the node reports capacity_insufficient); the Runtime * image needs about 2 GB of disk; - * - network: node files and artifacts only from the console (`fetch` and - * `metadata`, which never use a release URL), Core's /api/v1 (node_spec.py, the - * `register` call), and sandboxes reach Core as well (`provider_config`). + * - network: node files and artifacts only from the console at the public URL + * (`fetch` and `metadata`, which never use a release URL), Core's /api/v1 at the + * same URL (node_spec.py, the `register` call), and sandboxes reach Core as well + * (`provider_config`). * `sized` says whether the deployment's sandbox size is known for the capacity item. */ export function hostRequirements(provider: "docker" | "microsandbox", sized: boolean): HostPrerequisite[] { @@ -50,7 +51,7 @@ export function hostRequirements(provider: "docker" | "microsandbox", sized: boo ? { label: "Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)" } : { label: "/dev/kvm in the kvm group (hardware or nested virtualization) and the libraries microsandbox links (glibc)" }, { label: sized ? "CPUs and memory for at least one sandbox: {{size}}; about 2 GB of disk for the Runtime image" : "CPUs and memory for at least one sandbox; about 2 GB of disk for the Runtime image" }, - { label: "Reaches {{console}} and {{core}}; sandboxes reach {{core}}" }, + { label: "Reaches {{core}}, as do its sandboxes" }, ]; } diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index f35ed4e3c..5f403b0ac 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -57,7 +57,6 @@ export const chinese = { "Checking this installation's public URL…": "正在检查这个安装的公网地址…", "The installation couldn't be read, so no command can be issued.": "无法读取安装信息,暂时不能生成命令。", "If root ran it, it is a system service:": "如果是 root 运行的,它是系统服务:", - "This console is open at {{origin}}, which other machines can't reach. On another machine, replace it in the command with the console's HTTPS address.": "当前控制台地址 {{origin}} 其他机器访问不到。在其他机器上运行前,请把命令里的这个地址换成控制台的 HTTPS 地址。", "It never deletes sandboxes, volumes or images.": "它不会删除任何沙箱、卷或镜像。", "It keeps microsandbox's image store and sandbox data, and prints how to remove them by hand.": "它会保留 microsandbox 的镜像存储和沙箱数据,并打印手动删除的方法。", "Old Core address gone?": "旧 Core 地址已失效?", @@ -66,7 +65,7 @@ export const chinese = { "Uninstall command with --force": "带 --force 的卸载命令", "CPUs and memory for at least one sandbox: {{size}}; about 2 GB of disk for the Runtime image": "CPU 和内存至少够一个沙箱:{{size}};Runtime 镜像约需 2 GB 磁盘", "CPUs and memory for at least one sandbox; about 2 GB of disk for the Runtime image": "CPU 和内存至少够一个沙箱;Runtime 镜像约需 2 GB 磁盘", - "Reaches {{console}} and {{core}}; sandboxes reach {{core}}": "能访问 {{console}} 和 {{core}};沙箱也要能访问 {{core}}", + "Reaches {{core}}, as do its sandboxes": "能访问 {{core}},它的沙箱也要能访问", "The command creates the parsar-node service user and a system service. It installs no software; if something is missing it stops and says what to install.": "命令会创建 parsar-node 服务用户和一个系统服务。它不安装任何软件;缺少什么时会停下并说明要装什么。", "parsar-node joins the docker group, which is equivalent to root on this host.": "parsar-node 会加入 docker 组,这在这台主机上等同于 root 权限。", "No sudo on this host?": "这台主机没有 sudo?", @@ -75,6 +74,7 @@ export const chinese = { "Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply": "节点需要一个其他机器及其沙箱都能访问的 HTTPS 公网地址:请在 config.json 里设置 public_url,然后运行 parsar apply", "Clean up the host": "清理主机", "{{name}} is removed from Core. To remove its service and files from the host, run:": "{{name}} 已从 Core 移除。要删除它在主机上的服务和文件,请运行:", + "{{name}} is removed from Core, but its service and files stay on the host. An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.": "{{name}} 已从 Core 移除,但它的服务和文件仍留在主机上。卸载命令需要其他机器能访问的 HTTPS 公开地址,而当前安装没有。", "Uninstall command": "卸载命令", "Installed without sudo?": "安装时没用 sudo?", "Run this as that user instead:": "请改为以该用户运行:", @@ -86,8 +86,6 @@ export const chinese = { "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.": "请选中命令手动复制。", - "This console is open at {{origin}}, which other machines can't reach. To add another machine, open the console at its HTTPS address, then generate the command.": "当前控制台地址 {{origin}} 其他机器访问不到。要添加其他机器,请用控制台的 HTTPS 地址打开后再生成命令。", - "Open this console over HTTPS to add a node: the installer downloads only over HTTPS.": "请通过 HTTPS 打开此控制台再添加节点:安装程序只通过 HTTPS 下载。", "Expires in {{time}}": "{{time}} 后过期", "Registration progress": "注册进度", "Registered · {{name}}": "已注册 · {{name}}", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 5171432e5..61d99e602 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -117,7 +117,7 @@ without the installer shows no Connect a host. | Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | -| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key and on an active project's page; `public_url` in a self-hosted Session's Connect a host command; `local_only` stops Add node from issuing a command; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | +| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key and on an active project's page; `public_url` in a self-hosted Session's Connect a host command and as the download origin and `--source-url` of the node install and uninstall commands (the reverse proxy sends `/node-install/*` to the console); `local_only`, or a `public_url` that is not an HTTPS origin, stops Add node from issuing a command and Clean up the host from giving one; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions @@ -146,7 +146,7 @@ consumed; the list carries each 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. **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, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; a "No sudo on this host?" disclosure gives the same command without sudo (a user service) and what that user needs, and the log hint follows the command last copied. Until the installation is read, when it can't be read, when it is `local_only`, or when `/console/config` lists `node_artifacts` without the deployment's provider (null reads as none; an absent field blocks nothing), the dialog says why and requests no token; it reads both again on opening and when the window regains focus | +| 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. The command runs the installer with sudo (a system service) and passes the token on standard input; a "No sudo on this host?" disclosure gives the same command without sudo (a user service) and what that user needs, and the log hint follows the command last copied. The command downloads the installer from the installation's `public_url`. Until the installation is read, when it can't be read, when it is `local_only` (or its `public_url` is not an HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider (null reads as none; an absent field blocks nothing), the dialog says why and requests no token; it reads both again on opening and when the window regains focus | | 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, and a Clean up the host dialog then gives the host's uninstall command (with sudo, and without it behind a disclosure; for a node enrolled with another address than the deployment's, also with `--force`, which skips the installer's confirmation with Core) | | 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) | From 2a2577cc3a096bd36a1ca0f7650fd26c905aa5ad Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 05:23:58 +0000 Subject: [PATCH 02/10] Show every node's sandbox limit and what its host holds The Nodes list showed active / limit only for microsandbox, and a node's page had no limit at all, so a Docker node's limit was invisible before and after Edit node. Both now show active / max_active for every provider. Edit node adds the host's CPUs and memory from the node detail read, each sandbox's size and at most how many of those fit. --- apps/web/DESIGN.md | 5 ++++ apps/web/PRODUCT.md | 6 ++-- apps/web/e2e/nodes.spec.ts | 6 ++++ apps/web/src/features/sandbox/NodeDetail.tsx | 3 +- .../src/features/sandbox/NodeEditDialog.tsx | 30 ++++++++++++++++--- apps/web/src/features/sandbox/NodeList.tsx | 6 ++-- .../features/sandbox/SandboxManagerView.tsx | 2 ++ .../sandbox/deployment-specification.test.ts | 12 +++++++- .../sandbox/deployment-specification.ts | 10 +++++++ apps/web/src/lib/locale-strings.ts | 4 +++ docs/web/protocol-coverage.md | 2 +- 11 files changed, 74 insertions(+), 12 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 7c96d0af0..417a8faf8 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -567,6 +567,11 @@ request runs. group) and limited use (trusted workloads, or hosts without KVM). The footer holds Use Docker (outline) and Keep microsandbox (primary), which takes focus; closing or Escape keeps microsandbox too. +- **Edit node**: the name, then the sandbox limit with one 12px Graphite line under + it once the node's heartbeat has the host's CPUs and memory: the host, each + sandbox's size and at most how many fit. The Nodes list and a node's Capacity + show "Active / limit" for Docker and microsandbox alike, so a saved limit shows + where it was set. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 4c4257585..ebb33aacc 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -84,8 +84,10 @@ workbench. backend (microsandbox by default; Docker only after a confirmation of its weaker isolation) or E2B account, the size of each sandbox (own machines only; E2B takes the template build's), a review, and advanced settings - with the complete form — then the node list with each node's capacity, host - figures and allocations, enrollment, renaming, sandbox limits and removal; Add node + with the complete form — then the node list with each node's capacity (active + sandboxes against its limit, for every backend), host figures and allocations, + enrollment, renaming, sandbox limits (beside the host's CPUs and memory and at most + how many sandboxes of the deployment's size they hold) and removal; Add node asks for the node's sandbox limits before it issues the one-time command, which installs the node with sudo as a system service (a disclosure gives the command without sudo, as a user service); both commands download from the installation's public URL, never the diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 010e82db1..96d6d4aa0 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -296,11 +296,17 @@ test("reports a failed sandbox change in a dialog, then reads the state again", test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => { await openConsole(page, request, "nodes?id=node-local"); + // A Docker node shows its limit too, and the edit shows what the host holds. + const capacity = page.getByRole("region", { name: "Capacity" }); + await expect(capacity).toContainText("Active / limit"); + await expect(capacity).toContainText("5 / 8"); await page.getByRole("button", { name: "Edit node" }).click(); const edit = page.getByRole("dialog", { name: "Edit node" }); + await expect(edit.getByText("Host: 16 CPU · 64 GiB. Each sandbox: 2 CPU · 4 GiB. Suggested: at most 8 at once.")).toBeVisible(); await edit.getByLabel("Name").fill("core-01-large"); await edit.getByLabel("Sandboxes at once").fill("6"); await edit.getByRole("button", { name: "Save" }).click(); await expect(edit).toBeHidden(); await expect(page.getByRole("heading", { name: "core-01-large", level: 1 })).toBeVisible(); + await expect(capacity).toContainText("5 / 6"); }); diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 268b13b5b..92eff952f 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -73,9 +73,10 @@ export function NodeDetail({ node, allocations, stale, suspension }: {
{t("Added")}
{formatDateTime(seconds(node.created_at), locale)}
- {/* Active slots, cleanup and host resources are on Sandbox metrics; only what it does not show is here. */} + {/* Cleanup and host resources are on Sandbox metrics; the node's limit is here as well, beside Edit node that sets it. */}
+ {suspends ? : null} {suspends ? : null} diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 86e89f751..d6c1c22c8 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -1,20 +1,28 @@ import { useId, useState } from "react"; -import type { SandboxAdminClient, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxAdminClient, SandboxNode, SandboxResources } from "@agents-core-web/agents-client"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; +import { formatBytes } from "../../lib/format"; import { sandboxRequestError } from "../../lib/sandbox-labels"; +import { nodeDetailQuery } from "../fleet/fleet-queries"; +import { sandboxesThatFit } from "./deployment-specification"; /** * A node's name and sandbox limits (`PATCH /core/v1/sandbox/nodes/{id}`). Core * takes all three together. Only microsandbox suspends sandboxes, so only it * shows the retained limit; for Docker the saved one is kept, raised to at least - * the active limit because Core requires it. + * the active limit because Core requires it. Under the limit, the host's CPUs + * and memory from the node's last heartbeat, each sandbox's size and how many + * of those the host holds. */ -export function NodeEditDialog({ client, node, onClose, onSaved }: { +export function NodeEditDialog({ client, node, size, onClose, onSaved }: { client: SandboxAdminClient; node: SandboxNode | null; + /** Each sandbox's CPUs and memory, from the deployment. */ + size: SandboxResources | null; onClose: () => void; onSaved: () => void; }) { @@ -35,6 +43,19 @@ export function NodeEditDialog({ client, node, onClose, onSaved }: { const activeProblem = activeLimit === null || activeLimit < 1 || activeLimit > 1_000_000 ? t("Enter a whole number from 1 to 1,000,000.") : null; const retainedProblem = suspends && (retainedLimit === null || activeLimit === null || retainedLimit < activeLimit || retainedLimit > 1_000_000) ? t("Enter at least the number of sandboxes at once.") : null; const ready = node !== null && !nameProblem && !activeProblem && !retainedProblem && !busy; + // The node detail read adds the host's total memory to its CPU count. + const detail = useQuery({ ...nodeDetailQuery(node?.id ?? "", "1h"), enabled: node !== null }); + const host = detail.data?.id === node?.id ? detail.data?.host : undefined; + const hostCpus = host?.effective_cpu_cores ?? node?.cpu_count ?? null; + const hostMemory = host?.total_memory_bytes ?? null; + const fit = sandboxesThatFit({ cpus: hostCpus, memoryBytes: hostMemory }, size); + const measure = (cpus: number, memory: number) => t("{{cpus}} CPU · {{memory}}", { cpus, memory: formatBytes(memory) }); + // Sentences run on with a space in English and without one in Chinese. + const hostFacts = hostCpus !== null && hostMemory !== null ? [ + t("Host: {{host}}.", { host: measure(hostCpus, hostMemory) }), + ...(size ? [t("Each sandbox: {{size}}.", { size: measure(size.cpus, size.memory_mib * 2 ** 20) })] : []), + ...(fit !== null && fit > 0 ? [t("Suggested: at most {{count}} at once.", { count: fit })] : []), + ].join(locale === "zh" ? "" : " ") : null; async function save() { if (!ready || !node) return; @@ -68,8 +89,9 @@ export function NodeEditDialog({ client, node, onClose, onSaved }: {
{t("The most sandboxes Core places on this node at the same time.")} - setActive(event.target.value)} aria-invalid={Boolean(activeProblem)} /> + setActive(event.target.value)} aria-invalid={Boolean(activeProblem)} aria-describedby={hostFacts ? `${id}-host` : undefined} /> {activeProblem ? {activeProblem} : null} + {hostFacts ? {hostFacts} : null}
{suspends ? (
diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index c7017656d..d19454f3a 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -42,7 +42,7 @@ export function seconds(value: string | null): number | null { export function NodeList({ nodes, allocations, stale, disabled, suspends = false, onOpen, onRemove }: { nodes: readonly SandboxNode[]; - /** microsandbox: sandboxes sleep as snapshots, so the list shows active and suspended counts. */ + /** microsandbox: sandboxes sleep as snapshots, so the list also shows suspended counts. */ suspends?: boolean; allocations: readonly SandboxAllocation[]; stale: boolean; @@ -60,7 +60,7 @@ export function NodeList({ nodes, allocations, stale, disabled, suspends = false {t("Node")} {t("Status")} - {suspends ? {t("Active / limit")} : null} + {t("Active / limit")} {suspends ? {t("Suspended")}{t("Suspended sandboxes keep their state as a snapshot on the node and resume on the Session's next Turn. They count toward the retained limit, not the active one.")} : null} {t("Last seen")} {t("Added")} @@ -79,7 +79,7 @@ export function NodeList({ nodes, allocations, stale, disabled, suspends = false onOpen(node)} openLabel={t("Open {{name}}", { name })} idLabel={t("Node ID")} /> {diagnostic ? : null} - {suspends ? {node.active} / {node.max_active} : null} + {node.active} / {node.max_active} {suspends ? {suspendedSandboxes(node)} : null} {node.last_seen_at ? formatRelative(seconds(node.last_seen_at), now, locale) : t("Never")} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index f4c9986eb..86f9768b3 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -21,6 +21,7 @@ import { NodeDetail } from "./NodeDetail"; import { NodeEditDialog } from "./NodeEditDialog"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; import { nodeSourceUrl } from "./core-origin"; +import { sandboxSize } from "./deployment-specification"; import "./SandboxManagerView.css"; /** Nodes: the deployment provider, the node list and one node's detail (`#nodes?id=…`). */ @@ -263,6 +264,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig key={editTarget?.id ?? "closed"} client={client} node={editTarget} + size={snapshot ? sandboxSize(snapshot.deployment) : null} onClose={() => setEditTarget(null)} onSaved={() => { const saved = editTarget; diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index fe306d494..8caee4ab7 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { defaultSandboxResources, distributionRuntime, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; import { isRuntimeReleaseField } from "./runtime-release"; import standardSizes from "./standard-sizes.json"; import type { SandboxSpecification } from "@agents-core-web/agents-client"; @@ -71,3 +71,13 @@ describe("deployment resources and Runtime", () => { } }); }); + +describe("sandboxes a host holds", () => { + it("is the smaller of what its CPUs and its memory hold, and unknown without either or the size", () => { + const size = { cpus: 2, memory_mib: 4096 }; + expect(sandboxesThatFit({ cpus: 16, memoryBytes: 64 * 2 ** 30 }, size)).toBe(8); + expect(sandboxesThatFit({ cpus: 64, memoryBytes: 18 * 2 ** 30 }, size)).toBe(4); + expect(sandboxesThatFit({ cpus: null, memoryBytes: 64 * 2 ** 30 }, size)).toBeNull(); + expect(sandboxesThatFit({ cpus: 16, memoryBytes: 64 * 2 ** 30 }, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 52ab24559..4019b8c04 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -45,6 +45,16 @@ export function sandboxSize(deployment: SandboxDeployment): SandboxResources | n return deployment.specification?.resources ?? templateBuildSize(deployment); } +/** + * At most how many sandboxes of this size a host's CPUs and memory hold at once, + * each at its full limits; null while a figure or the size is unknown. A + * suggestion for a node's limit, which Core itself never derives. + */ +export function sandboxesThatFit(host: { cpus: number | null; memoryBytes: number | null }, size: Pick | null): number | null { + if (!size || host.cpus === null || host.memoryBytes === null || size.cpus <= 0 || size.memory_mib <= 0) return null; + return Math.min(Math.floor(host.cpus / size.cpus), Math.floor(host.memoryBytes / (size.memory_mib * 2 ** 20))); +} + /** The paired console serves one matched distribution; Core persists approval. */ export async function distributionRuntime(signal: AbortSignal): Promise { const response = await fetch("/node-install/manifest.json", { signal, credentials: "include", redirect: "error" }); diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 5f403b0ac..c979502c4 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -118,6 +118,10 @@ export const chinese = { "Node": "节点", "Health": "健康状态", "Active / limit": "活跃 / 上限", + "Sandboxes Core has placed on this node, against the most it places here at once.": "Core 在这台节点上放置的沙箱数,相对于同时运行的上限。", + "Host: {{host}}.": "主机:{{host}}。", + "Each sandbox: {{size}}.": "每个沙箱:{{size}}。", + "Suggested: at most {{count}} at once.": "建议同时运行不超过 {{count}} 个。", "Reserved": "已预留", "Retained / limit": "保留 / 上限", "Cleanup pending": "待清理", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 61d99e602..3bd98137d 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -144,7 +144,7 @@ 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. **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 | +| 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. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | | 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, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; a "No sudo on this host?" disclosure gives the same command without sudo (a user service) and what that user needs, and the log hint follows the command last copied. The command downloads the installer from the installation's `public_url`. Until the installation is read, when it can't be read, when it is `local_only` (or its `public_url` is not an HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider (null reads as none; an absent field blocks nothing), the dialog says why and requests no token; it reads both again on opening and when the window regains focus | | 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) | From 3ff65a31996552fe86b7291b4e3c49cab9feaebe Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 05:26:34 +0000 Subject: [PATCH 03/10] Keep the Nodes page usable after Core refuses a change Every failed sandbox write was treated as an uncertain outcome: the node list turned to Status unconfirmed and all writes stayed locked until a refresh, even when Core had clearly refused the change. Only no answer, a timeout or a 5xx is uncertain now; any other 4xx shows Core's reason in an error toast and leaves the page as it was. The sandbox administration hint appears only for sandbox_admin_not_configured, other 403s show Core's message, and the Nodes copy no longer tells the administrator to ask the deployment administrator. --- apps/web/DESIGN.md | 12 +++++----- apps/web/e2e/nodes.spec.ts | 22 ++++++++++++++++--- .../src/features/sandbox/NodeEnrollment.tsx | 2 +- .../features/sandbox/SandboxManagerView.tsx | 19 ++++++++++------ apps/web/src/lib/locale-strings.ts | 9 ++++---- apps/web/src/lib/locale.test.ts | 4 +++- apps/web/src/lib/sandbox-diagnostic.ts | 4 ++-- apps/web/src/lib/sandbox-labels.test.ts | 15 +++++++++++++ apps/web/src/lib/sandbox-labels.ts | 17 +++++++++++++- 9 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/lib/sandbox-labels.test.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 417a8faf8..73d45a106 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -655,11 +655,13 @@ at zero). ### Notices Errors are popups, never lines inserted into a page. A failed action whose outcome -needs a decision (an uncertain sandbox change) opens an error dialog with Core's -reason and the next step as its primary button. A failed refresh that keeps the last -data on screen, projects that could not be read, and other failed actions are -reported in an error toast with the reason. Only when a page or section has nothing to show -does an error state take the place of its content; errors inside a dialog or a form +needs a decision (a sandbox change with no answer, a timeout or a 5xx) opens an +error dialog with the reason and the next step as its primary button. A failed +refresh that keeps the last data on screen, projects that could not be read, and +other failed actions, Core's clear refusal of a sandbox change among them, are +reported in an error toast with the reason; a refusal leaves the page usable as it +was. Only when a page or section has nothing to show does an error state take the +place of its content; errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline, 12px corners, 12.5px Graphite) state bounded aggregation. A standing warning that needs action, such as the Nodes page naming nodes still bound to an old Core address, is an diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 96d6d4aa0..0db837dd8 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole, resetFixture, setNode, writes } from "./console"; +import { expectManagementBoundary, failNext, openConsole, resetFixture, setNode, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -282,10 +282,11 @@ test("keeps the saved size and Runtime for the same backend, and starts another test("reports a failed sandbox change in a dialog, then reads the state again", async ({ page, request }) => { await openConsole(page, request, "nodes"); + // Core's answer is lost, so the change may have been saved. await page.route("**/core/v1/sandbox/deployment/maintenance", (route) => route.fulfill({ - status: 409, + status: 503, contentType: "application/json", - body: JSON.stringify({ error: { message: "The deployment changed.", type: "conflict_error", code: "sandbox_deployment_conflict", param: null } }), + body: JSON.stringify({ error: { message: "Unavailable.", type: "server_error", code: null, param: null } }), })); await page.getByRole("button", { name: "Enter maintenance to change provider" }).click(); const failed = page.getByRole("dialog", { name: "Couldn't confirm the sandbox change" }); @@ -294,6 +295,21 @@ test("reports a failed sandbox change in a dialog, then reads the state again", await expect(page.getByRole("button", { name: "Enter maintenance to change provider" })).toBeEnabled(); }); +test("keeps the page usable when Core refuses a sandbox change, and shows Core's reason", async ({ page, request }) => { + await openConsole(page, request, "nodes", { sandbox: "none" }); + await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 403, message: "This console is read-only." }); + await page.getByRole("button", { name: "Own machines" }).click(); + await page.getByRole("button", { name: "microsandbox Recommended" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + const save = page.getByRole("button", { name: "Save configuration" }); + await save.click(); + // A clear refusal changed nothing: no "couldn't confirm" dialog, and the same page to try again. + await expect(page.getByText("This console is read-only.")).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await save.click(); + await expect(page.getByText("c0ffee000000")).toBeVisible(); +}); + test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => { await openConsole(page, request, "nodes?id=node-local"); // A Docker node shows its limit too, and the edit shows what the host holds. diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index a8d88fed3..5454a7cd9 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -256,7 +256,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : sandboxDiagnosticMessage(progress.problem, locale); return createPortal(
- {!available ?

{t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}

+ {!available ?

{t("This console serves no node installer. For a console deployed by hand, point CORE_CONSOLE_NODE_PAYLOAD_DIR at the distribution's node payload and restart it.")}

: !enrollment && blocker ? blocker.failed ?

{blocker.text}

:

{blocker.text}

diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 86f9768b3..89e2e8306 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -10,7 +10,7 @@ import { ErrorState } from "../../components/ErrorState"; import { useFailureToast, useToast } from "../../components/Toast"; import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { installationQuery } from "../../lib/installation"; -import { sandboxConfigurationRejection, sandboxRequestError } from "../../lib/sandbox-labels"; +import { sandboxConfigurationRejection, sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; import type { SandboxConsoleConfig } from "./console-config"; import { sandboxAdmin, sandboxConsoleConfigQuery, sandboxDeploymentQuery, sandboxScope, sandboxSnapshotQuery, type SandboxSnapshot } from "./sandbox-queries"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; @@ -52,7 +52,7 @@ function SandboxAccess() { const { data: config, isPending: checking, isFetching, isError, refetch } = useQuery(sandboxConsoleConfigQuery); if (isError && config === undefined) return <>

{t("The console configuration could not be read. Refresh to try again.")}

; if (checking) return <>

{t("Connecting to this console's Core…")}

; - if (!config?.sandbox_admin) return <>

{t("Sandbox administration is not configured on this console. Ask the deployment administrator to configure access.")}

; + if (!config?.sandbox_admin) return <>

{t("Sandbox administration is not configured on this console.")}

; return ; } @@ -77,14 +77,14 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const [removeError, setRemoveError] = useState(null); // After a removal, the host's uninstall command; it stays for the closing animation. const [cleanup, setCleanup] = useState<{ node: NodeCleanup; open: boolean } | null>(null); - // When a deployment write last had an uncertain outcome; only a read begun after it confirms the state again. + // When a deployment write last had an uncertain outcome (no response, a timeout or a 5xx); only a read begun after it confirms the state again. const [uncertainSince, setUncertainSince] = useState(null); const setupNeedsRefresh = uncertainSince !== null && !(snapshot && snapshot.readAt > uncertainSince); // The state on screen is Core's last successful read, with no uncertain write since. const confirmed = snapshot !== null && !query.isError && !setupNeedsRefresh; // Writes additionally wait for any read in flight. const fresh = confirmed && !loading; - // A failed write opens a dialog with Core's reason; the error stays for the closing animation. + // A write with an uncertain outcome opens a dialog with the reason; the error stays for the closing animation. const [writeFailure, setWriteFailure] = useState<{ error: unknown; open: boolean } | null>(null); const { refetch } = query; const refresh = useCallback(() => { @@ -138,9 +138,14 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig if (!controller.signal.aborted) { // Core rejected the configuration and saved nothing: the wizard explains why. if (fromWizard && sandboxConfigurationRejection(error) !== null) throw error; - setUncertainSince(performance.now()); setWriteFailure({ error, open: true }); - // Nothing re-reads on its own: the operator refreshes to confirm. A later visit reads again. - void queryClient.invalidateQueries({ queryKey: sandboxScope, refetchType: "none" }); + if (sandboxWriteUncertain(error)) { + setUncertainSince(performance.now()); setWriteFailure({ error, open: true }); + // Nothing re-reads on its own: the operator refreshes to confirm. A later visit reads again. + void queryClient.invalidateQueries({ queryKey: sandboxScope, refetchType: "none" }); + } else { + // Core refused the change, so nothing changed: its reason, and the page stays usable as it was. + toast.show(t("Core rejected the sandbox change"), { tone: "error", detail: sandboxRequestError(error, locale), key: "sandbox-write" }); + } } } finally { if (!controller.signal.aborted) setBusy(false); } return false; diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index c979502c4..7e0e110eb 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -29,7 +29,7 @@ export const chinese = { "Add your first node": "添加第一个节点", "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 serves no node installer. For a console deployed by hand, point CORE_CONSOLE_NODE_PAYLOAD_DIR at the distribution's node payload and restart it.": "此控制台没有提供节点安装程序。手动部署的控制台需要把 CORE_CONSOLE_NODE_PAYLOAD_DIR 指向发行包的节点载荷目录,然后重启控制台。", "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…": "正在准备命令…", @@ -171,7 +171,8 @@ export const chinese = { "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", "The selected sandbox node is unavailable or has no capacity.": "所选沙箱节点不可用或容量不足。", "Sign in to the console again to access sandbox management.": "请重新登录控制台以访问沙箱管理。", - "Sandbox administration is not configured on this console. Ask the deployment administrator to configure access.": "此控制台尚未配置沙箱管理权限。请联系部署管理员配置。", + "Sandbox administration is not configured on this console.": "此控制台尚未配置沙箱管理权限。", + "Core rejected the sandbox change": "Core 拒绝了此次沙箱更改", "The console configuration could not be read. Refresh to try again.": "无法读取控制台配置。请刷新重试。", "The sandbox request was rejected. Refresh to check the current state.": "沙箱请求被拒绝。请刷新并检查当前状态。", "The sandbox service is unavailable. Refresh to check the current state.": "沙箱服务不可用。请刷新并检查当前状态。", @@ -198,11 +199,11 @@ export const chinese = { "Compute state unconfirmed": "计算状态未确认", "Check the assigned node and its provider, then refresh. The last recorded compute state does not confirm that execution is running.": "请检查已分配节点及其后端,然后刷新。上次记录的计算状态不能确认执行仍在运行。", "Sandbox ownership mismatch": "沙箱归属不一致", - "Ask the deployment administrator to reconcile the assigned resource and its ownership record before resuming execution.": "请联系部署管理员核对已分配资源及其归属记录,再恢复执行。", + "Reconcile the assigned resource and its ownership record before resuming execution.": "请核对已分配资源及其归属记录,再恢复执行。", "Sandbox provider unavailable": "沙箱后端不可用", "Restore the provider on the assigned node, then refresh. A connected node alone does not confirm that its sandbox provider is ready.": "请恢复已分配节点上的运行后端,然后刷新。节点在线并不代表其沙箱后端已就绪。", "Sandbox state needs attention": "沙箱状态需要检查", - "Ask the deployment administrator to inspect the assigned node and resource, then refresh.": "请联系部署管理员检查已分配节点和资源,然后刷新。", + "Inspect the assigned node and resource, then refresh.": "请检查已分配节点和资源,然后刷新。", "Docker unavailable": "Docker 不可用", "The node can't reach the Docker daemon. Check that Docker is running and the node can use its socket.": "节点连不上 Docker 守护进程。请确认 Docker 正在运行,且节点能访问它的 socket。", "Docker limits unsupported": "Docker 无法限制资源", diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index bec65c0c5..a27d16588 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -29,7 +29,9 @@ describe("sandbox localization", () => { }); it("maps conflicts and unconfigured access without leaking raw backend diagnostics", () => { expect(sandboxRequestError(new AgentCoreError("raw secret", 409, "runtime_node_in_use"), "zh")).toContain("保留资源"); - expect(sandboxRequestError(new AgentCoreError("raw secret", 503, "sandbox_admin_not_configured"), "zh")).toContain("尚未配置"); + expect(sandboxRequestError(new AgentCoreError("raw secret", 503, "sandbox_admin_not_configured"), "zh")).toBe("此控制台尚未配置沙箱管理权限。"); + // Any other refusal is Core's to explain. + expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh")).toBe("This console is read-only."); expect(sandboxRequestError(new Error("raw secret"), "zh")).not.toContain("raw secret"); }); }); diff --git a/apps/web/src/lib/sandbox-diagnostic.ts b/apps/web/src/lib/sandbox-diagnostic.ts index 5da511f2b..186854da6 100644 --- a/apps/web/src/lib/sandbox-diagnostic.ts +++ b/apps/web/src/lib/sandbox-diagnostic.ts @@ -18,7 +18,7 @@ const diagnostics: Record = { }, ownership_mismatch: { label: "Sandbox ownership mismatch", - advice: "Ask the deployment administrator to reconcile the assigned resource and its ownership record before resuming execution.", + advice: "Reconcile the assigned resource and its ownership record before resuming execution.", }, provider_unavailable: { label: "Sandbox provider unavailable", @@ -76,7 +76,7 @@ export function sandboxDiagnosticMessage(value?: string, locale: Locale = "en"): if (!value) return null; const message = Object.hasOwn(diagnostics, value) ? diagnostics[value]! : { label: "Sandbox state needs attention", - advice: "Ask the deployment administrator to inspect the assigned node and resource, then refresh.", + advice: "Inspect the assigned node and resource, then refresh.", } as const; return { label: translate(locale, message.label), advice: translate(locale, message.advice) }; } diff --git a/apps/web/src/lib/sandbox-labels.test.ts b/apps/web/src/lib/sandbox-labels.test.ts new file mode 100644 index 000000000..ac2d281a5 --- /dev/null +++ b/apps/web/src/lib/sandbox-labels.test.ts @@ -0,0 +1,15 @@ +import { AgentCoreError } from "@agents-core-web/agents-client"; +import { describe, expect, it } from "vitest"; + +import { sandboxWriteUncertain } from "./sandbox-labels"; + +describe("sandbox write outcome", () => { + it("is uncertain without a response, on a timeout or a 5xx, and certain on Core's refusal", () => { + expect(sandboxWriteUncertain(new TypeError("Failed to fetch"))).toBe(true); + expect(sandboxWriteUncertain(new AgentCoreError("Unavailable.", 503))).toBe(true); + expect(sandboxWriteUncertain(new AgentCoreError("Timed out.", 408))).toBe(true); + expect(sandboxWriteUncertain(new AgentCoreError("Withheld.", 400, "sandbox_configuration_unconfirmed"))).toBe(true); + expect(sandboxWriteUncertain(new AgentCoreError("Read-only.", 403))).toBe(false); + expect(sandboxWriteUncertain(new AgentCoreError("Changed.", 409, "sandbox_deployment_conflict"))).toBe(false); + }); +}); diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 8359449e9..621ce34db 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -17,14 +17,29 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { else if (error.code === "runtime_node_in_use") key = "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal."; else if (error.code === "runtime_node_unavailable") key = "The selected sandbox node is unavailable or has no capacity."; else if (error.code === "sandbox_deployment_conflict") key = "Sandbox deployment is already configured. Refresh to inspect the saved provider and Core origin."; + else if (error.code === "sandbox_admin_not_configured") key = "Sandbox administration is not configured on this console."; else if (error.status === 401) key = "Sign in to the console again to access sandbox management."; - else if (error.status === 403 || error.code === "sandbox_admin_not_configured") key = "Sandbox administration is not configured on this console. Ask the deployment administrator to configure access."; + // Any other refusal is Core's to explain; its message names the reason. + else if (error.status === 403 && error.message) return error.message; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; else key = "The sandbox request was rejected. Refresh to check the current state."; } else if (error instanceof Error && error.message === "removal_unconfirmed") key = "Core did not confirm node removal. Refresh to check its state."; return translate(locale, key); } +/** + * Whether a failed sandbox write may still have taken effect, so the page must + * read Core again before trusting what it shows: no response at all (a network + * failure or an abort), a timeout, a 5xx (an unreadable response is a 502), + * or a configuration write whose rejection the client withheld. Any other 4xx + * is Core's clear refusal, and nothing changed. + */ +export function sandboxWriteUncertain(error: unknown): boolean { + if (!(error instanceof AgentCoreError)) return true; + if (error.code === "sandbox_configuration_unconfirmed") return true; + return error.status < 400 || error.status === 408 || error.status >= 500; +} + /** * Core's own reason when it rejects a deployment configuration it cannot serve, * such as E2B with a loopback public_url; null for any other failure. Nothing From 9b403185cf381434c9e6f3e23e0b8c27a805c683 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 05:28:18 +0000 Subject: [PATCH 04/10] Mark nodes on an old Core address in their status The Nodes page named nodes bound to an old Core address in a banner, but their rows still read Available although Core places no new sandboxes on them. Such a node's status on the list and on its page is now Old address, with Remove and add again under it. --- apps/web/DESIGN.md | 7 +++-- apps/web/PRODUCT.md | 3 ++ apps/web/e2e/nodes.spec.ts | 10 ++++++ apps/web/src/features/sandbox/NodeDetail.tsx | 10 ++++-- .../web/src/features/sandbox/NodeList.test.ts | 18 ++++++----- apps/web/src/features/sandbox/NodeList.tsx | 31 ++++++++++++++----- .../features/sandbox/SandboxManagerView.css | 4 +++ .../features/sandbox/SandboxManagerView.tsx | 4 +-- apps/web/src/lib/locale-strings.ts | 2 ++ docs/web/protocol-coverage.md | 2 +- 10 files changed, 68 insertions(+), 23 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 73d45a106..13b939d25 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -665,9 +665,10 @@ place of its content; errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline, 12px corners, 12.5px Graphite) state bounded aggregation. A standing warning that needs action, such as the Nodes page naming nodes still bound to an old Core address, is an -amber-tinted line at the top of the page body. Partial-data chips are amber-tinted pills -with a help tip. Safety notices (a key shown once, a destructive consequence) stay -visible in body text. +amber-tinted line at the top of the page body; each of those nodes' status reads +Old address (amber dot) with "Remove and add again" under it in 12px Graphite. +Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key +shown once, a destructive consequence) stay visible in body text. ### Onboarding Signing in and the console tour share one frame: a dark stage on the left (always diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index ebb33aacc..b6dce3dd4 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -102,6 +102,9 @@ workbench. - A node whose provider is not ready names the reason (Docker unreachable, no Docker limits, missing Runtime image, no KVM, missing microsandbox components, a host too small) and its fix in the help tip beside its status, wherever that status shows. +- A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes + list and its page its status is Old address, with "Remove and add again", never + Available. - **E2B deployments** have no machines: the Nodes entry becomes Sandbox backend, and Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no node column diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 0db837dd8..91d2309b3 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -170,6 +170,16 @@ test("removes a node after confirmation", async ({ page, request }) => { await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeFocused(); }); +test("marks a node on an old Core address in its row, beside each node's limit", async ({ page, request }) => { + await openConsole(page, request, "nodes", { installation: "stale" }); + const row = page.getByRole("row", { name: /core-01/ }); + await expect(row).toContainText("Old address"); + await expect(row).toContainText("Remove and add again"); + await expect(row).not.toContainText("Available"); + // Docker nodes show their limit too. + await expect(row).toContainText("5 / 8"); +}); + test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { await openConsole(page, request, "nodes", { sandbox: "none" }); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 92eff952f..45f565b1b 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -9,7 +9,7 @@ import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "../../lib/sand import { sandboxStateLabel } from "../../lib/sandbox-labels"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { phaseTiming } from "./allocation-phase"; -import { nodeState, NodeStatus, seconds } from "./NodeList"; +import { nodeState, NodeStatus, OldAddressHint, seconds } from "./NodeList"; /** Why a node is not serving: disconnected, or the reason its provider is not ready. */ function nodeDiagnostic(node: SandboxNode): string { @@ -36,9 +36,11 @@ function PhaseTime({ allocation, retentionSeconds, now }: { allocation: SandboxA return {text}; } -export function NodeDetail({ node, allocations, stale, suspension }: { +export function NodeDetail({ node, allocations, coreUrl, stale, suspension }: { node: SandboxNode; allocations: readonly SandboxAllocation[]; + /** The deployment's address; a node enrolled with another one is on an old address. */ + coreUrl: string; stale: boolean; /** The deployment's idle suspension policy; only microsandbox has one. */ suspension: SandboxDeployment["suspension"]; @@ -52,6 +54,7 @@ export function NodeDetail({ node, allocations, stale, suspension }: { // Only microsandbox suspends sandboxes into snapshots; Docker retains nothing. const suspends = node.provider === "microsandbox"; const diagnostic = stale ? "" : nodeDiagnostic(node); + const state = nodeState(node, own, stale, coreUrl); const count = (value: number) => formatInteger(value, locale); return ( <> @@ -60,8 +63,9 @@ export function NodeDetail({ node, allocations, stale, suspension }: {
{t("Status")}
- + {diagnostic ? : null} + {state === "old_address" ? : null}
diff --git a/apps/web/src/features/sandbox/NodeList.test.ts b/apps/web/src/features/sandbox/NodeList.test.ts index 05fa27d8d..7fc6647be 100644 --- a/apps/web/src/features/sandbox/NodeList.test.ts +++ b/apps/web/src/features/sandbox/NodeList.test.ts @@ -6,16 +6,20 @@ import { nodeState } from "./NodeList"; const allocation = (nodeId: string, diagnostic: SandboxAllocation["diagnostic"]) => ({ id: `alloc_${nodeId}`, node_id: nodeId, diagnostic }) as SandboxAllocation; +const core = "https://core.example"; + describe("node state", () => { - it("reports stale data and reachability before anything else", () => { - expect(nodeState(node("a", { online: false }), [], true)).toBe("unconfirmed"); - expect(nodeState(node("a", { online: false, cleanup_pending: 2 }), [], false)).toBe("offline"); - expect(nodeState(node("a", { provider_ready: false }), [], false)).toBe("degraded"); + it("reports stale data, an old address and reachability before anything else", () => { + expect(nodeState(node("a", { online: false }), [], true, core)).toBe("unconfirmed"); + expect(nodeState(node("a", { core_url: "https://core-old.example" }), [], false, core)).toBe("old_address"); + expect(nodeState(node("a", { online: false, core_url: "https://core-old.example" }), [], false, core)).toBe("old_address"); + expect(nodeState(node("a", { online: false, cleanup_pending: 2 }), [], false, core)).toBe("offline"); + expect(nodeState(node("a", { provider_ready: false }), [], false, core)).toBe("degraded"); }); it("asks for attention on pending cleanup or a diagnosed allocation of this node only", () => { - expect(nodeState(node("a", { cleanup_pending: 1 }), [], false)).toBe("attention"); - expect(nodeState(node("a"), [allocation("a", "resource_missing")], false)).toBe("attention"); - expect(nodeState(node("a"), [allocation("b", "resource_missing")], false)).toBe("available"); + expect(nodeState(node("a", { cleanup_pending: 1 }), [], false, core)).toBe("attention"); + expect(nodeState(node("a"), [allocation("a", "resource_missing")], false, core)).toBe("attention"); + expect(nodeState(node("a"), [allocation("b", "resource_missing")], false, core)).toBe("available"); }); }); diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index d19454f3a..832e5f738 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -9,20 +9,26 @@ import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeHealth, suspendedSandboxes } from "../fleet/fleet-model"; -export type NodeState = "unconfirmed" | "offline" | "degraded" | "attention" | "available"; +export type NodeState = "unconfirmed" | "old_address" | "offline" | "degraded" | "attention" | "available"; -/** One status per node: stale data and reachability first, then anything reported to look at. */ -export function nodeState(node: SandboxNode, allocations: readonly SandboxAllocation[], stale: boolean): NodeState { +/** + * One status per node: stale data first; then a node enrolled with another + * address than the deployment's `coreUrl`, which gets no new sandboxes until it + * is removed and added again; then reachability, then anything reported to look at. + */ +export function nodeState(node: SandboxNode, allocations: readonly SandboxAllocation[], stale: boolean, coreUrl: string): NodeState { if (stale) return "unconfirmed"; + if (node.core_url !== coreUrl) return "old_address"; const health = nodeHealth(node); if (health !== "available") return health; const attention = node.cleanup_pending > 0 || allocations.some((allocation) => allocation.node_id === node.id && allocation.diagnostic); return attention ? "attention" : "available"; } -const stateTone: Record = { unconfirmed: "neutral", offline: "danger", degraded: "warning", attention: "warning", available: "ok" }; +const stateTone: Record = { unconfirmed: "neutral", old_address: "warning", offline: "danger", degraded: "warning", attention: "warning", available: "ok" }; const stateLabel: Record = { unconfirmed: "Status unconfirmed", + old_address: "Old address", offline: "Offline", degraded: "Provider unavailable", attention: "Needs attention", @@ -34,14 +40,22 @@ export function NodeStatus({ state }: { state: NodeState }) { return ; } +/** What to do about a node on an old address, under its status. */ +export function OldAddressHint() { + const { t } = useTranslation("sandbox"); + return {t("Remove and add again")}; +} + export function seconds(value: string | null): number | null { if (!value) return null; const parsed = Date.parse(value); return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000); } -export function NodeList({ nodes, allocations, stale, disabled, suspends = false, onOpen, onRemove }: { +export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspends = false, onOpen, onRemove }: { nodes: readonly SandboxNode[]; + /** The deployment's address; a node enrolled with another one is on an old address. */ + coreUrl: string; /** microsandbox: sandboxes sleep as snapshots, so the list also shows suspended counts. */ suspends?: boolean; allocations: readonly SandboxAllocation[]; @@ -70,7 +84,7 @@ export function NodeList({ nodes, allocations, stale, disabled, suspends = false {nodes.map((node) => { const name = node.name || node.id; - const state = nodeState(node, allocations, stale); + const state = nodeState(node, allocations, stale, coreUrl); // A degraded node names the reason its provider is not ready. const diagnostic = state === "degraded" ? nodeProviderDiagnostic(node) : ""; return ( @@ -78,7 +92,10 @@ export function NodeList({ nodes, allocations, stale, disabled, suspends = false onOpen(node)} openLabel={t("Open {{name}}", { name })} idLabel={t("Node ID")} /> - {diagnostic ? : null} + + {diagnostic ? : null} + {state === "old_address" ? : null} + {node.active} / {node.max_active} {suspends ? {suspendedSandboxes(node)} : null} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 60b69ea31..9aa721a63 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -52,6 +52,10 @@ /* Node list and node detail. */ .nodes-nowrap { white-space: nowrap; } .node-status-fact, .node-diagnostic { display: inline-flex; align-items: center; gap: 4px; } +/* A node on an old address: what to do, under its status. */ +.node-status-hint { display: block; color: var(--fg-muted); font-size: 12px; } +.node-status-fact { flex-wrap: wrap; } +.node-status-fact .node-status-hint { flex-basis: 100%; } .sandbox-manager-page .console-section-title h2 .heading-count { margin-left: 6px; } /* Add node: the countdown sits beside Copy; requirements fold away once seen. */ diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 89e2e8306..3927ecfc6 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -258,7 +258,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig />
{status} - {selected ? : snapshot && !loading ? ( + {selected ? : snapshot && !loading ? ( {t("Back")}} /> ) : null}
@@ -302,7 +302,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {hostedNodes ?
{nodes.length - ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> + ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> : }
: null} : null} diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 7e0e110eb..69347cffa 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -28,6 +28,8 @@ export const chinese = { "Node ID": "节点 ID", "Add your first node": "添加第一个节点", "Status unconfirmed": "状态待确认", + "Old address": "地址已过期", + "Remove and add again": "移除并重新添加", "Maintenance is enabled. New sandbox placement is paused.": "维护模式已开启,暂停分配新沙箱。", "This console serves no node installer. For a console deployed by hand, point CORE_CONSOLE_NODE_PAYLOAD_DIR at the distribution's node payload and restart it.": "此控制台没有提供节点安装程序。手动部署的控制台需要把 CORE_CONSOLE_NODE_PAYLOAD_DIR 指向发行包的节点载荷目录,然后重启控制台。", "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。", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 3bd98137d..c61112fb5 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -143,7 +143,7 @@ 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. **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 | +| 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, and its status there and on its page reads Old address instead of its health. **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. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | | 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, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; a "No sudo on this host?" disclosure gives the same command without sudo (a user service) and what that user needs, and the log hint follows the command last copied. The command downloads the installer from the installation's `public_url`. Until the installation is read, when it can't be read, when it is `local_only` (or its `public_url` is not an HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider (null reads as none; an absent field blocks nothing), the dialog says why and requests no token; it reads both again on opening and when the window regains focus | From 0aae56126ef51f4a3101e43992ee30440f381ac2 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 05:29:26 +0000 Subject: [PATCH 05/10] Point to the next step once an added node is ready Add node ended at the node's Connected line with only Done, leaving a new administrator to find the rest of Getting started. While the checklist is open, the ready state now names the next step in one line: set a default model while that step is to do, otherwise finish Getting started, each with a link to System or the Overview. --- apps/web/DESIGN.md | 4 +++- apps/web/PRODUCT.md | 4 +++- .../features/overview/getting-started.test.ts | 11 ++++++++++- .../src/features/overview/getting-started.ts | 17 ++++++++++++++++- .../web/src/features/sandbox/NodeEnrollment.tsx | 14 ++++++++++++++ .../src/features/sandbox/SandboxManagerView.css | 1 + apps/web/src/lib/locale-strings.ts | 4 ++++ 7 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 13b939d25..a811a4968 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -550,7 +550,9 @@ request runs. failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to - generate. + generate. Once the node is ready, while Getting started is open, one line under + the green status names the next step (set a default model, or finish Getting + started) with a text action to System or the Overview. - **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images (and, for microsandbox, keeps its image store and diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index b6dce3dd4..2fcaa89a8 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -123,7 +123,9 @@ workbench. samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar - opens it again, and it ends with a brief "You're set". The optional + opens it again, and it ends with a brief "You're set". While it is open, Add node + ends with the next step once its node is ready: the default model while that is to + do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. - Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn, diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 8a8137456..946e176b5 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -2,7 +2,7 @@ import type { CoreHarness, SandboxDeployment } from "@agents-core-web/agents-cli import { afterEach, describe, expect, it, vi } from "vitest"; import type { FleetState } from "../fleet/use-sandbox-fleet"; -import { checklistStorageKey, checklistView, gettingStartedSteps, rememberInstallation } from "./getting-started"; +import { checklistStorageKey, checklistView, gettingStartedSteps, nextStepAfterNode, rememberInstallation } from "./getting-started"; import { node, project } from "./test-fixtures"; const deployment = (overrides: Partial = {}): SandboxDeployment => ({ @@ -90,3 +90,12 @@ describe("Getting started visibility", () => { expect(checklistStorageKey({ status: "loading" })).toBeNull(); }); }); + +describe("the next step after a node is ready", () => { + it("is the default model while it is to do, else the checklist, and only while the checklist is open", () => { + expect(nextStepAfterNode(true, "todo")).toBe("default-model"); + expect(nextStepAfterNode(true, "done")).toBe("getting-started"); + expect(nextStepAfterNode(true, null)).toBeNull(); + expect(nextStepAfterNode(false, "todo")).toBeNull(); + }); +}); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index abaae996d..fc52a1e0f 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -69,7 +69,7 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { * Done once the default harness has a deployment default model provider; * without a default harness, once any enabled harness has one. */ -function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): StepState { +export function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): StepState { if (!harnesses || harnesses === "failed") return harnesses ? "unknown" : null; const target = harnesses.find((harness) => harness.default); const set = target ? target.model_provider !== null : harnesses.some((harness) => harness.enabled && harness.model_provider !== null); @@ -151,6 +151,21 @@ export function writeChecklistMemory(key: string, value: Exclude
+ {next ?

+ {t(next === "default-model" ? "Next: set a default model." : "Next: finish Getting started.")} + +

: null} {problem && !ready ?

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

diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 9aa721a63..7d82b9362 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -25,6 +25,7 @@ .sandbox-command .field textarea { width: 100%; min-height: 130px; border: 0; border-radius: 0; padding: 14px; background: transparent; font-family: var(--font-mono, monospace); font-size: 11px; line-height: 1.6; resize: vertical; overflow-wrap: anywhere; } .sandbox-enrollment-status { display: flex; gap: 8px; align-items: center; font-size: 13px; color: var(--fg-muted); } .sandbox-enrollment-status.connected { color: color-mix(in srgb, var(--success) 65%, var(--fg)); } +.sandbox-add-node .sandbox-next-step { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px; font-size: 13px; } .sandbox-add-node .sandbox-command-expiry { font-size: 12px; } .sandbox-maintenance { padding: 12px 16px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; } @media (max-width: 600px) { diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 69347cffa..37054c04b 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -92,6 +92,10 @@ export const chinese = { "Registration progress": "注册进度", "Registered · {{name}}": "已注册 · {{name}}", "{{name}} · Connected": "{{name}} · 已连接", + "Next: set a default model.": "下一步:设置默认模型。", + "Next: finish Getting started.": "下一步:完成新手引导。", + "Open System": "打开系统", + "Open Overview": "打开概览", "Rerun only on {{name}} if asked": "仅在 {{name}} 上按需重跑", "Waiting for registration": "等待注册", "Waiting to connect": "等待连接", From 18a7d61d9e6f98067f86a7f0b958735cefdfd4d1 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 06:02:29 +0000 Subject: [PATCH 06/10] Show Core's reason for every sandbox refusal Only a 403 showed Core's message; a 400 got generic text and every 409 sandbox_deployment_conflict read "already configured", although Core uses that code for a stale expected_generation, a deployment not in maintenance and resources still allocated. Any 4xx other than 401 now shows Core's message, and only sandbox_admin_not_configured keeps the console's own words. Whether a write is uncertain now depends on the status alone, so an E2B configuration refused with a 4xx, whose reason the client withholds, is a refusal too: it reads "Core rejected the E2B configuration." and no longer locks the page. --- apps/web/e2e/nodes.spec.ts | 5 ++++ apps/web/src/lib/locale-strings.ts | 4 +--- apps/web/src/lib/locale.test.ts | 9 ++++--- apps/web/src/lib/sandbox-labels.test.ts | 5 ++-- apps/web/src/lib/sandbox-labels.ts | 32 ++++++++++++++----------- 5 files changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 91d2309b3..3bc2d35d5 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -316,6 +316,11 @@ test("keeps the page usable when Core refuses a sandbox change, and shows Core's // A clear refusal changed nothing: no "couldn't confirm" dialog, and the same page to try again. await expect(page.getByText("This console is read-only.")).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); + // One code covers several reasons, so a conflict shows Core's own. + await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 409, code: "sandbox_deployment_conflict", message: "Another administrator changed the deployment; it is now at generation 2." }); + await save.click(); + await expect(page.getByText("Another administrator changed the deployment; it is now at generation 2.")).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); await save.click(); await expect(page.getByText("c0ffee000000")).toBeVisible(); }); diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 37054c04b..fa5493eb5 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -174,11 +174,10 @@ export const chinese = { "Sandbox placement could not be loaded.": "无法加载沙箱分配信息。", "The sandbox request failed. Refresh to check the current state before trying again.": "沙箱请求失败。重试前请刷新并检查当前状态。", "Core did not confirm node removal. Refresh to check its state.": "Core 未确认节点已移除。请刷新以检查状态。", - "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", - "The selected sandbox node is unavailable or has no capacity.": "所选沙箱节点不可用或容量不足。", "Sign in to the console again to access sandbox management.": "请重新登录控制台以访问沙箱管理。", "Sandbox administration is not configured on this console.": "此控制台尚未配置沙箱管理权限。", "Core rejected the sandbox change": "Core 拒绝了此次沙箱更改", + "Core rejected the E2B configuration.": "Core 拒绝了这个 E2B 配置。", "The console configuration could not be read. Refresh to try again.": "无法读取控制台配置。请刷新重试。", "The sandbox request was rejected. Refresh to check the current state.": "沙箱请求被拒绝。请刷新并检查当前状态。", "The sandbox service is unavailable. Refresh to check the current state.": "沙箱服务不可用。请刷新并检查当前状态。", @@ -225,7 +224,6 @@ export const chinese = { "Connecting to this console's Core…": "正在连接此控制台的 Core…", "Saving sandbox change…": "正在保存沙箱更改…", "Refresh sandbox state to confirm whether setup was saved before submitting again.": "再次提交前,请刷新沙箱状态以确认配置是否已保存。", - "Sandbox deployment is already configured. Refresh to inspect the saved provider and Core origin.": "沙箱部署已配置。请刷新以查看已保存的运行后端和 Core 地址。", "Set up hosted sandboxes": "配置托管沙箱", "Sandbox provider": "沙箱运行后端", "Choose a provider": "选择运行后端", diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index a27d16588..2e8f17e88 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -27,11 +27,14 @@ describe("sandbox localization", () => { } expect(sandboxDiagnosticMessage("", "zh")).toBeNull(); }); - it("maps conflicts and unconfigured access without leaking raw backend diagnostics", () => { - expect(sandboxRequestError(new AgentCoreError("raw secret", 409, "runtime_node_in_use"), "zh")).toContain("保留资源"); + it("shows Core's reason for a refusal, names an unconfigured console, and keeps other failures to the console's words", () => { expect(sandboxRequestError(new AgentCoreError("raw secret", 503, "sandbox_admin_not_configured"), "zh")).toBe("此控制台尚未配置沙箱管理权限。"); - // Any other refusal is Core's to explain. + // A refusal is Core's to explain: one code, such as a 409 conflict, covers several reasons. expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh")).toBe("This console is read-only."); + expect(sandboxRequestError(new AgentCoreError("expected_generation is stale.", 409, "sandbox_deployment_conflict"), "zh")).toBe("expected_generation is stale."); + // An E2B refusal whose reason the client withheld is named without it. + expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh")).toBe("Core 拒绝了这个 E2B 配置。"); + expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh")).not.toContain("raw secret"); expect(sandboxRequestError(new Error("raw secret"), "zh")).not.toContain("raw secret"); }); }); diff --git a/apps/web/src/lib/sandbox-labels.test.ts b/apps/web/src/lib/sandbox-labels.test.ts index ac2d281a5..28441f6c1 100644 --- a/apps/web/src/lib/sandbox-labels.test.ts +++ b/apps/web/src/lib/sandbox-labels.test.ts @@ -4,11 +4,12 @@ import { describe, expect, it } from "vitest"; import { sandboxWriteUncertain } from "./sandbox-labels"; describe("sandbox write outcome", () => { - it("is uncertain without a response, on a timeout or a 5xx, and certain on Core's refusal", () => { + it("is uncertain without a response, on a timeout or a 5xx, and certain on any other 4xx, a withheld E2B reason included", () => { expect(sandboxWriteUncertain(new TypeError("Failed to fetch"))).toBe(true); expect(sandboxWriteUncertain(new AgentCoreError("Unavailable.", 503))).toBe(true); expect(sandboxWriteUncertain(new AgentCoreError("Timed out.", 408))).toBe(true); - expect(sandboxWriteUncertain(new AgentCoreError("Withheld.", 400, "sandbox_configuration_unconfirmed"))).toBe(true); + expect(sandboxWriteUncertain(new AgentCoreError("Withheld.", 0, "sandbox_configuration_unconfirmed"))).toBe(true); + expect(sandboxWriteUncertain(new AgentCoreError("Withheld.", 400, "sandbox_configuration_unconfirmed"))).toBe(false); expect(sandboxWriteUncertain(new AgentCoreError("Read-only.", 403))).toBe(false); expect(sandboxWriteUncertain(new AgentCoreError("Changed.", 409, "sandbox_deployment_conflict"))).toBe(false); }); diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 621ce34db..b271881b3 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -10,33 +10,37 @@ const states: Record = { export function sandboxStateLabel(state: string, locale: Locale): string { return translate(locale, Object.hasOwn(states, state) ? states[state]! : "Unknown state"); } +/** + * What to say about a failed sandbox request. A refusal (a 4xx other than 401 + * and a 408 timeout) is Core's to explain, so its message shows as sent: one + * code, such as a 409 conflict, covers several reasons. Only an unconfigured + * console gets the console's own words. An E2B configuration whose reason the + * client withheld, since it may echo the key, is named as refused without it. + */ export function sandboxRequestError(error: unknown, locale: Locale): string { let key: MessageKey = "The sandbox request failed. Refresh to check the current state before trying again."; if (error instanceof AgentCoreError) { - if (error.code === "sandbox_configuration_unconfirmed") key = "The sandbox request failed. Refresh to check the current state before trying again."; - else if (error.code === "runtime_node_in_use") key = "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal."; - else if (error.code === "runtime_node_unavailable") key = "The selected sandbox node is unavailable or has no capacity."; - else if (error.code === "sandbox_deployment_conflict") key = "Sandbox deployment is already configured. Refresh to inspect the saved provider and Core origin."; - else if (error.code === "sandbox_admin_not_configured") key = "Sandbox administration is not configured on this console."; + const refused = !sandboxWriteUncertain(error); + if (error.code === "sandbox_admin_not_configured") key = "Sandbox administration is not configured on this console."; else if (error.status === 401) key = "Sign in to the console again to access sandbox management."; - // Any other refusal is Core's to explain; its message names the reason. - else if (error.status === 403 && error.message) return error.message; - else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; - else key = "The sandbox request was rejected. Refresh to check the current state."; + else if (error.code === "sandbox_configuration_unconfirmed") { if (refused) key = "Core rejected the E2B configuration."; } + else if (refused) { + if (error.message) return error.message; + key = "The sandbox request was rejected. Refresh to check the current state."; + } else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } else if (error instanceof Error && error.message === "removal_unconfirmed") key = "Core did not confirm node removal. Refresh to check its state."; return translate(locale, key); } /** * Whether a failed sandbox write may still have taken effect, so the page must - * read Core again before trusting what it shows: no response at all (a network - * failure or an abort), a timeout, a 5xx (an unreadable response is a 502), - * or a configuration write whose rejection the client withheld. Any other 4xx - * is Core's clear refusal, and nothing changed. + * read Core again before trusting what it shows. The status alone decides: no + * response at all (a network failure or an abort), a 408 timeout or a 5xx (an + * unreadable response is a 502). Any other 4xx is Core's clear refusal, and + * nothing changed, even when the client withheld its reason for an E2B key. */ export function sandboxWriteUncertain(error: unknown): boolean { if (!(error instanceof AgentCoreError)) return true; - if (error.code === "sandbox_configuration_unconfirmed") return true; return error.status < 400 || error.status === 408 || error.status >= 500; } From 1b4611a21c73fa8bdd8efe70a47a3f6f6c79fe28 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 06:02:37 +0000 Subject: [PATCH 07/10] Use the public URL exactly as Core reports it, and only over HTTPS nodeSourceUrl normalized the public URL through URL.origin, which drops an explicit port such as :443, and accepted plain HTTP on loopback names. It now keeps the validated value as written, like the self-hosted executor command, and requires an HTTPS origin. Add node also takes --core-url from that same freshly read public URL instead of the page's deployment read, so a public_url fixed on the Core host shows on the next opening without a refresh. --- .../src/features/sandbox/NodeEnrollment.tsx | 14 +++++++------- .../src/features/sandbox/core-origin.test.ts | 19 +++++++++---------- apps/web/src/features/sandbox/core-origin.ts | 17 ++++++++++------- docs/web/protocol-coverage.md | 2 +- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index a31cdc9c0..607146f1a 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -12,7 +12,7 @@ import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxRequestError } from "../../lib/sandbox-labels"; import { checklistOpenFor, modelStep, nextStepAfterNode } from "../overview/getting-started"; import { harnessesQuery } from "../system/harness-queries"; -import { nodeSourceUrl, sandboxCoreOrigin } from "./core-origin"; +import { nodeSourceUrl } from "./core-origin"; import { nodeFilesAvailable, type SandboxConsoleConfig } from "./console-config"; import { nodeInstallCommand, nodeLogCommand, type NodeInstallMode } from "./enrollment-command"; import { CommandBlock, CopyCommand, HostRequirements, NoSudoGuide } from "./node-commands"; @@ -89,8 +89,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const generation = useRef(0); const request = useRef(null); // Nodes download from, and reach Core at, the public URL; the browser's address may be a tunnel or loopback. - const sourceUrl = installation.data ? nodeSourceUrl(installation.data) : null; - const coreUrl = sandboxCoreOrigin(deployment.core_url); + // The deployment's core_url is the same address, but the installation is read again on each opening, so a fix shows at once. + const publicUrl = installation.data ? nodeSourceUrl(installation.data) : null; const available = consoleConfig.node_installer; const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null; const backend = provider === "microsandbox" ? "microsandbox" : "Docker"; @@ -99,7 +99,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // is issued, nor before the installation is read: a failed read (an older Core, say) proves nothing. const blocker: { text: string; failed?: boolean } | null = installation.data === undefined ? installation.isError ? { text: t("The installation couldn't be read, so no command can be issued."), failed: true } : { text: t("Checking this installation's public URL…") } - : !sourceUrl || !coreUrl + : !publicUrl ? { text: t("Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply") } : !nodeFilesAvailable(consoleConfig, deployment.provider) ? { text: t("This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.", { provider: backend }) } @@ -130,8 +130,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const lapsed = Boolean(enrollment && expiresAt <= now); // Expired only once a read begun after the expiry found no node for the command. const expired = lapsed && checkedAt >= expiresAt; - const commandFor = (mode: NodeInstallMode) => enrollment && provider && available && sourceUrl && coreUrl && (registered || !expired) && !ready - ? nodeInstallCommand({ token: enrollment.token, coreUrl, sourceUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, mode }) : ""; + const commandFor = (mode: NodeInstallMode) => enrollment && provider && available && publicUrl && (registered || !expired) && !ready + ? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, mode }) : ""; const command = commandFor("sudo"); const nodeId = node?.id ?? null; const polling = open && enrollment !== null && !ready && (registered || !expired); @@ -230,7 +230,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, } const size = deployment.specification?.resources; const values = { - core: coreUrl ?? "", + core: publicUrl ?? "", size: size ? t("{{cpus}} CPU · {{memory}}", { cpus: size.cpus, memory: formatBytes(size.memory_mib * 2 ** 20) }) : "", }; const requirements = provider ? <> diff --git a/apps/web/src/features/sandbox/core-origin.test.ts b/apps/web/src/features/sandbox/core-origin.test.ts index fbcb8fb13..d68068256 100644 --- a/apps/web/src/features/sandbox/core-origin.test.ts +++ b/apps/web/src/features/sandbox/core-origin.test.ts @@ -1,20 +1,19 @@ import { describe, expect, it } from "vitest"; -import { nodeSourceUrl, sandboxCoreOrigin } from "./core-origin"; +import { httpsOrigin, nodeSourceUrl } from "./core-origin"; -describe("sandbox deployment Core origin", () => { +describe("HTTPS origin", () => { it.each([ - [" https://CORE.example:8443/ ", "https://core.example:8443"], - ["http://localhost:8080", "http://localhost:8080"], - ["http://127.0.0.2:8080/", "http://127.0.0.2:8080"], - ["http://[::1]:8080", "http://[::1]:8080"], - ])("normalizes %s", (input, expected) => expect(sandboxCoreOrigin(input)).toBe(expected)); - it.each(["", "/v1", "http://remote.example", "http://host.localhost", "https://core.example/v1", "https://core.example/path/..", "https://user:secret@core.example", "https://@core.example", "https://core.example?", "https://core.example#", "https://core.example//", "https://core.example\\path", "https://co\nre.example", "file://core.example"])("rejects unsafe or non-origin input %s", (input) => expect(sandboxCoreOrigin(input)).toBeNull()); + ["https://core.example.com", "https://core.example.com"], + [" https://core.example.com:443/ ", "https://core.example.com:443"], + ["https://10.74.84.167:18443", "https://10.74.84.167:18443"], + ])("keeps %s as written", (input, expected) => expect(httpsOrigin(input)).toBe(expected)); + it.each(["", "/v1", "http://core.example", "http://localhost:8080", "https://core.example/v1", "https://user:secret@core.example", "https://@core.example", "https://core.example?", "https://core.example#", "https://core.example//", "https://core.example\\path", "https://co\nre.example", "file://core.example"])("rejects %s", (input) => expect(httpsOrigin(input)).toBeNull()); }); describe("node command source", () => { it("is the installation's public URL, never a loopback, missing or plain HTTP one", () => { - expect(nodeSourceUrl({ public_url: "https://Core.example.com/", local_only: false })).toBe("https://core.example.com"); - expect(nodeSourceUrl({ public_url: "http://127.0.0.1:8091", local_only: true })).toBeNull(); + expect(nodeSourceUrl({ public_url: "https://core.example.com:8443", local_only: false })).toBe("https://core.example.com:8443"); + expect(nodeSourceUrl({ public_url: "https://127.0.0.1:8091", local_only: true })).toBeNull(); expect(nodeSourceUrl({ public_url: null, local_only: false })).toBeNull(); expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false })).toBeNull(); }); diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index d63dd7536..7d565a3f8 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -2,12 +2,15 @@ import type { CoreInstallation } from "@agents-core-web/agents-client"; import { isValidDirectCoreBaseUrl } from "../../lib/connection"; -export function sandboxCoreOrigin(value: string): string | null { - const candidate = value.trim(); - if (!/^https?:\/\/[^/?#\\\s]+\/?$/i.test(candidate) || !isValidDirectCoreBaseUrl(candidate)) return null; - const url = new URL(candidate); - if (url.protocol === "http:" && url.hostname.endsWith(".localhost")) return null; - return url.origin; +/** + * The value itself when it is an HTTPS origin: no path, query, fragment or + * credentials; a single trailing slash is dropped. Otherwise null. It is kept + * as written, not normalized, so an explicit port such as :443 stays exactly + * as Core reports it. + */ +export function httpsOrigin(value: string): string | null { + const candidate = value.trim().replace(/\/$/, ""); + return /^https:\/\/[^/?#\\\s@]+$/i.test(candidate) && isValidDirectCoreBaseUrl(candidate) ? candidate : null; } /** @@ -19,5 +22,5 @@ export function sandboxCoreOrigin(value: string): string | null { */ export function nodeSourceUrl(installation: Pick): string | null { if (installation.local_only || !installation.public_url) return null; - return sandboxCoreOrigin(installation.public_url); + return httpsOrigin(installation.public_url); } diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index c61112fb5..2e834ad8a 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -117,7 +117,7 @@ without the installer shows no Connect a host. | Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | -| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key and on an active project's page; `public_url` in a self-hosted Session's Connect a host command and as the download origin and `--source-url` of the node install and uninstall commands (the reverse proxy sends `/node-install/*` to the console); `local_only`, or a `public_url` that is not an HTTPS origin, stops Add node from issuing a command and Clean up the host from giving one; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | +| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key and on an active project's page; `public_url` in a self-hosted Session's Connect a host command and as the download origin and `--source-url` of the node install and uninstall commands (and the install command's `--core-url`) (the reverse proxy sends `/node-install/*` to the console); `local_only`, or a `public_url` that is not an HTTPS origin, stops Add node from issuing a command and Clean up the host from giving one; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions From 39342d5e0a611886236220d893263d766020abcd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 06:02:46 +0000 Subject: [PATCH 08/10] Treat a node without a Core address as unknown, not on an old one A file-managed local node, which Core did not enroll, reports an empty core_url. It read as Old address and was named in the banner. An empty address is now unknown: such a node shows its health, stays out of the banner, and its clean-up offers no --force form. On a node's page, as in the list, an Old address status no longer shows the offline or provider help tip beside it. --- apps/web/src/features/sandbox/NodeDetail.tsx | 3 ++- apps/web/src/features/sandbox/NodeList.test.ts | 2 ++ apps/web/src/features/sandbox/NodeList.tsx | 11 ++++++++++- apps/web/src/features/sandbox/SandboxManagerView.tsx | 6 +++--- docs/web/protocol-coverage.md | 2 +- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 45f565b1b..b0423fd0f 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -53,8 +53,9 @@ export function NodeDetail({ node, allocations, coreUrl, stale, suspension }: { const reporting = !stale && node.online; // Only microsandbox suspends sandboxes into snapshots; Docker retains nothing. const suspends = node.provider === "microsandbox"; - const diagnostic = stale ? "" : nodeDiagnostic(node); const state = nodeState(node, own, stale, coreUrl); + // As in the list, an old address is the status to act on; the node's health would only distract. + const diagnostic = stale || state === "old_address" ? "" : nodeDiagnostic(node); const count = (value: number) => formatInteger(value, locale); return ( <> diff --git a/apps/web/src/features/sandbox/NodeList.test.ts b/apps/web/src/features/sandbox/NodeList.test.ts index 7fc6647be..493d9653e 100644 --- a/apps/web/src/features/sandbox/NodeList.test.ts +++ b/apps/web/src/features/sandbox/NodeList.test.ts @@ -13,6 +13,8 @@ describe("node state", () => { expect(nodeState(node("a", { online: false }), [], true, core)).toBe("unconfirmed"); expect(nodeState(node("a", { core_url: "https://core-old.example" }), [], false, core)).toBe("old_address"); expect(nodeState(node("a", { online: false, core_url: "https://core-old.example" }), [], false, core)).toBe("old_address"); + // A node Core did not enroll, such as a file-managed local one, reports no address: unknown, not old. + expect(nodeState(node("a", { core_url: "" }), [], false, core)).toBe("available"); expect(nodeState(node("a", { online: false, cleanup_pending: 2 }), [], false, core)).toBe("offline"); expect(nodeState(node("a", { provider_ready: false }), [], false, core)).toBe("degraded"); }); diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 832e5f738..03a71c916 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -11,6 +11,15 @@ import { nodeHealth, suspendedSandboxes } from "../fleet/fleet-model"; export type NodeState = "unconfirmed" | "old_address" | "offline" | "degraded" | "attention" | "available"; +/** + * Whether a node enrolled with another Core address than the deployment's + * `coreUrl`. An empty address is unknown, not old: a node Core did not enroll, + * such as a file-managed local one, reports none. + */ +export function onOldAddress(node: SandboxNode, coreUrl: string): boolean { + return Boolean(node.core_url && coreUrl && node.core_url !== coreUrl); +} + /** * One status per node: stale data first; then a node enrolled with another * address than the deployment's `coreUrl`, which gets no new sandboxes until it @@ -18,7 +27,7 @@ export type NodeState = "unconfirmed" | "old_address" | "offline" | "degraded" | */ export function nodeState(node: SandboxNode, allocations: readonly SandboxAllocation[], stale: boolean, coreUrl: string): NodeState { if (stale) return "unconfirmed"; - if (node.core_url !== coreUrl) return "old_address"; + if (onOldAddress(node, coreUrl)) return "old_address"; const health = nodeHealth(node); if (health !== "available") return health; const attention = node.cleanup_pending > 0 || allocations.some((allocation) => allocation.node_id === node.id && allocation.diagnostic); diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 3927ecfc6..da0f77eda 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -16,7 +16,7 @@ import { sandboxAdmin, sandboxConsoleConfigQuery, sandboxDeploymentQuery, sandbo import { SandboxSetupWizard } from "./SandboxSetupWizard"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; import { NodeEnrollment } from "./NodeEnrollment"; -import { NodeList } from "./NodeList"; +import { NodeList, onOldAddress } from "./NodeList"; import { NodeDetail } from "./NodeDetail"; import { NodeEditDialog } from "./NodeEditDialog"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; @@ -188,7 +188,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const { deployment } = snapshot; setCleanup({ node: { name: target.name || target.id, sourceUrl: nodeSourceUrl(installation), installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, - provider: deployment.provider, oldAddress: target.core_url !== deployment.core_url ? target.core_url : null, + provider: deployment.provider, oldAddress: onOldAddress(target, deployment.core_url) ? target.core_url : null, }, open: true }); } } @@ -209,7 +209,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig : hostedNodes && fresh && !snapshot.deployment.maintenance ? "ready" : "unavailable"; useConsoleIntent("add-node", addNodeReadiness, () => setAdding(true)); // A node enrolled with another address than Core's current one (config.json's public_url) gets no new sandboxes until it is added again. - const staleNodes = hostedNodes && snapshot ? nodes.filter((node) => node.core_url !== snapshot.deployment.core_url).map((node) => node.name || node.id) : []; + const staleNodes = hostedNodes && snapshot ? nodes.filter((node) => onOldAddress(node, snapshot.deployment.core_url)).map((node) => node.name || node.id) : []; const selected = params.id ? nodes.find((node) => node.id === params.id) : undefined; const refreshButton = ; const readFailure = error !== null ? sandboxRequestError(error, locale) : null; diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 2e834ad8a..d0b2a0586 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -143,7 +143,7 @@ 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, and its status there and on its page reads Old address instead of its health. **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 | +| 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, and its status there and on its page reads Old address instead of its health; an empty `core_url` (a node Core did not enroll) is unknown, not old. **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. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | | 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, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; a "No sudo on this host?" disclosure gives the same command without sudo (a user service) and what that user needs, and the log hint follows the command last copied. The command downloads the installer from the installation's `public_url`. Until the installation is read, when it can't be read, when it is `local_only` (or its `public_url` is not an HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider (null reads as none; an absent field blocks nothing), the dialog says why and requests no token; it reads both again on opening and when the window regains focus | From 0fdf998e37d14497eedfccce1d6f242d10d7993e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 06:02:50 +0000 Subject: [PATCH 09/10] Open Clean up the host even when the installation must be read again After Remove, the dialog opened only when the installation read was already cached; after a failed read the host's uninstall command was simply never offered. The dialog now always opens and reads the installation itself if needed: it says it is checking, or that the read failed with Try again, and gives the command once the public URL is read. A loopback public URL now reads as unreachable from other machines rather than missing. --- apps/web/DESIGN.md | 8 +++--- apps/web/e2e/nodes.spec.ts | 12 +++++++++ .../features/sandbox/NodeCleanupDialog.tsx | 26 ++++++++++++++----- .../features/sandbox/SandboxManagerView.tsx | 8 +++--- apps/web/src/lib/locale-strings.ts | 4 ++- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a811a4968..0b6a5bc2c 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -559,9 +559,11 @@ request runs. data), and the no-sudo form behind an "Installed without sudo?" disclosure. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public - URL; without one other machines can use, a single line says the service stays on - the host and no command can be given. Done dismisses it and focus returns to the - page heading. + URL, which the dialog reads again if it is not at hand: until then one line says + it is being checked, a failed read says so with Try again, and a public URL other + machines can't use (loopback, or none) gets a line saying the service stays on the + host and no command can be given. Done dismisses it and focus returns to the page + heading. - **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists what it gives up, each point a 600 Ink lead over a Graphite line: weaker isolation (containers share the host kernel; microsandbox gives each sandbox diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 3bc2d35d5..a02689aa2 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -180,6 +180,18 @@ test("marks a node on an old Core address in its row, beside each node's limit", await expect(row).toContainText("5 / 8"); }); +test("gives the host's uninstall command even when the installation must be read again", async ({ page, request }) => { + await openConsole(page, request, "nodes"); + await page.route("**/core/v1/installation", (route) => route.fulfill({ status: 500, json: { error: { message: "Unavailable.", type: "server_error", code: null, param: null } } })); + await page.getByRole("button", { name: "Remove edge-03" }).click(); + await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click(); + const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); + await expect(cleanup.getByRole("alert")).toContainText("The installation couldn't be read, so no command can be issued."); + await page.unroute("**/core/v1/installation"); + await cleanup.getByRole("button", { name: "Try again" }).click(); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/'https:\/\/core\.example\.com\/node-install\/node-install\.pyz'/); +}); + test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { await openConsole(page, request, "nodes", { sandbox: "none" }); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx index 10cbf5eca..84cae0311 100644 --- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -1,14 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; +import { installationQuery } from "../../lib/installation"; +import { nodeSourceUrl } from "./core-origin"; import { nodeUninstallCommand, type NodeInstallMode } from "./enrollment-command"; import { CommandBlock } from "./node-commands"; /** A node Core has just removed, with what builds its host's uninstall command. */ export interface NodeCleanup { name: string; - /** The installation's public URL, which the command downloads the installer from; null when other machines can't use it. */ - sourceUrl: string | null; installationId: string; scriptDigest: string; provider: string; @@ -25,17 +26,28 @@ export interface NodeCleanup { * node's user. A node enrolled with an earlier address may find it gone; then * `--force` skips only that confirmation. Nothing deletes sandboxes, volumes or * images. Like Add node's, the command downloads from the installation's public - * URL; while other machines can't use it (loopback, say) the dialog says so instead. + * URL, which the dialog reads (again, if it is not at hand): until it is read, if + * the read fails (with Try again), or while other machines can't use it, the + * dialog says so in place of the command. It never opens empty. */ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCleanup | null; open: boolean; onClose: () => void }) { const { t, i18n } = useTranslation("sandbox"); // Sentences run on with a space in English and without one in Chinese. const join = (...sentences: string[]) => sentences.join(i18n.resolvedLanguage?.startsWith("zh") ? "" : " "); - const command = (mode: NodeInstallMode, force = false) => cleanup?.sourceUrl - ? nodeUninstallCommand({ sourceUrl: cleanup.sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, mode, force }) : ""; + const installation = useQuery({ ...installationQuery, enabled: cleanup !== null }); + const sourceUrl = installation.data ? nodeSourceUrl(installation.data) : null; + const command = (mode: NodeInstallMode, force = false) => cleanup && sourceUrl + ? nodeUninstallCommand({ sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, mode, force }) : ""; + const stays = cleanup ? t("{{name}} is removed from Core, but its service and files stay on the host.", { name: cleanup.name }) : ""; return {t("Done")}}> - {cleanup && !cleanup.sourceUrl ?
-

{t("{{name}} is removed from Core, but its service and files stay on the host. An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.", { name: cleanup.name })}

+ {cleanup && !installation.data ?
+ {installation.isError + ?

{join(stays, t("The installation couldn't be read, so no command can be issued."))}

+ :

{t("Checking this installation's public URL…")}

} +
: cleanup && !sourceUrl ?
+

{join(stays, installation.data?.local_only && installation.data.public_url + ? t("Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.", { url: installation.data.public_url }) + : t("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none."))}

: cleanup ?

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

diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index da0f77eda..420dad035 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -20,7 +20,6 @@ import { NodeList, onOldAddress } from "./NodeList"; import { NodeDetail } from "./NodeDetail"; import { NodeEditDialog } from "./NodeEditDialog"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; -import { nodeSourceUrl } from "./core-origin"; import { sandboxSize } from "./deployment-specification"; import "./SandboxManagerView.css"; @@ -182,12 +181,11 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig setRemoveTarget(null); if (params.id === target.id) navigate("nodes"); refresh(); - // The host still runs the node's service until it is uninstalled there. Add node has read the installation. - const installation = queryClient.getQueryData(installationQuery.queryKey); - if (consoleConfig.node_installer && installation && snapshot) { + // The host still runs the node's service until it is uninstalled there; the dialog reads the installation for the command. + if (consoleConfig.node_installer && snapshot) { const { deployment } = snapshot; setCleanup({ node: { - name: target.name || target.id, sourceUrl: nodeSourceUrl(installation), installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, + name: target.name || target.id, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, provider: deployment.provider, oldAddress: onOldAddress(target, deployment.core_url) ? target.core_url : null, }, open: true }); } diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index fa5493eb5..736589cc9 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -76,7 +76,9 @@ export const chinese = { "Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply": "节点需要一个其他机器及其沙箱都能访问的 HTTPS 公网地址:请在 config.json 里设置 public_url,然后运行 parsar apply", "Clean up the host": "清理主机", "{{name}} is removed from Core. To remove its service and files from the host, run:": "{{name}} 已从 Core 移除。要删除它在主机上的服务和文件,请运行:", - "{{name}} is removed from Core, but its service and files stay on the host. An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.": "{{name}} 已从 Core 移除,但它的服务和文件仍留在主机上。卸载命令需要其他机器能访问的 HTTPS 公开地址,而当前安装没有。", + "{{name}} is removed from Core, but its service and files stay on the host.": "{{name}} 已从 Core 移除,但它的服务和文件仍留在主机上。", + "An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.": "卸载命令需要其他机器能访问的 HTTPS 公开地址,而当前安装没有。", + "Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.": "其他机器无法访问本安装的公开地址 {{url}},因此无法生成卸载命令。", "Uninstall command": "卸载命令", "Installed without sudo?": "安装时没用 sudo?", "Run this as that user instead:": "请改为以该用户运行:", From af4d6163e486d1b9727a4aedac852732317f866a Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 06:11:09 +0000 Subject: [PATCH 10/10] Keep localized words for node removal's exact refusals runtime_node_in_use and runtime_node_unavailable each have one exact, stable meaning, so a refused node removal again reads in the console's language instead of Core's English message. Every other 4xx still shows Core's message. --- apps/web/src/lib/locale-strings.ts | 2 ++ apps/web/src/lib/locale.test.ts | 2 ++ apps/web/src/lib/sandbox-labels.ts | 10 +++++++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 736589cc9..3a700ae02 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -176,6 +176,8 @@ export const chinese = { "Sandbox placement could not be loaded.": "无法加载沙箱分配信息。", "The sandbox request failed. Refresh to check the current state before trying again.": "沙箱请求失败。重试前请刷新并检查当前状态。", "Core did not confirm node removal. Refresh to check its state.": "Core 未确认节点已移除。请刷新以检查状态。", + "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", + "The selected sandbox node is unavailable or has no capacity.": "所选沙箱节点不可用或容量不足。", "Sign in to the console again to access sandbox management.": "请重新登录控制台以访问沙箱管理。", "Sandbox administration is not configured on this console.": "此控制台尚未配置沙箱管理权限。", "Core rejected the sandbox change": "Core 拒绝了此次沙箱更改", diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index 2e8f17e88..79023a9d3 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -29,6 +29,8 @@ describe("sandbox localization", () => { }); it("shows Core's reason for a refusal, names an unconfigured console, and keeps other failures to the console's words", () => { expect(sandboxRequestError(new AgentCoreError("raw secret", 503, "sandbox_admin_not_configured"), "zh")).toBe("此控制台尚未配置沙箱管理权限。"); + // A code with one exact meaning keeps the console's localized words. + expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); // A refusal is Core's to explain: one code, such as a 409 conflict, covers several reasons. expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh")).toBe("This console is read-only."); expect(sandboxRequestError(new AgentCoreError("expected_generation is stale.", 409, "sandbox_deployment_conflict"), "zh")).toBe("expected_generation is stale."); diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index b271881b3..bff113df6 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -13,9 +13,11 @@ export function sandboxStateLabel(state: string, locale: Locale): string { /** * What to say about a failed sandbox request. A refusal (a 4xx other than 401 * and a 408 timeout) is Core's to explain, so its message shows as sent: one - * code, such as a 409 conflict, covers several reasons. Only an unconfigured - * console gets the console's own words. An E2B configuration whose reason the - * client withheld, since it may echo the key, is named as refused without it. + * code, such as a 409 conflict, covers several reasons. Only codes with one + * exact, stable meaning get the console's own words: an unconfigured console, + * and a node that still holds sandboxes or is unavailable. An E2B configuration + * whose reason the client withheld, since it may echo the key, is named as + * refused without it. */ export function sandboxRequestError(error: unknown, locale: Locale): string { let key: MessageKey = "The sandbox request failed. Refresh to check the current state before trying again."; @@ -25,6 +27,8 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { else if (error.status === 401) key = "Sign in to the console again to access sandbox management."; else if (error.code === "sandbox_configuration_unconfirmed") { if (refused) key = "Core rejected the E2B configuration."; } else if (refused) { + if (error.code === "runtime_node_in_use") return translate(locale, "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal."); + if (error.code === "runtime_node_unavailable") return translate(locale, "The selected sandbox node is unavailable or has no capacity."); if (error.message) return error.message; key = "The sandbox request was rejected. Refresh to check the current state."; } else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state.";