From 702017800f2329d0530d8493f86d3b0d8fec08e0 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 24 Sep 2026 23:05:35 +0800 Subject: [PATCH] fix(web): make machine setup actionable through forwarded consoles --- apps/web/e2e/fixture-sandbox.mjs | 2 +- apps/web/e2e/sandbox-manager.spec.ts | 71 +++++++++++++++++++ apps/web/e2e/sandbox-setup.spec.ts | 17 ++++- .../src/features/sandbox/NodeEnrollment.tsx | 16 +++-- .../features/sandbox/SandboxManagerView.tsx | 8 ++- apps/web/src/lib/locale-strings.ts | 7 ++ docs/getting-started/install.md | 6 +- 7 files changed, 118 insertions(+), 9 deletions(-) diff --git a/apps/web/e2e/fixture-sandbox.mjs b/apps/web/e2e/fixture-sandbox.mjs index 58aac8cf0..f2e9964fe 100644 --- a/apps/web/e2e/fixture-sandbox.mjs +++ b/apps/web/e2e/fixture-sandbox.mjs @@ -10,7 +10,7 @@ let provider = "docker"; let diagnostic = ""; let coreUrl = ""; export function resetSandboxFixture() { - nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; coreUrl = ""; + nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; coreUrl = "https://core.example"; } resetSandboxFixture(); export function handleSandboxFixture(request, response, url, sendJson, sendError) { diff --git a/apps/web/e2e/sandbox-manager.spec.ts b/apps/web/e2e/sandbox-manager.spec.ts index 4b92f6b1d..3deeb0ad7 100644 --- a/apps/web/e2e/sandbox-manager.spec.ts +++ b/apps/web/e2e/sandbox-manager.spec.ts @@ -45,6 +45,11 @@ test("add opens one command, copy works, existing hosts do not imply connection, await context.grantPermissions(["clipboard-read", "clipboard-write"]); await openManager(page); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const dialog = page.getByRole("dialog", { name: "Add node" }); const command = dialog.getByLabel("One-time enrollment command"); await expect(command).toHaveValue(/fixture-once-token/); @@ -67,6 +72,11 @@ test("unavailable installer shows compact guidance and never creates an enrollme await page.route("**/console/config", (route) => route.fulfill({ json: { sandbox_admin: true, node_installer: false } })); await openManager(page); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const dialog = page.getByRole("dialog"); await expect(dialog).toContainText("Node installation is unavailable"); await expect(dialog.getByRole("textbox")).toHaveCount(0); @@ -107,6 +117,8 @@ test("Chinese actions, diagnostics, and enrollment are translated and language p await page.locator(".sandbox-topology-node").first().click(); await expect(page.getByRole("region", { name: "沙箱资源分配" }).first()).toContainText("沙箱资源缺失"); await page.getByRole("button", { name: "添加节点", exact: true }).click(); + await page.getByLabel("新节点可访问的控制台地址").fill("https://console.example"); + await page.getByRole("button", { name: "生成注册命令", exact: true }).click(); await expect(page.getByLabel("一次性注册命令")).toHaveValue(/fixture-once-token/); await expect(page.getByRole("dialog")).toContainText("等待节点连接"); await page.keyboard.press("Escape"); @@ -128,12 +140,22 @@ test("an uncertain write is never retried; closing discards a late token and all await route.fulfill({ json: { token: attempts === 1 ? "stale-token" : "fresh-token", expires_at: new Date(Date.now() + 600000).toISOString() } }).catch(() => {}); }); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect.poll(() => attempts).toBe(1); await page.keyboard.press("Escape"); release(); await expect(page.getByRole("dialog")).toHaveCount(0); expect(attempts).toBe(1); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fresh-token/); expect(attempts).toBe(2); }); @@ -148,6 +170,11 @@ test("a connected node remains successful after its enrollment token expires", a }); await openManager(page); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const dialog = page.getByRole("dialog", { name: "Add node" }); await expect(dialog.getByLabel("One-time enrollment command")).toHaveValue(/short-lived-token/); await request.post(`${fixture}/__fixture/sandbox-add-node`); @@ -171,6 +198,11 @@ test("expired commands and failed writes require an explicit retry", async ({ pa return attempts === 1 ? route.fulfill({ json: { token: "expired-token", expires_at: "2020-01-01T00:00:00Z" } }) : route.fulfill({ status: 503, json: { error: { message: "Unavailable" } } }); }); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByRole("dialog")).toContainText("Command expired"); await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); expect(attempts).toBe(1); @@ -220,6 +252,11 @@ for (const width of [1280, 1440]) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ animations: "disabled", path: testInfo.outputPath(`nodes-${width}-${theme}.png`) }); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); const copyButton = page.getByRole("button", { name: "Copy node command" }); await expect(copyButton).toBeInViewport(); @@ -302,3 +339,37 @@ for (const theme of ["light", "dark"]) { await page.locator("#sandbox-selected-node").screenshot({ path: testInfo.outputPath(`node-details-zh-${theme}.png`), animations: "disabled" }); }); } + +test("SSH tunnel enrollment requires a reachable console address before issuing a token", async ({ page, request }) => { + await openManager(page); + await page.getByRole("button", { name: "Add node", exact: true }).click(); + const dialog = page.getByRole("dialog"); + const source = dialog.getByLabel("Console address reachable from the new node"); + const generate = dialog.getByRole("button", { name: "Generate enrollment command", exact: true }); + await expect(source).toHaveValue(""); + for (const invalid of ["http://127.0.0.1:14173", "https://localhost", "https://console.example/v1", "https://user:secret@console.example"]) { + await source.fill(invalid); + await expect(generate).toBeDisabled(); + } + const before = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls; + expect(before.filter((call: { method: string }) => call.method === "POST")).toHaveLength(0); + await source.fill("https://console.example"); + await generate.click(); + const command = dialog.getByLabel("One-time enrollment command"); + await expect(command).toHaveValue(/--source-url 'https:\/\/console.example'/); + await expect(command).toHaveValue(/--core-url 'https:\/\/core.example'/); + await expect(command).not.toHaveValue(/127\.0\.0\.1/); +}); + +test("a loopback deployment origin cannot issue a remote-node command", async ({ page, request }) => { + await page.route("**/core/v1/sandbox/deployment", async (route) => { + const deployment = await (await route.fetch()).json(); + await route.fulfill({ json: { ...deployment, core_url: "http://127.0.0.1:8080" } }); + }); + await openManager(page); + await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog")).toContainText("deployment needs an HTTPS Core address"); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); + const calls = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls; + expect(calls.filter((call: { method: string }) => call.method === "POST")).toHaveLength(0); +}); diff --git a/apps/web/e2e/sandbox-setup.spec.ts b/apps/web/e2e/sandbox-setup.spec.ts index c58cd027d..d0fdfd940 100644 --- a/apps/web/e2e/sandbox-setup.spec.ts +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -31,6 +31,11 @@ test("bundled console needs no extra admin key and provides one install command await page.getByLabel("Sandbox provider").selectOption("docker"); await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const command = page.getByLabel("One-time enrollment command"); await expect(command).toHaveValue(/fixture-once-token/); await expect(command).toHaveValue(/\/node-install\/node-install.pyz/); @@ -68,6 +73,11 @@ for (const provider of ["docker", "microsandbox"]) { await expect(page.getByLabel("Sandbox provider")).toHaveCount(0); await expect(page.getByText("No nodes registered. Add a node to provide hosted capacity.")).toBeVisible(); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/--core-url 'https:\/\/core.example'/); expect(await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }))).not.toMatch(/fixture-admin-key|fixture-once-token/); await request.post(`${fixture}/__fixture/sandbox-add-node`); @@ -167,6 +177,11 @@ for (const operation of ["setup", "enrollment"] as const) { await request.post(setupUrl, { data: { provider: "docker", core_url: "https://core.example" } }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } } await expect.poll(() => page.evaluate(() => typeof (window as Window & { releaseSandboxResponse?: () => void }).releaseSandboxResponse)).toBe("function"); await page.evaluate(() => { location.hash = "system"; }); @@ -193,7 +208,7 @@ test("unpaired or unavailable consoles show setup guidance without admin credent await page.route("**/console/config", (route) => route.fulfill(body ? { contentType: "application/json", body: JSON.stringify(body) } : { status: 404, body: "Not found" })); await page.reload(); await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); - await expect(page.getByRole("alert")).toContainText("Sandbox administration is not configured"); + await expect(page.getByRole("alert")).toContainText(body ? "Sandbox administration is not configured" : "Console access could not be checked"); await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0); } diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index e0b9d49a9..bffa75393 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -5,7 +5,7 @@ import type { SandboxAdminClient, SandboxDeployment, SandboxNode } from "@agents import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import { sandboxCoreOrigin } from "./core-origin"; +import { sandboxSetupOrigin } from "./core-origin"; import type { SandboxConsoleConfig } from "./console-config"; import { nodeInstallCommand } from "./enrollment-command"; @@ -32,8 +32,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab const request = useRef(null); const knownIds = useRef(new Set()); const revealedId = useRef(null); - const sourceUrl = sandboxCoreOrigin(window.location.origin); - const coreUrl = sandboxCoreOrigin(deployment.core_url || window.location.origin); + const [sourceDraft, setSourceDraft] = useState(() => sandboxSetupOrigin(window.location.origin) ?? ""); + const sourceUrl = sandboxSetupOrigin(sourceDraft); + const coreUrl = sandboxSetupOrigin(deployment.core_url || window.location.origin); const available = Boolean(consoleConfig.node_installer && sourceUrl && coreUrl); const expired = Boolean(enrollment && new Date(enrollment.expires_at).getTime() <= now); const connected = fresh && nodes.find((node) => !knownIds.current.has(node.id) && node.online && node.provider_ready); @@ -62,7 +63,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab setCopied(false); setCopyFailed(false); } async function generate() { - if (request.current || !available) return; + if (request.current || !available || disabled || !fresh) return; generation.current++; const controller = new AbortController(); request.current = controller; knownIds.current = new Set(nodes.map((node) => node.id)); @@ -87,8 +88,13 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab {createPortal(
- {!available ?

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

: <> + {!consoleConfig.node_installer || !coreUrl ?

{t(!consoleConfig.node_installer ? "Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console." : "The deployment needs an HTTPS Core address reachable from nodes and sandbox guests. Ask the deployment administrator to configure it.")}

: <> {!connected ?

{t("Run on the host you want to add.")}

: null} + {!enrollment && !busy && error === null ?
+ +

{t("Use the HTTPS address of this console. A localhost address or SSH tunnel on your computer cannot be reached from another machine.")}

+ +
: null} {busy ?

{t("Preparing your command…")}

: null} {error !== null ? <>

{sandboxRequestError(error, locale)}

: null} {enrollment ? <> diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 0713a0a4f..fa78eb075 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -34,7 +34,13 @@ function SandboxAccess({ presentation }: { presentation: "manager" | "home" }) { return () => controller.abort(); }, [revision]); 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("Hosted Sandbox Manager")}

{t("Manage hosted execution for this Core deployment.")}

+

{t(config ? "Machine management needs setup" : "Cannot connect to this console")}

+

{t(config ? "Sandbox administration is not configured on this console. Ask the deployment administrator to configure access." : "Console access could not be checked. Check your connection or sign in again, then refresh.")}

+ +
+
; return ; } diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 52dcb91d8..01a790928 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -2,6 +2,13 @@ import { apiKeyChinese } from "./api-key-strings"; import { firstRunChinese } from "./first-run-strings"; import { consoleAuthChinese } from "./console-auth-strings"; export const chinese = { + "The deployment needs an HTTPS Core address reachable from nodes and sandbox guests. Ask the deployment administrator to configure it.": "部署需要配置节点和沙箱都能访问的 HTTPS Core 地址,请联系部署管理员。", + "Console address reachable from the new node": "新节点可访问的控制台地址", + "Use the HTTPS address of this console. A localhost address or SSH tunnel on your computer cannot be reached from another machine.": "请使用此控制台的 HTTPS 地址。其他机器无法访问你电脑上的 localhost 地址或 SSH 隧道。", + "Generate enrollment command": "生成注册命令", + "Machine management needs setup": "机器管理需要配置", + "Cannot connect to this console": "无法连接此控制台", + "Console access could not be checked. Check your connection or sign in again, then refresh.": "无法确认控制台访问状态。请检查连接或重新登录,然后刷新。", ...apiKeyChinese, ...consoleAuthChinese, ...firstRunChinese, diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index fd7d1d232..cc202ba9d 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -154,7 +154,11 @@ and either Docker socket access or microsandbox's KVM/native-library prerequisit The command checks host access before downloading the Runtime and verifies microsandbox's shared libraries after downloading its native programs. The console serves only fixed, non-secret distribution files at `/node-install/`; -private installation configuration is never part of this payload. Retain the +private installation configuration is never part of this payload. When opening the +console through a localhost SSH tunnel, enter the HTTPS address of this same +console that the new host can reach before generating its enrollment command. +That address must serve `/node-install/`; an API-only origin is not sufficient. +The saved deployment Core origin must also be reachable from nodes and guests. Retain the installed `node-payload/` directory. Manual console deployments enable the same flow with `CORE_CONSOLE_NODE_PAYLOAD_DIR` pointing to the matched distribution payload. TLS verification stays enabled; deployments using a private certificate