From b549998225f6dfd66988f15bdedda882c39d4261 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 22:21:23 +0800 Subject: [PATCH 1/4] Center sandbox management on nodes and simplify enrollment --- CONTRIBUTING.md | 13 +- apps/web/e2e/fixture-sandbox.mjs | 2 +- apps/web/e2e/sandbox-manager.spec.ts | 304 +++++++++--------- apps/web/e2e/sandbox-setup.spec.ts | 66 ++-- .../src/features/sandbox/NodeEnrollment.tsx | 125 ++++--- .../features/sandbox/SandboxManagerView.css | 103 ++++-- .../features/sandbox/SandboxManagerView.tsx | 67 ++-- .../src/features/sandbox/SandboxNodeCard.tsx | 36 +++ .../web/src/features/sandbox/SandboxSetup.tsx | 9 +- .../sandbox/enrollment-command.test.ts | 75 +++-- .../features/sandbox/enrollment-command.ts | 26 +- apps/web/src/lib/locale-strings.ts | 56 ++-- deploy/install/node_install.py | 5 + docs/getting-started/install.md | 16 +- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 22 +- 15 files changed, 540 insertions(+), 385 deletions(-) create mode 100644 apps/web/src/features/sandbox/SandboxNodeCard.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e701a6ba8..c9c552228 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1601,7 +1601,18 @@ Chinese/English sandbox text, status and diagnostic formatting live in the share `apps/web/src/lib/` locale modules. A persisted explicit language preference wins before the first browser language; unrelated product surfaces are outside this translation scope. Preserve zero-node setup and node installation behavior when -localizing their controls. +localizing their controls. The sandbox manager centers node readiness and capacity; +installation identifiers, provider metadata and allocation records are secondary +inspection details. Node enrollment is an explicit Add node action in a focused +dialog, using the saved Core origin or the paired console origin by default. +Do not expose routine network wiring or manual runtime setup as the primary flow. +Generate a one-time command only on user intent, never retry enrollment writes +automatically, and discard credentials and late responses when the dialog closes +or the Core connection changes. Detect successful addition against the node IDs +present before enrollment; an existing node reconnecting is not a new enrollment. +The command verifies the installer checksum before execution, retains normal TLS +verification, and passes the enrollment credential only to the installer process. + Both proxy paths retain fixed-origin, cross-site, safe-path, redirect and Upgrade restrictions through the standard Go reverse proxy with streaming/cancellation. diff --git a/apps/web/e2e/fixture-sandbox.mjs b/apps/web/e2e/fixture-sandbox.mjs index fe15d6736..0c1664c06 100644 --- a/apps/web/e2e/fixture-sandbox.mjs +++ b/apps/web/e2e/fixture-sandbox.mjs @@ -54,7 +54,7 @@ export function handleSandboxFixture(request, response, url, sendJson, sendError }); } else if (path.endsWith("/deployment")) sendJson(response, deployment()); - else if (path.endsWith("/enrollment-tokens")) sendJson(response, { token: "fixture-once-token", expires_at: "2026-09-23T09:00:00Z" }); + else if (path.endsWith("/enrollment-tokens")) sendJson(response, { token: "fixture-once-token", expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString() }); else if (path.endsWith("/allocations")) sendJson(response, { data: path.includes("node-local") ? [{ id: "allocation-1", node_id: "node-local", session_id: "session_snapshot", tenant_id: "fixture-project", environment_id: "environment-1", state: "active", compute_phase: "running", initialization: "ready", diagnostic, created_at: now }] : [] }); else if (request.method === "DELETE") { const id = path.split("/").at(-1); diff --git a/apps/web/e2e/sandbox-manager.spec.ts b/apps/web/e2e/sandbox-manager.spec.ts index 871f00d60..1ad414dbf 100644 --- a/apps/web/e2e/sandbox-manager.spec.ts +++ b/apps/web/e2e/sandbox-manager.spec.ts @@ -1,58 +1,180 @@ import { expect, test, type Page } from "@playwright/test"; const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +const installer = { sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }; async function openManager(page: Page) { await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); } +async function details(page: Page, name = "Core server") { + const card = page.locator(".sandbox-node-card").filter({ has: page.getByRole("heading", { name, exact: true }) }); + await card.getByText("Node details", { exact: true }).click(); + return card; +} test.beforeEach(async ({ page, request }) => { - await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: false }) })); + await page.route("**/console/config", (route) => route.fulfill({ json: installer })); await request.post(`${fixture}/__fixture/reset`); await page.goto("/"); await expect(page.getByRole("button", { name: "Sessions", exact: true })).toBeVisible(); }); -test("console access needs no browser admin credential; removal and enrollment are guarded", async ({ page, request }) => { + +test("nodes lead the page, details preserve diagnostics and removal is confirmed", async ({ page, request }) => { await openManager(page); await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); - await expect(page.getByRole("button", { name: /Connect admin|Disconnect admin/ })).toHaveCount(0); + await expect(page.getByLabel("Core URL reachable from the node")).toHaveCount(0); + await expect(page.getByText("fixture-installation", { exact: true })).toBeHidden(); + await expect(page.getByText("session_snapshot", { exact: true })).toBeHidden(); + const nodes = page.getByRole("region", { name: "Sandbox nodes", exact: true }); + await expect(nodes).toContainText("Provider ready"); + await expect(nodes).toContainText("Host metrics unavailable"); + const card = await details(page); + await expect(card.getByRole("region", { name: "Sandbox allocations" })).toContainText("session_snapshot"); + await card.getByRole("button", { name: "Remove Core server", exact: true }).click(); + await expect(page.getByRole("alert")).toHaveCount(0); + await card.getByRole("button", { name: "Confirm removal" }).click(); + await expect(page.getByRole("alert")).toContainText("active allocations or retained resources"); + await card.getByRole("button", { name: "Cancel removal" }).click(); + const offline = await details(page, "Offline host"); + await offline.getByRole("button", { name: "Remove Offline host" }).click(); + await offline.getByRole("button", { name: "Confirm removal" }).click(); + await expect(offline).toHaveCount(0); const calls = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls; - expect(calls.length).toBeGreaterThan(0); expect(calls.every((call: { authorization: unknown }) => call.authorization === null)).toBe(true); - await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Provider ready"); - await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Host metrics unavailable"); - await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("session_snapshot"); - await page.getByRole("button", { name: "Remove Core server", exact: true }).click(); - await page.getByRole("button", { name: "Confirm removal" }).click(); - await expect(page.getByRole("alert")).toContainText("active allocations or retained resources"); - await expect(page.getByRole("button", { name: "Remove Core server", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Cancel removal" }).click(); - await page.getByRole("button", { name: "Remove Offline host", exact: true }).click(); - await page.getByRole("button", { name: "Confirm removal" }).click(); - await expect(page.getByRole("button", { name: "Remove Offline host", exact: true })).toHaveCount(0); - await page.getByLabel("Core URL reachable from the node").fill("https://core.example"); - await page.getByRole("button", { name: "Generate enrollment command" }).click(); - await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); - await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/--enrollment-token-file/); - const storage = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); - expect(storage).not.toContain("fixture-admin-key"); expect(storage).not.toContain("fixture-once-token"); - expect(page.url()).not.toContain("fixture-admin-key"); +}); + +test("add opens one command, copy works, existing hosts do not imply connection, and close discards secrets", async ({ page, context, request }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await openManager(page); + await page.getByRole("button", { name: "Add node", 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/); + await expect(dialog.getByRole("status")).toHaveText("Waiting for your node to connect…"); + await expect(dialog.getByRole("textbox")).toHaveCount(1); + await dialog.getByRole("button", { name: "Copy node command" }).click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await command.inputValue()); + await expect(dialog).toContainText("One-time enrollment token expires"); + await request.post(`${fixture}/__fixture/sandbox-add-node`); + await expect(dialog.getByRole("status")).toContainText("Enrolled host · Connected", { timeout: 10000 }); + expect(await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }))).not.toContain("fixture-once-token"); + await page.keyboard.press("Escape"); + await expect(command).toHaveCount(0); + await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeFocused(); + const calls = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls; + expect(calls.filter((call: { method: string; path: string }) => call.method === "POST" && call.path.endsWith("enrollment-tokens"))).toHaveLength(1); +}); + +test("unavailable installer shows compact guidance and never creates an enrollment", async ({ page, request }) => { + 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(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("Node installation is unavailable"); + await expect(dialog.getByRole("textbox")).toHaveCount(0); + expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls.filter((call: { method: string }) => call.method === "POST")).toHaveLength(0); +}); + +test("empty nodes and stale reads are distinct; failed reads cannot imply ready", async ({ page }) => { + await openManager(page); + await page.route("**/core/v1/sandbox/deployment", (route) => route.fulfill({ status: 503, json: { error: { message: "Deployment unavailable." } } })); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByRole("alert")).toContainText("Previously loaded state is shown below"); + await expect(page.locator(".sandbox-node-status").first()).toHaveText("Status unconfirmed"); + await page.unroute("**/core/v1/sandbox/deployment"); + await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ json: { data: [] } })); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByRole("heading", { name: "Add your first node" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Sandbox allocations" })).toHaveCount(0); +}); + +for (const value of ["node_unavailable", "resource_missing"]) { + test(`${value} remains visible in node details and clears after recovery`, async ({ page, request }) => { + await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=${value}`); + await openManager(page); + const card = await details(page); + const allocations = card.getByRole("region", { name: "Sandbox allocations" }); + await expect(allocations).toContainText(value === "node_unavailable" ? "Node disconnected" : "Sandbox resource missing"); + await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=`); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(allocations).toContainText("No reported issue"); + }); +} + +test("Chinese actions, diagnostics, and enrollment are translated and language persists", async ({ page, request }) => { + await page.getByLabel("Language / 语言").selectOption("zh"); + await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=resource_missing`); + await page.getByRole("button", { name: "托管沙箱管理", exact: true }).click(); + await page.locator(".sandbox-node-card").first().getByText("节点详情", { exact: true }).click(); + await expect(page.getByRole("region", { name: "沙箱资源分配" }).first()).toContainText("沙箱资源缺失"); + 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"); + await page.getByLabel("Language / 语言").selectOption("en"); await page.reload(); - await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); + await expect(page.getByLabel("Language / 语言")).toHaveValue("en"); await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); }); -test("microsandbox shares the manager and mobile tables stay contained", async ({ page, request }) => { - await request.post(`${fixture}/__fixture/sandbox-microsandbox`); - await page.setViewportSize({ width: 390, height: 844 }); + +test("an uncertain write is never retried; closing discards a late token and allows a fresh request", async ({ page }) => { + await openManager(page); + let attempts = 0; + let release: () => void = () => {}; + const pending = new Promise((resolve) => { release = resolve; }); + await page.route("**/core/v1/sandbox/enrollment-tokens", async (route) => { + attempts++; + if (attempts === 1) await pending; + 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.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.getByLabel("One-time enrollment command")).toHaveValue(/fresh-token/); + expect(attempts).toBe(2); +}); + +test("expired commands and failed writes require an explicit retry", async ({ page }) => { await openManager(page); - await expect(page.locator(".sandbox-summary")).toContainText("microsandbox"); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - const table = page.getByRole("region", { name: "Sandbox nodes", exact: true }); - await expect(table).toBeVisible(); - const bounds = await table.boundingBox(); - expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); - await expect(page.getByLabel("Language / 语言")).toBeVisible(); - await page.getByLabel("Core URL reachable from the node").scrollIntoViewIfNeeded(); - await expect(page.getByLabel("Core URL reachable from the node")).toBeInViewport(); + let attempts = 0; + await page.route("**/core/v1/sandbox/enrollment-tokens", (route) => { + attempts++; + 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")).toContainText("Command expired"); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); + expect(attempts).toBe(1); + await page.getByRole("button", { name: "Generate new command" }).click(); + await expect(page.getByRole("dialog").getByRole("alert")).toBeVisible(); + expect(attempts).toBe(2); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); }); + +for (const width of [320, 768, 1440]) { + for (const theme of ["light", "dark"]) { + test(`node manager and add modal fit ${width}px ${theme}`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate((theme) => document.documentElement.dataset.theme = theme, theme); + await openManager(page); + 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.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); + const copyButton = page.getByRole("button", { name: "Copy node command" }); + await expect(copyButton).toBeInViewport(); + const copyBounds = await copyButton.boundingBox(); + const commandBounds = await page.locator(".sandbox-command").boundingBox(); + expect(copyBounds!.x + copyBounds!.width).toBeLessThanOrEqual(commandBounds!.x + commandBounds!.width); + const bounds = await page.getByRole("dialog").boundingBox(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width); + await page.screenshot({ animations: "disabled", path: testInfo.outputPath(`add-node-${width}-${theme}.png`) }); + }); + } +} test("hosted creation defaults to automatic and an explicit unavailable node is never replaced", async ({ page }) => { await page.getByRole("button", { name: "Sessions", exact: true }).click(); await page.getByRole("button", { name: "New Session", exact: true }).click(); @@ -82,15 +204,6 @@ test("Session details show the actual Core placement", async ({ page }) => { await expect(dialog).toContainText("Core server"); await expect(dialog).toContainText("node-local"); }); -test("empty nodes and a failed refresh have distinct states", async ({ page }) => { - await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ data: [] }) })); - await openManager(page); - await expect(page.getByText("No nodes registered. Add a node to provide hosted capacity.")).toBeVisible(); - await expect(page.getByText("No sandbox allocations.")).toBeVisible(); - await page.route("**/core/v1/sandbox/deployment", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { message: "Deployment unavailable." } }) })); - await page.getByRole("button", { name: "Refresh sandbox state" }).click(); - await expect(page.getByRole("alert")).toContainText("Previously loaded state is shown below"); -}); test("late placement reads cannot replace another Session's placement", async ({ page, request }) => { const second = await request.post(`${fixture}/v1/agents/sessions`, { headers: { "OpenAI-Beta": "agents=v1", "Idempotency-Key": "placement-second" }, @@ -116,106 +229,3 @@ test("late placement reads cannot replace another Session's placement", async ({ releaseOld(); await expect(page.getByRole("dialog")).not.toContainText("Old placement"); }); -test("disconnect diagnostics clear after reconnection in manager and Session details", async ({ page, request }) => { - await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=node_unavailable`); - await openManager(page); - await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("Node disconnected"); - await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("Existing resources stay assigned"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await page.locator(".conversation-session-action").click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toContainText("Node disconnected"); - await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=`); - await dialog.getByRole("button", { name: "Refresh placement" }).click(); - await expect(dialog).toContainText("Available · Recorded allocation"); - await expect(dialog).not.toContainText("Node disconnected"); - await page.getByRole("button", { name: "Close dialog", exact: true }).click(); - await openManager(page); - await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("No reported issue"); - await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).not.toContainText("Node disconnected"); -}); -test("a missing resource preserves ownership and offers inspection without replacement", async ({ page, request }) => { - await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=resource_missing`); - await openManager(page); - const allocations = page.getByRole("region", { name: "Sandbox allocations", exact: true }); - await expect(allocations).toContainText("Sandbox resource missing"); - await expect(allocations).toContainText("retains the ownership record"); - await expect(allocations).toContainText("does not create a replacement automatically"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await page.locator(".conversation-session-action").click(); - await expect(page.getByRole("dialog")).toContainText("Sandbox resource missing"); - await expect(page.getByRole("dialog")).toContainText("Check the provider resource on the assigned node"); - const requests = await (await request.get(`${fixture}/__fixture/requests`)).json(); - expect(requests.filter((entry: { method: string; path: string }) => entry.method === "POST" && entry.path === "/v1/agents/sessions")).toHaveLength(0); -}); - -test("Chinese defaults from browser preference, persists, and translates manager actions and diagnostics", async ({ page, request }) => { - await page.addInitScript(() => Object.defineProperty(navigator, "languages", { get: () => ["zh-CN", "en-US"] })); - await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=resource_missing`); - await page.reload(); - await page.getByRole("button", { name: "托管沙箱管理", exact: true }).click(); - await expect(page.getByRole("heading", { name: "节点", exact: true })).toBeVisible(); - const allocations = page.getByRole("region", { name: "沙箱资源分配", exact: true }); - await expect(allocations).toContainText("沙箱资源缺失"); - await expect(allocations).toContainText("活跃"); - await expect(allocations).toContainText("运行中"); - await expect(page.getByRole("region", { name: "沙箱节点", exact: true })).toContainText("主机指标不可用(心跳已过期)"); - await page.getByRole("button", { name: "移除 Core server", exact: true }).click(); - await page.getByRole("button", { name: "确认移除", exact: true }).click(); - await expect(page.getByRole("alert")).toContainText("节点仍有活跃分配或保留资源"); - await page.getByRole("button", { name: "取消移除", exact: true }).click(); - await page.getByRole("button", { name: "刷新沙箱状态" }).click(); - await expect(page.getByRole("alert")).toHaveCount(0); - await page.getByLabel("节点可访问的 Core 地址").fill("https://core.example"); - await page.getByRole("button", { name: "生成注册命令", exact: true }).click(); - await expect(page.getByLabel("一次性注册命令")).toHaveValue(/fixture-once-token/); - await page.getByLabel("Language / 语言").selectOption("en"); - await expect(page.getByRole("heading", { name: "Hosted Sandbox Manager" })).toBeVisible(); - await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); - await page.reload(); - await expect(page.getByLabel("Language / 语言")).toHaveValue("en"); - await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); - await page.getByLabel("Language / 语言").selectOption("zh"); - await page.route("**/core/v1/sandbox/deployment", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "sandbox_admin_not_configured", message: "Sandbox administration is not configured on this console" } }) })); - await page.getByRole("button", { name: "刷新沙箱状态" }).click(); - await expect(page.getByRole("alert")).toContainText("此控制台尚未配置沙箱管理权限"); -}); - -test("a direct Core connection never requests sandbox administration and clears enrollment", async ({ page, request }) => { - await openManager(page); - await page.getByLabel("Core URL reachable from the node").fill("https://core.example"); - await page.getByRole("button", { name: "Generate enrollment command" }).click(); - await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); - const before = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls.length; - await page.getByRole("button", { name: "Configure Agent Core connection", exact: true }).click(); - const connection = page.getByRole("dialog", { name: "Connect an Agent Core", exact: true }); - await connection.getByRole("radio", { name: /Other compatible Core/ }).check(); - await connection.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); - await connection.getByLabel("Bearer token").fill("project-only"); - await connection.getByRole("button", { name: "Apply connection", exact: true }).click(); - await expect(page.getByText("Sandbox management is available through the signed-in console connection.", { exact: false })).toBeVisible(); - await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toHaveCount(0); - expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(before); -}); - -test("an uncertain enrollment write is not retried and a late response cannot survive navigation", async ({ page }) => { - await openManager(page); - let attempts = 0; - let release: () => void = () => {}; - const pending = new Promise((resolve) => { release = resolve; }); - await page.route("**/core/v1/sandbox/enrollment-tokens", async (route) => { - attempts += 1; - await pending; - await route.fulfill({ contentType: "application/json", body: JSON.stringify({ token: "stale-token", expires_at: "2026-09-24T00:00:00Z" }) }).catch(() => {}); - }); - await page.getByLabel("Core URL reachable from the node").fill("https://core.example"); - await page.getByRole("button", { name: "Generate enrollment command" }).click(); - await expect.poll(() => attempts).toBe(1); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await openManager(page); - release(); - await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); - await expect(page.getByLabel("Core URL reachable from the node")).toHaveValue(new URL(page.url()).origin); - expect(attempts).toBe(1); -}); diff --git a/apps/web/e2e/sandbox-setup.spec.ts b/apps/web/e2e/sandbox-setup.spec.ts index 0ca7bd8b6..9320fccd5 100644 --- a/apps/web/e2e/sandbox-setup.spec.ts +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -7,7 +7,7 @@ async function openSetup(page: Page) { await expect(page.getByRole("heading", { name: "Set up hosted sandboxes" })).toBeVisible(); } test.beforeEach(async ({ page, request }) => { - await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: false }) })); + await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }) })); await request.post(`${fixture}/__fixture/reset`); await request.post(`${fixture}/__fixture/sandbox-uninitialized`); await page.goto("/"); @@ -25,16 +25,16 @@ test("bundled console needs no extra admin key and provides one install command await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); await expect(page.getByRole("heading", { name: "Set up hosted sandboxes" })).toBeVisible(); await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); - await expect(page.getByLabel("Core origin reachable from nodes and guests")).toHaveValue(new URL(page.url()).origin); + await expect(page.getByLabel("Core origin reachable from nodes and guests")).toBeHidden(); await page.getByLabel("Sandbox provider").selectOption("docker"); await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); - await page.getByRole("button", { name: "Generate node command" }).click(); + await page.getByRole("button", { name: "Add node", exact: true }).click(); const command = page.getByLabel("One-time enrollment command"); - await expect(command).toHaveValue(/PARSAR_NODE_ENROLLMENT_TOKEN='fixture-once-token' python3 -c/); + await expect(command).toHaveValue(/fixture-once-token/); await expect(command).toHaveValue(/\/node-install\/node_install.py/); - await expect(command).toHaveValue(/hashlib.sha256/); + await expect(page.getByRole("button", { name: "Copy node command" })).toBeVisible(); - await expect(page.getByRole("status")).toContainText("refresh automatically"); + await expect(page.getByRole("dialog").getByRole("status")).toContainText("Waiting for your node"); await expect(page.locator(".sandbox-manager")).not.toContainText("Create a private /etc/parsar/sandbox-node.json"); await request.post(`${fixture}/__fixture/sandbox-add-node`); await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Enrolled host", { timeout: 10000 }); @@ -50,8 +50,9 @@ for (const provider of ["docker", "microsandbox"]) { const submit = page.getByRole("button", { name: "Initialize sandbox deployment" }); await expect(submit).toBeDisabled(); await expect(page.getByLabel("Sandbox provider")).toHaveValue(""); - await expect(page.getByRole("button", { name: "Generate enrollment command" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Add node", exact: true })).toHaveCount(0); await page.getByLabel("Sandbox provider").selectOption(provider); + await page.getByText("Advanced network settings", { exact: true }).click(); const origin = page.getByLabel("Core origin reachable from nodes and guests"); for (const invalid of ["http://core.example", "https://core.example/v1", "https://user:secret@core.example", "https://core.example?key=secret"]) { await origin.fill(invalid); @@ -60,17 +61,15 @@ for (const provider of ["docker", "microsandbox"]) { await origin.fill("https://CORE.example/"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await submit.click(); - await expect(page.locator(".sandbox-summary")).toContainText(provider === "docker" ? "Docker" : "microsandbox"); + await page.getByText("Deployment details", { exact: true }).click(); + await expect(page.locator(".sandbox-deployment-details")).toContainText(provider === "docker" ? "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 expect(page.getByLabel("Core URL reachable from the node")).toHaveValue("https://core.example"); - await expect(page.getByLabel("Core URL reachable from the node")).toHaveAttribute("readonly", ""); - await expect(page.getByRole("link", { name: "Node configuration guide" })).toBeVisible(); - await expect(page.locator(".sandbox-steps")).toContainText("fixture-installation"); - await page.getByRole("button", { name: "Generate enrollment command" }).click(); + await page.getByRole("button", { name: "Add node", 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`); + await page.keyboard.press("Escape"); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Enrolled host"); await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Provider ready"); @@ -84,6 +83,7 @@ for (const provider of ["docker", "microsandbox"]) { test("concurrent setup conflict requires refresh and displays the committed provider", async ({ page, request }) => { await openSetup(page); await page.getByLabel("Sandbox provider").selectOption("docker"); + await page.getByText("Advanced network settings", { exact: true }).click(); await page.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); const winner = { provider: "microsandbox", core_url: "https://other-core.example" }; expect((await request.post(setupUrl, { data: winner })).status()).toBe(200); @@ -92,14 +92,16 @@ test("concurrent setup conflict requires refresh and displays the committed prov await expect(page.getByRole("alert")).toContainText("already configured"); await expect(page.getByRole("button", { name: "Initialize sandbox deployment" })).toBeDisabled(); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); - await expect(page.locator(".sandbox-summary")).toContainText("microsandbox"); - await expect(page.getByLabel("Core URL reachable from the node")).toHaveValue(winner.core_url); + await page.getByText("Deployment details", { exact: true }).click(); + await expect(page.locator(".sandbox-deployment-details")).toContainText("microsandbox"); + await expect(page.locator(".sandbox-deployment-details")).toContainText(winner.core_url); await expect(page.getByLabel("Sandbox provider")).toHaveCount(0); }); test("a lost setup response is not retried and refresh recovers the saved deployment", async ({ page, request }) => { await openSetup(page); await page.getByLabel("Sandbox provider").selectOption("docker"); + await page.getByText("Advanced network settings", { exact: true }).click(); await page.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); let writes = 0; await page.route("**/core/v1/sandbox/deployment", async (route) => { @@ -113,7 +115,8 @@ test("a lost setup response is not retried and refresh recovers the saved deploy await expect(page.getByRole("button", { name: "Initialize sandbox deployment" })).toBeDisabled(); expect(writes).toBe(1); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); - await expect(page.locator(".sandbox-summary")).toContainText("Docker"); + await page.getByText("Deployment details", { exact: true }).click(); + await expect(page.locator(".sandbox-deployment-details")).toContainText("Docker"); expect(writes).toBe(1); expect((await (await request.get(setupUrl)).json()).provider).toBe("docker"); }); @@ -121,6 +124,7 @@ test("a lost setup response is not retried and refresh recovers the saved deploy test("a failed setup refresh keeps setup disabled until a successful read", async ({ page }) => { await openSetup(page); await page.getByLabel("Sandbox provider").selectOption("docker"); + await page.getByText("Advanced network settings", { exact: true }).click(); await page.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); await page.route("**/core/v1/sandbox/deployment", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { message: "Deployment unavailable" } }) })); await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); @@ -154,12 +158,13 @@ for (const operation of ["setup", "enrollment"] as const) { await openSetup(page); if (operation === "setup") { await page.getByLabel("Sandbox provider").selectOption("docker"); - await page.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); + await page.getByText("Advanced network settings", { exact: true }).click(); + await page.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); } else { 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: "Generate enrollment command" }).click(); + await page.getByRole("button", { name: "Add node", exact: true }).click(); } await expect.poll(() => page.evaluate(() => typeof (window as Window & { releaseSandboxResponse?: () => void }).releaseSandboxResponse)).toBe("function"); await page.evaluate(() => { location.hash = "system"; }); @@ -190,30 +195,7 @@ test("unpaired or unavailable consoles show setup guidance without admin credent await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0); } - await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: false }) })); + await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }) })); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await expect(page.getByRole("heading", { name: "Set up hosted sandboxes" })).toBeVisible(); }); - -test("Chinese setup and the generated installer command preserve polling and language switching", async ({ page, request }) => { - await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }) })); - await page.getByLabel("Language / 语言").selectOption("zh"); - await page.getByRole("button", { name: "托管沙箱管理", exact: true }).click(); - await expect(page.getByRole("heading", { name: "配置托管沙箱" })).toBeVisible(); - await page.getByLabel("沙箱运行后端").selectOption("docker"); - await expect(page.locator(".sandbox-manager")).toContainText("节点命令会安装匹配的运行时镜像"); - await page.getByLabel("节点和沙箱可访问的 Core 地址").fill("https://core.example"); - await page.getByRole("button", { name: "初始化沙箱部署" }).click(); - await page.getByRole("button", { name: "生成节点命令" }).click(); - await expect(page.getByLabel("一次性注册命令")).toHaveValue(/PARSAR_NODE_ENROLLMENT_TOKEN='fixture-once-token' python3 -c/); - await expect(page.getByRole("button", { name: "复制节点命令" })).toBeVisible(); - await expect(page.locator(".sandbox-manager")).toContainText("每隔几秒自动刷新"); - await request.post(`${fixture}/__fixture/sandbox-add-node`); - await expect(page.getByRole("region", { name: "沙箱节点", exact: true })).toContainText("Enrolled host", { timeout: 10000 }); - await expect(page.getByRole("region", { name: "沙箱节点", exact: true })).toContainText("后端就绪"); - await page.getByLabel("Language / 语言").selectOption("en"); - await expect(page.getByRole("button", { name: "Copy node command" })).toBeVisible(); - await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); - await page.getByRole("button", { name: "Clear enrollment command" }).click(); - await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); -}); diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 5ae35893c..8863c0fd6 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,52 +1,97 @@ -import { useState } from "react"; -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import { createPortal } from "react-dom"; +import { Check, Copy, Plus, Terminal } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { SandboxAdminClient, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; +import { Modal } from "../../components/Modal"; import { useLocale } from "../../lib/LocaleProvider"; +import { sandboxRequestError } from "../../lib/sandbox-labels"; import { sandboxCoreOrigin } from "./core-origin"; import type { SandboxConsoleConfig } from "./console-config"; -import { enrollmentCommand, nodeInstallCommand } from "./enrollment-command"; +import { nodeInstallCommand } from "./enrollment-command"; -export function NodeEnrollment({ consoleConfig, deployment, initialCoreUrl, busy, enrollment, onEnroll, onClear }: { - consoleConfig: SandboxConsoleConfig | null; +export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disabled, fresh, onRefresh }: { + client: SandboxAdminClient; + consoleConfig: SandboxConsoleConfig; deployment: SandboxDeployment; - initialCoreUrl: string; - busy: boolean; - enrollment: { token: string; expires_at: string } | null; - onEnroll: () => Promise; - onClear: () => void; + nodes: SandboxNode[]; + disabled: boolean; + fresh: boolean; + onRefresh: () => void; }) { const { t, locale } = useLocale(); - const [commandUrl, setCommandUrl] = useState(initialCoreUrl); - const sourceUrl = sandboxCoreOrigin(window.location.origin); - const installer = consoleConfig?.node_installer && sourceUrl; + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [enrollment, setEnrollment] = useState<{ token: string; expires_at: string } | null>(null); + const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [copyFailed, setCopyFailed] = useState(false); - const coreUrl = sandboxCoreOrigin(deployment.core_url || commandUrl); - const command = enrollment && coreUrl ? installer - ? nodeInstallCommand(enrollment.token, coreUrl, sourceUrl!, deployment.provider, deployment.installation_id, consoleConfig!.node_installer_sha256) - : enrollmentCommand(enrollment.token, coreUrl) : ""; + const [now, setNow] = useState(Date.now()); + const generation = useRef(0); + const request = useRef(null); + const knownIds = useRef(new Set()); + const sourceUrl = sandboxCoreOrigin(window.location.origin); + const coreUrl = sandboxCoreOrigin(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); + const command = enrollment && available && !expired + ? nodeInstallCommand(enrollment.token, coreUrl!, sourceUrl!, deployment.provider, deployment.installation_id, consoleConfig.node_installer_sha256) : ""; + useEffect(() => () => { generation.current++; request.current?.abort(); }, []); + useEffect(() => { + if (!open || !enrollment) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [open, enrollment]); + useEffect(() => { + if (!open || !enrollment || expired) return; + const interval = window.setInterval(onRefresh, 3000); + return () => window.clearInterval(interval); + }, [open, enrollment, expired, onRefresh]); + function close() { + generation.current++; + request.current?.abort(); request.current = null; + setOpen(false); setEnrollment(null); setError(null); setBusy(false); + setCopied(false); setCopyFailed(false); + } + async function generate() { + if (request.current || !available) return; + generation.current++; + const controller = new AbortController(); request.current = controller; + knownIds.current = new Set(nodes.map((node) => node.id)); + setBusy(true); setEnrollment(null); setError(null); setCopied(false); setCopyFailed(false); + try { + // Capture the current node set before issuing a one-time enrollment token. + const current = await client.listNodes({ signal: controller.signal }); + if (controller.signal.aborted) return; + knownIds.current = new Set(current.data.map((node) => node.id)); + const result = await client.createEnrollment({ signal: controller.signal }); + if (!controller.signal.aborted) { setEnrollment(result); setNow(Date.now()); } + } catch (error) { if (!controller.signal.aborted) setError(error); } + finally { if (!controller.signal.aborted) { request.current = null; setBusy(false); } } + } async function copyCommand() { - try { await navigator.clipboard.writeText(command); setCopied(true); setCopyFailed(false); } - catch { setCopied(false); setCopyFailed(true); } + const current = generation.current; + try { await navigator.clipboard.writeText(command); if (current === generation.current) { setCopied(true); setCopyFailed(false); } } + catch { if (current === generation.current) { setCopied(false); setCopyFailed(true); } } } - return

{t("Add node")}

- {installer ? <> -

{t("Run one command on the Linux amd64 host you want to add. It installs the matched node program and runtime image, writes the configuration, registers this host, and starts its background service.")}

-

{t("The host needs Python 3.9+, a systemd user session with lingering enabled, and")} {deployment.provider === "docker" ? t("access to Docker at /var/run/docker.sock") : t("KVM access and the microsandbox host libraries")}. {t("Run as a non-root user. The host must reach this console to download its node program; the host and sandbox guests must also reach the Core origin below.")}

- :
    -
  1. {t("Install")} parsar-sandbox-node {t("from the same Core release on a Linux host.")} {deployment.provider === "docker" ? t("Prepare its local Docker Unix socket and pinned runtime image.") : t("Prepare KVM access and the qualified microsandbox runtime, helper and firmware.")}
  2. -
  3. {t("Create a private")} /etc/parsar/sandbox-node.json {t("provider configuration with provider")} {deployment.provider} {t("and installation ID")} {deployment.installation_id}. {t("Use this host's own backend paths and image.")} {t("Node configuration guide")}
  4. -
  5. {t("Generate the command below. Adjust its absolute paths, node name and capacity, then execute it on that host. Keep its private state directory on persistent storage.")}
  6. -
  7. {t("Run the node under the host's service supervisor. Refresh sandbox state here and check that the node is online and its provider is ready.")}
  8. -
} - -

{t("This Core origin must also be reachable from sandbox guests. Every added node uses this deployment's {provider} provider.").replace("{provider}", deployment.provider)}

- {!enrollment ? : <> -

{t("One-time enrollment token expires")} {new Date(enrollment.expires_at).toLocaleString(locale)}. {t("Save the command now; it is cleared when you leave this page.")}

-