diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1991aca5..127630a2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -596,6 +596,33 @@ does not qualify its isolation or enable public creation. ### Hosted sandbox nodes and optional suspension +Default installation includes Core, Web and PostgreSQL but no execution node. +It always creates a separate deployment administrator credential. Core receives +only its digest; the paired console server receives the private token and injects +it only on approved management routes after console login and same-origin checks. +The browser never receives that token. Node/daemon transport routes instead +forward their own credentials unchanged to Core. Zero-node Core receives neither the Docker socket nor KVM. +The Web's first setup selects one provider and public Core origin through the +admin-only deployment endpoint. The paired console serves only an explicit list +of non-secret matched distribution artifacts for its node installation command; +never serve private installation files or arbitrary paths. Node installation +reuses the existing node process and Provider configuration, verifies downloaded +files, retains private identity and uses a user service. It performs no SSH +installation, Session creation or model call. PostgreSQL owns this immutable selection under +the existing execution lease and deployment lock. Exact retries are idempotent; +a changed selection conflicts. No provider migration or hot reload is implied. + +Web-managed startup claims the stable installation identity and a new owner epoch +even before provider selection. The existing runtime manager stays present and +loads one immutable configuration when selection becomes available, before any +node lifecycle is created. Admission refuses uninitialized hosted work without +creating Session state. File-managed and Web-managed configuration are mutually +exclusive. Node registration, observation and daemon bootstrap reuse existing +contracts. Derive Runtime bootstrap and daemon WebSocket addresses from the saved +validated origin; never infer them from inbound Host headers. Keep the startup +configuration API a startup snapshot; use the live deployment endpoint in setup. + + A Core deployment may run without a sandbox provider. When enabled, exactly one sandbox provider is selected at setup: Docker or microsandbox. Keep both adapters but reject multiple provider entries, legacy default-provider maps and engine-based placement. Harness selection is @@ -618,8 +645,8 @@ migrate an existing Session to another provider or recreate a released allocatio Fresh adoption of a deployment with unverified retained allocations fails closed. The [Hosted Sandbox Manager](services/agents-api/HOSTED-SANDBOX-MANAGER.md) is a -deployment-level admin surface, separate from project credentials. Its Web token -stays in memory. Node enrollment credentials authorize only registration; durable +deployment-level admin surface, separate from project credentials. A direct-Core +Web token stays in memory; the paired console token stays on its server. Node enrollment credentials authorize only registration; durable node credentials authorize only node transport. Project keys can read a narrow node directory and their own Session placement, never global allocations. diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 599ef5bf2..49c89ce2c 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -3030,17 +3030,26 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa }); }); - await openSessionsFromHome(page); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await page.goto("/"); const dashboard = page.locator(".dashboard-page"); + // The synthetic second Session has no Runtime observation. Let that initial + // snapshot finish before navigation, so requests cannot be aborted mid-chain. + await expect(dashboard.locator(".dashboard-source-badge").filter({ hasText: "Runtime" })).toContainText("Unavailable"); + expect(sessionAfters).toEqual([null, "session_snapshot", null, "session_snapshot"]); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await expect.poll(() => sessionAfters.length).toBe(6); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); expect(agentAfters).toEqual([null, "agent_b"]); - // Session collection loads once for the page and once per Runtime snapshot. - // Returning to Dashboard refreshes Runtime immediately instead of waiting 30 seconds. - await expect.poll(() => sessionAfters.length).toBe(6); - expect(sessionAfters.filter((after) => after === null)).toHaveLength(3); - expect(sessionAfters.filter((after) => after === "session_snapshot")).toHaveLength(3); + // Session collection loads once; the unavailable Runtime snapshot is retried + // on entry to Sessions and again on return to Dashboard. Each reads both pages. + await expect.poll(() => sessionAfters).toEqual([ + null, "session_snapshot", + null, "session_snapshot", + null, "session_snapshot", + null, "session_snapshot", + ]); }); test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { 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..dc361a319 --- /dev/null +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -0,0 +1,182 @@ +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(); +}); + +test("bundled console needs no extra admin key and provides one install command with automatic node status", 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) }) })); + const browserAuthorizations: Array = []; + await page.route("**/core/v1/sandbox/**", async (route) => { + browserAuthorizations.push(route.request().headers().authorization); + await route.continue({ headers: { ...route.request().headers(), authorization: "Bearer fixture-admin-key" } }); + }); + await page.reload(); + 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 page.getByLabel("Sandbox provider").selectOption("docker"); + await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); + await page.getByRole("button", { name: "Generate node command" }).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(/\/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.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 }); + await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Provider ready"); + expect(browserAuthorizations.length).toBeGreaterThan(0); + expect(browserAuthorizations.every((value) => value === undefined)).toBe(true); +}); + +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..1cd5a7394 --- /dev/null +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import { sandboxCoreOrigin } from "./core-origin"; +import type { SandboxConsoleConfig } from "./console-config"; +import { enrollmentCommand, nodeInstallCommand } from "./enrollment-command"; + +export function NodeEnrollment({ consoleConfig, deployment, initialCoreUrl, busy, enrollment, onEnroll, onClear }: { + consoleConfig: SandboxConsoleConfig | null; + deployment: SandboxDeployment; + initialCoreUrl: string; + busy: boolean; + enrollment: { token: string; expires_at: string } | null; + onEnroll: () => Promise; + onClear: () => void; +}) { + const [commandUrl, setCommandUrl] = useState(initialCoreUrl); + const sourceUrl = sandboxCoreOrigin(window.location.origin); + const installer = consoleConfig?.node_installer && sourceUrl; + 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) : ""; + async function copyCommand() { + try { await navigator.clipboard.writeText(command); setCopied(true); setCopyFailed(false); } + catch { setCopied(false); setCopyFailed(true); } + } + return

Add node

+ {installer ? <> +

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.

+

The host needs Python 3.9+, a systemd user session with lingering enabled, and {deployment.provider === "docker" ? "access to Docker at /var/run/docker.sock" : "KVM access and the microsandbox host libraries"}. 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. 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.

+