From b96152267346ed8fadd3334c23b44882df2014fd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 20:33:36 +0800 Subject: [PATCH 1/9] feat(web): initialize zero-node sandbox deployments from manager --- apps/web/e2e/fixture-sandbox.mjs | 24 ++- apps/web/e2e/sandbox-setup.spec.ts | 153 ++++++++++++++++++ .../src/features/sandbox/NodeEnrollment.tsx | 31 ++++ .../features/sandbox/SandboxManagerView.css | 3 + .../features/sandbox/SandboxManagerView.tsx | 47 ++++-- .../web/src/features/sandbox/SandboxSetup.tsx | 30 ++++ .../src/features/sandbox/core-origin.test.ts | 12 ++ apps/web/src/features/sandbox/core-origin.ts | 9 ++ .../agents-client/src/sandbox-client.test.ts | 26 +++ packages/agents-client/src/sandbox-client.ts | 9 +- 10 files changed, 324 insertions(+), 20 deletions(-) create mode 100644 apps/web/e2e/sandbox-setup.spec.ts create mode 100644 apps/web/src/features/sandbox/NodeEnrollment.tsx create mode 100644 apps/web/src/features/sandbox/SandboxSetup.tsx create mode 100644 apps/web/src/features/sandbox/core-origin.test.ts create mode 100644 apps/web/src/features/sandbox/core-origin.ts diff --git a/apps/web/e2e/fixture-sandbox.mjs b/apps/web/e2e/fixture-sandbox.mjs index 78c51742e..69347f3d5 100644 --- a/apps/web/e2e/fixture-sandbox.mjs +++ b/apps/web/e2e/fixture-sandbox.mjs @@ -8,8 +8,9 @@ let nodes = []; let calls = []; 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 = ""; + nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; coreUrl = ""; } resetSandboxFixture(); export function handleSandboxFixture(request, response, url, sendJson, sendError) { @@ -21,8 +22,10 @@ export function handleSandboxFixture(request, response, url, sendJson, sendError nodes = nodes.map((entry) => entry.id === "node-local" ? { ...entry, online: value !== "node_unavailable", provider_ready: value !== "provider_unavailable", diagnostic: value === "provider_unavailable" ? value : "" } : entry); sendJson(response, {}); return true; } - if (path === "/__fixture/sandbox") { sendJson(response, { nodes, calls }); return true; } + if (path === "/__fixture/sandbox") { sendJson(response, { nodes, calls, provider, core_url: coreUrl }); return true; } if (path === "/__fixture/sandbox-microsandbox") { provider = "microsandbox"; sendJson(response, {}); return true; } + if (path === "/__fixture/sandbox-uninitialized") { provider = ""; coreUrl = ""; nodes = []; sendJson(response, {}); return true; } + if (path === "/__fixture/sandbox-add-node") { nodes.push({ ...node("node-enrolled", "Enrolled host"), provider }); sendJson(response, {}); return true; } const projectRoute = path === "/v1/sandbox/nodes" || /^\/v1\/agents\/sessions\/[^/]+\/sandbox-placement$/.test(path); if (projectRoute && request.headers["openai-beta"] !== "agents=v1") { sendError(response, 400, "OpenAI-Beta: agents=v1 is required.", "invalid_beta"); return true; @@ -34,7 +37,22 @@ export function handleSandboxFixture(request, response, url, sendJson, sendError if (!path.startsWith("/core/v1/sandbox/")) return false; calls.push({ path, method: request.method, authorized: request.headers.authorization === "Bearer fixture-admin-key" }); if (request.headers.authorization !== "Bearer fixture-admin-key") { sendError(response, 401, "A deployment admin key is required.", "invalid_admin_key"); return true; } - if (path.endsWith("/deployment")) sendJson(response, { installation_id: "fixture-installation", provider, maintenance: false, owner_epoch: 1 }); + const deployment = () => ({ installation_id: "fixture-installation", provider, core_url: coreUrl, maintenance: false, owner_epoch: 1 }); + if (path.endsWith("/deployment") && request.method === "POST") { + let body = ""; + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + try { + const input = JSON.parse(body); + if (provider && (provider !== input.provider || coreUrl !== input.core_url)) { + sendError(response, 409, "Sandbox deployment is already configured.", "sandbox_deployment_conflict"); return; + } + provider = input.provider; coreUrl = input.core_url; + sendJson(response, deployment()); + } catch { sendError(response, 400, "Invalid setup request."); } + }); + } + 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("/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") { diff --git a/apps/web/e2e/sandbox-setup.spec.ts b/apps/web/e2e/sandbox-setup.spec.ts new file mode 100644 index 000000000..a94234d22 --- /dev/null +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -0,0 +1,153 @@ +import { expect, test, type Page } from "@playwright/test"; + +const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +const setupUrl = `${fixture}/core/v1/sandbox/deployment`; +const adminHeaders = { Authorization: "Bearer fixture-admin-key" }; +async function openSetup(page: Page) { + await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); + await page.getByLabel("Deployment admin key").fill("fixture-admin-key"); + await page.getByRole("button", { name: "Connect admin", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Set up hosted sandboxes" })).toBeVisible(); +} +test.beforeEach(async ({ page, request }) => { + await request.post(`${fixture}/__fixture/reset`); + await request.post(`${fixture}/__fixture/sandbox-uninitialized`); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toBeVisible(); +}); + +for (const provider of ["docker", "microsandbox"]) { + test(`initial ${provider} setup, enrollment, refresh and immutable selection`, async ({ page, request }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openSetup(page); + 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 page.getByLabel("Sandbox provider").selectOption(provider); + 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); + await expect(submit).toBeDisabled(); + } + 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 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 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.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"); + const state = await (await request.get(`${fixture}/__fixture/sandbox`)).json(); + expect(state.calls.filter((call: { path: string; method: string }) => call.path.endsWith("/deployment") && call.method === "POST")).toHaveLength(1); + expect(state.provider).toBe(provider); + expect(state.core_url).toBe("https://core.example"); + }); +} + +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.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, { headers: adminHeaders, data: winner })).status()).toBe(200); + expect((await request.post(setupUrl, { headers: adminHeaders, data: winner })).status()).toBe(200); + await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); + 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 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.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); + let writes = 0; + await page.route("**/core/v1/sandbox/deployment", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + writes++; + await route.fetch(); + await route.abort("connectionreset"); + }); + await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); + await expect(page.getByRole("alert")).toContainText("Refresh sandbox state to confirm"); + 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"); + expect(writes).toBe(1); + expect((await (await request.get(setupUrl, { headers: adminHeaders })).json()).provider).toBe("docker"); +}); + +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.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(); + await expect(page.getByRole("alert")).toContainText("Refresh sandbox state to confirm"); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByRole("alert")).toContainText("Deployment unavailable"); + await expect(page.getByLabel("Sandbox provider")).toBeDisabled(); + await page.unroute("**/core/v1/sandbox/deployment"); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByLabel("Sandbox provider")).toBeEnabled(); + await expect(page.getByLabel("Sandbox provider")).toHaveValue(""); +}); + +for (const operation of ["setup", "enrollment"] as const) { + test(`Core connection changes discard credentials and a late ${operation} result`, async ({ page, request }) => { + await page.addInitScript((operation) => { + const target = window as Window & { releaseSandboxResponse?: () => void }; + const originalFetch = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.href); + if (init?.method === "POST" && url.pathname === `/core/v1/sandbox/${operation === "setup" ? "deployment" : "enrollment-tokens"}`) { + // Ignore cancellation to check a result accepted before unmounting. + return new Promise((resolve) => { + target.releaseSandboxResponse = () => resolve(new Response(JSON.stringify(operation === "setup" ? { installation_id: "old-installation", provider: "docker", core_url: "https://old.example", maintenance: false, owner_epoch: 1 } : { token: "old-secret-token", expires_at: "2026-09-23T09:00:00Z" }), { status: 200 })); + }); + } + return originalFetch(input, init); + }; + }, operation); + await page.reload(); + 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.getByRole("button", { name: "Initialize sandbox deployment" }).click(); + } else { + await request.post(setupUrl, { headers: adminHeaders, 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 expect.poll(() => page.evaluate(() => typeof (window as Window & { releaseSandboxResponse?: () => void }).releaseSandboxResponse)).toBe("function"); + await page.evaluate(() => { location.hash = "system"; }); + 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("replacement-token"); + await connection.getByRole("button", { name: "Apply connection", exact: true }).click(); + await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); + await expect(page.getByLabel("Deployment admin key")).toHaveValue(""); + await page.evaluate(() => { (window as Window & { releaseSandboxResponse?: () => void }).releaseSandboxResponse?.(); }); + await expect(page.getByLabel("Deployment admin key")).toHaveValue(""); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); + await expect(page.locator(".sandbox-manager")).not.toContainText("old-installation"); + expect(await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }))).not.toMatch(/fixture-admin-key|old-secret-token/); + }); +} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx new file mode 100644 index 000000000..56f8592cb --- /dev/null +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -0,0 +1,31 @@ +import { useState } from "react"; +import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import { sandboxCoreOrigin } from "./core-origin"; +import { enrollmentCommand } from "./enrollment-command"; + +export function NodeEnrollment({ deployment, initialCoreUrl, busy, enrollment, onEnroll, onClear }: { + deployment: SandboxDeployment; + initialCoreUrl: string; + busy: boolean; + enrollment: { token: string; expires_at: string } | null; + onEnroll: () => Promise; + onClear: () => void; +}) { + const [commandUrl, setCommandUrl] = useState(initialCoreUrl); + const coreUrl = sandboxCoreOrigin(deployment.core_url || commandUrl); + return

Add node

+
    +
  1. Install parsar-sandbox-node from the same Core release on a Linux host. {deployment.provider === "docker" ? "Prepare its local Docker Unix socket and pinned runtime image." : "Prepare KVM access and the qualified microsandbox runtime, helper and firmware."}
  2. +
  3. Create a private /etc/parsar/sandbox-node.json provider configuration with provider {deployment.provider} and installation ID {deployment.installation_id}. Use this host’s own backend paths and image. Node configuration guide
  4. +
  5. 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. 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. +
+ +

This Core origin must also be reachable from sandbox guests. Every added node uses this deployment’s {deployment.provider} provider.

+ {!enrollment ? : <> +

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

+