diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c0b72c4e..a1991aca5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,7 +117,9 @@ migrations. The public protocol schema is `contracts/agents-api/openapi.yaml`; there is no product swaggo contract in this repository. Preserve its pinned types, coverage ledgers and official SDK/raw HTTP tests when changing API behavior. Run `make openapi` after handler annotation changes. It reuses the original -Core-only swaggo v1.16.4 generator and writes this schema, without product routes. +Core-only swaggo v1.16.4 generator, then separates project paths under `/v1` from +`/core/v1/sandbox` administration in `sandbox-manager.openapi.yaml` (base path `/`). +Both generated schemas remain free of product routes. Core changes must retain the independent build and official-client workflow. Native adapter changes require their applicable build/check targets and live provider @@ -401,7 +403,8 @@ settlement distinct. Advance at most one bounded initialization operation per fu maintenance scan. At allocation EOF, begin the next page in the same call rather than consume an observation interval on an empty page. Refill at most once, retain the 32-allocation per-call bound and the five-second ticker, and never loop on an -empty store. Use process-local progress and the existing lifecycle gate. +empty store. For managed nodes these bounds apply independently to each node. +Use process-local progress and the same node lifecycle gate as direct provisioning. After a next-Turn input is durably pending, a completed managed allocation in a suspension/recovery phase may hint this loop. Initial inputs, cold creation, @@ -552,7 +555,7 @@ See the [Template coverage and unresolved semantics](contracts/agents-api/enviro SandboxProvider has five operations: Create, GetInfo, Renew, Kill and RunCommand. Use maintained provider SDKs and thin adapters. Hosted deployments select Docker or -the optional single-host microsandbox profile. +the optional microsandbox profile on the assigned node. Provider initialization creates the sandbox and starts its daemon/harness; RunCommand is for initialization only. Daily execution and Files use Runtime and native or bounded local capabilities. Docker's lack of a native renewable lease @@ -591,13 +594,13 @@ The [managed Runtime build and operator configuration](services/agents-api/deplo defines the explicit opt-in for basic hosted admission. Building an image alone does not qualify its isolation or enable public creation. -### Optional single-host sandbox suspension +### Hosted sandbox nodes and optional suspension 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 -independent. The configuration has one installation UUID, one provider kind and -one backend object. No compatibility parser or parallel provider route remains. +independent. The configuration has one installation UUID and one provider kind. +A local node has one explicit backend object; a remote-only Core has none. No mixed-provider or engine-based provider route is supported. The execution database pins the selected installation and backend namespace. Under the existing execution lease, startup validates that identity before @@ -614,6 +617,112 @@ admit new sandboxes. Retain immutable historical allocation ownership; never migrate an existing Session to another provider or recreate a released allocation. 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 +node credentials authorize only node transport. Project keys can read a narrow +node directory and their own Session placement, never global allocations. + +One execution owner manages local and remote nodes through the same finite +Provider protocol. The embedded local node preserves existing single-host setup; +remote nodes actively connect over authenticated TLS. Persist private node +identity and highest owner epoch; refuse another process using the same identity +or a changed backend namespace. Reserve each NodeID before transport upgrade and +retain that reservation through disconnect cleanup; a duplicate connection must +not replace a live or opening connection. Keep one private state directory per +node and never copy its identity to another host. This is connection exclusion, +not host attestation. The Hub's global mutex protects only in-memory connection +state. Authentication, ownership and Store callbacks run synchronously outside +that mutex, respect cancellation and have a five-second limit; never detach +database writes. Closing the Hub cancels opening and live connections without +waiting for database callbacks. Keep each node reservation until its fenced +disconnect cleanup finishes. Register database presence in an explicit transaction: +a canceled statement must not later publish presence through autocommit. Disconnect +cleanup first locks the node row by identity, then applies the connection/epoch +fence with a fresh READ COMMITTED statement so an in-flight commit cannot be +missed. These transactions must not acquire the deployment-wide manager lock. +Heartbeats establish provider readiness and +last-observed host metrics, never Session activity. Transport reconnects use +bounded backoff. Send relative operation budgets, anchored to the node clock at +receipt and consumed while queued; clocks on different hosts need not agree. Core +still bounds its own response wait. Do not replay mutations after a timeout or +lost response. Retain allocation +and checkpoint operation receipts and observe the original operation instead. +Disconnects and read timeouts are unavailable/uncertain, never resource absence. +Runtime resource observation uses the same immutable node placement through one +bounded read-only Provider operation. Preserve main's Runtime observation/history +service and authorization boundaries. The node delegates only to a provider-owned +observation source; absent capability or transport returns unavailable, never a +Core-local fallback. Observation must not create, renew, restore, or touch Session +activity. Preserve the durable compute receipt and provider timestamps; existing +observation clock validation can reject skewed samples without changing idle policy. +Online-state writes compare the handshake epoch atomically in PostgreSQL so a +stale Core cannot publish readiness for a new owner. Node-managed allocations +do not expire merely because the internal observation keepalive is an hour old; +explicit deletion and configured snapshot retention still authorize cleanup. +Persist only bounded, sanitized observation codes for offline, missing or +unconfirmed resources; keep these separate from the lifecycle and do not invent +a successful running observation after a host restart. + +Managed lifecycle state is owned by one serial worker per registered node: +its gate, allocation and pending cursors, connections, initialization progress and +wake hints are not shared with other nodes. A thin coordinator discovers nodes +and owns worker shutdown; it never holds its map mutex during database, provider +or wait operations. Each worker advances independently, including when another +node is online but its provider is stuck. Do not add a shared scan barrier or +global provider pool: lifecycle concurrency is at most one operation per node, +and grows with the registered node count. This is not a fixed global limit. +Keep offline workers so retained resources remain observable after reconnect. + +Allocation scans filter by the fixed node before their 32-row page limit; pending +scans join the unreleased committed placement. Each node advances its own cursor, +including failed observations, and wraps once at EOF. Direct provisioning resolves +the tenant-scoped existing placement before entering that same node's gate; an +existing allocation must agree with the placement. Never choose another node. +Legacy allocations without node identity retain one separate serial lifecycle. +The coordinator stops accepting work and cancels and drains all node workers and +direct callers before releasing the sole execution lease. Lease loss is global; +ordinary provider failures stay within their node. Session locks, deployment +capacity transactions and revision/one-shot receipts remain authoritative, with +no external operation holding a database lock. + +Commit environment-to-node placement with Session creation and its creation retry +identity. Automatic selection chooses an eligible node; explicit +`x_agents_core.sandbox_node_id` fails if unavailable or full. The optional +model-provider extension remains independent. Existing retries keep their original +node even when it is offline. Node capacity counts pending reservations and +unresolved resources; new placement and suspended-to-restoring admission share a +database lock. Confirmed cleanup releases placement capacity. Under the existing +execution lease, first adoption requires matching installation/configuration identity +and positive Provider evidence for every unreleased allocation on the actual local +backend. A socket or runtime path is not host identity. Before the Worker or +listener starts, a startup-only verifier uses the local adapter's common GetInfo, +GetCompute and ObserveOnly snapshot operations; it cannot create, restore, kill or +replay resources. Normal operation continues exclusively through the node proxy. +Verify each retained current, target and snapshot identity independently; absence +alone never proves ownership. Unknown, unavailable, mismatched or corrupt resources +reject the entire adoption. Volume-only Docker remnants and consumed-restore +transitions without the original source receipt require resolution with the +previous Core before upgrading; do not reconstruct missing ownership evidence. + +Read candidates in bounded pages without holding a transaction across Provider +calls. Then lock the deployment and all candidate allocation rows in a short +leased transaction, compare the complete receipt set to the verified snapshot, +and commit node placement, binding and the database idle anchor together. Database +reads and the final transaction have a five-second budget; each Provider check has +its own thirty-second budget. A changed plan or lost lease commits no adoption. +Pending Environments without allocations receive their first placement; released +history remains unassigned and cannot be recreated. Later startups preserve the +fixed node, idle anchor and existing snapshot retention deadline. + +Do not add node-level drain controls. Refuse node removal with pending allocations, +instances, snapshots, unknown results or cleanup resources. Offline ownership is +retained. Removing a node does not delete compute. Refuse deletion of the embedded local +node while deployment configuration still enables it; changing that configuration +requires the existing clean maintenance transition. Keep the deployment-wide +maintenance/provider-switch guard. This boundary does not add cross-node Session +migration, Core multi-active, autoscaling, Kubernetes or harness residency. + The common `services/agents-api/internal/sandbox` contract owns the five base operations (Create, GetInfo, Renew, Kill, RunCommand) and the optional CheckpointProvider @@ -640,14 +749,23 @@ no external mount or separate storage lifecycle. Suspend only after at least one Turn is terminal, no queued/in-progress/waiting root or subagent Turn, pending input/file operation or initialization remains, -and real activity has been idle for the configured interval. Heartbeats do not -reset activity. The daemon must close admission and drain native cleanup, output -receipts and file work before acknowledging planned suspension. Never change a +and real activity has been idle for the configured interval. For node-managed +allocations, record the first root or child terminal transition in the same +transaction using Core's database clock and the existing compute activity field. +Native completion timestamps remain unchanged in public history but cannot drive +idle admission across hosts; repeated terminal projections never reset that timer. +Read activity together with the database observation time. Candidate filtering and +the Session-locked phase recheck compare elapsed database time with the configured +idle duration; callers must not supply a Core-wall-clock cutoff. Anchor the initial +snapshot retention deadline to that same database observation. Core and database +host clocks need not be synchronized for these decisions. +Heartbeats do not reset activity. The daemon must close admission and drain native +cleanup, output receipts and file work before acknowledging planned suspension. Never change a harness or keep an agent process alive across Turns solely to meet this feature. The acceptance boundary is a next Turn in the same Session with history, files and configuration intact, without replaying an earlier request. -The existing Worker lease, Session lock and lifecycle gate own both providers. +The existing Worker lease, Session lock and per-node lifecycle gates own both providers. New Turn claims, file-write intents and capture admission serialize under the Session lock. Turn and file-write admission share the same compute-phase check; existing receipts remain readable. New pending work cancels capture and wakes @@ -663,7 +781,7 @@ precedence over wake, including at the final database compare-and-swap. Retain unknown cleanup identities until owned resources are confirmed absent. Fixed guest CPU/memory/disk settings, max_active reservations, max_retained -allocation count and snapshot retention bound the single host. Unknown operations +allocation count and snapshot retention bound each assigned node. Unknown operations retain capacity reservations. Source teardown must be confirmed before releasing active capacity. Delete consumed artifacts and old compute closures; do not grow a chain of old writable disks across suspension cycles. No Kubernetes, distributed @@ -1381,7 +1499,7 @@ across upgrades. This is the same managed Runtime, not user-managed enrollment. #### Matched Core and console distribution -The installer milestone packages Core and the unchanged Web console together, +The installer packages Core and the Web console together, with independent `--core-only` and `--web-only` modes. `site/` is the public static landing, separate from `apps/web`; it must not create an onboarding prerequisite, call a model, or claim complete protocol compatibility. Operator installation, @@ -1420,6 +1538,14 @@ Compose in either case; native Core and its Web proxy use loopback, with a private PostgreSQL port. This packaging choice does not change either Provider's execution contract. The basic distroless API image and binary builds remain independent artifacts. +The standalone API release and Core distribution both include the Hosted Sandbox +Manager guide at the relative path used by their packaged README. Include the +guide in each artifact checksum list so extracted documentation matches its build. +The distribution includes the sandbox-node binary. An enabled local node uses a +persistent private state directory, explicitly separate from read-only configuration. +Docker grants Core write access only to that node-state mount; native Core uses the +same installation-owned directory. Zero-node installs create neither node identity +state nor sandbox administrator credentials. One Runtime image contains the existing daemon, shared helpers and three native harness packages. Their differences remain in the adapters. Core keeps exclusive @@ -1431,11 +1557,20 @@ existing write-only model execution extension, with the installation's persisten credential encryption key. Provider identity/backend namespace and native history must not change on a repeated install. -`services/core-console` serves the existing production Web build and forwards only -public `/v1` requests to one configured Core. It uses the standard Go reverse -proxy with streaming/cancellation, a separate operator password, fixed origin and -cross-site checks. Only the server reads the Core bearer. It does not implement -product identity, resource semantics, Runtime discovery or an execution loop. +`services/core-console` serves the production Web build and forwards public `/v1` +requests to one configured Core using its project bearer, after console Basic +authentication. Its explicit sandbox administration routes instead require a +unique browser-supplied Bearer credential and forward it unchanged for Core to +verify; console Basic access does not confer deployment administration. Never +substitute the project bearer on those routes or give the console a shared admin +credential. The installer keeps the administrator key and digests separate from +project configuration and exposes only the digest file to Core. The Web keeps an +entered administrator credential in memory. Node registration, identity and +WebSocket transport are not console routes; nodes connect directly to Core. +Both proxy paths retain fixed-origin, cross-site, safe-path, redirect and Upgrade +restrictions through the standard Go reverse proxy with streaming/cancellation. +The console implements no product identity, resource semantics, Runtime discovery +or execution loop. The console has neither KVM nor Docker authority; its static root contains no secrets. Installation exposes only loopback API/console ports. Remote exposure requires an operator-configured HTTPS/access boundary. Web-only mode can connect @@ -2873,9 +3008,9 @@ Effective extension reads use the persisted engine; Sessions without the extensi retain the official Agent response shape. See the [extension contract](contracts/agents-api/harness-selection.md) for null/retry behavior and operator configuration. -Hosted engine-to-provider selection belongs to Core composition. Admission and -initial allocation share the mapping; retained allocations use their persisted -provider identity. Runtime images must satisfy their existing qualification rules. +Hosted provider selection belongs to deployment configuration and is independent +of the engine. Session creation fixes a node through a Core extension or automatic +placement; retained allocations keep that node and provider identity. Runtime images must satisfy their existing qualification rules. Transient model options are partitioned by engine and must not expose another engine's credentials. Do not infer an engine from a model name or template. diff --git a/Makefile b/Makefile index 80a8d849d..71e1496aa 100644 --- a/Makefile +++ b/Makefile @@ -17,22 +17,24 @@ check-database: sqlc-generate: cd services/agents-api && $(SQLC) generate +SWAG ?= go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) + .PHONY: openapi openapi: @set -e; root="$${PARSAR_HOME:-$$HOME/.parsar}/build"; mkdir -p "$$root"; \ output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ - go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) init \ + $(SWAG) init \ -g cmd/server/main.go --dir ./services/agents-api,./contracts/agents-api/v1 \ --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ - mv "$$output/swagger.yaml" contracts/agents-api/openapi.yaml + go run ./scripts/openapi-split "$$output/swagger.yaml" contracts/agents-api/openapi.yaml contracts/agents-api/sandbox-manager.openapi.yaml check-sqlc: python3 scripts/check-sqlc.py check-go: - go test ./apps/parsar-daemon/... ./internal/... ./contracts/agents-api/... -count=1 + go test ./apps/parsar-daemon/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1 build-daemon: @set -e; output="$${PARSAR_HOME:-$$HOME/.parsar}/build/daemon"; \ diff --git a/README.md b/README.md index 23b9cd9b2..956c91a65 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,17 @@ enable microsandbox or Docker explicitly when installing. With a provider enable Core creates each required sandbox from the colocated Runtime image. Model credentials are supplied through the existing write-only API extension. +Hosted deployments use one selected provider across local or remote nodes. The +Hosted Sandbox Manager shows node health, capacity and Session placement. New +Sessions use automatic placement by default or an explicitly selected node; +existing Sessions retain their node across disconnects and resume. + ## Start here - [Install Core and Web](docs/getting-started/install.md) - [Make your first API request](docs/getting-started/quickstart.md) - [Service health, data and operations](docs/getting-started/operations.md) +- [Hosted Sandbox Manager](services/agents-api/HOSTED-SANDBOX-MANAGER.md) - [Protocol coverage and native differences](contracts/agents-api/README.md) - [Add or select a harness](contracts/agents-api/harness-selection.md) - [Public landing page source](site/index.html) diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 2dd5df09a..599ef5bf2 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -1776,7 +1776,7 @@ test("streams initial Session creation, captures early events, then hands off to for (const failure of [ { label: "response loss", control: { sessionCreateResponseLoss: 1 }, message: "Agent core request failed (502)." }, - { label: "creation-stream EOF before identity", control: { sessionCreateStreamMissingIdentity: 1 }, message: "Agent core returned an empty event stream." }, + { label: "creation-stream EOF before identity", control: { sessionCreateStreamMissingIdentity: 1 }, message: "Agent Core already recorded this Session creation, so its stream sends no events." }, ]) { test(`keeps one Session create attempt across ${failure.label} and an unchanged manual retry`, async ({ page, request }) => { await openAgents(page, request); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 9a5b3e3d3..32f64aaae 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -1,4 +1,5 @@ import http from "node:http"; +import { handleSandboxFixture, resetSandboxFixture } from "./fixture-sandbox.mjs"; const host = "127.0.0.1"; const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); @@ -759,6 +760,8 @@ const server = http.createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", `http://${host}:${port}`); + if (handleSandboxFixture(request, response, url, sendJson, sendError)) return; + if (request.method === "GET" && url.pathname === "/__fixture/health") { return sendJson(response, { ready: true }); } @@ -766,6 +769,7 @@ const server = http.createServer(async (request, response) => { for (const stream of streamResponses.keys()) stream.end(); streamResponses.clear(); state = initialState(); + resetSandboxFixture(); return sendJson(response, { reset: true }); } if (request.method === "POST" && url.pathname === "/__fixture/control") { diff --git a/apps/web/e2e/fixture-sandbox.mjs b/apps/web/e2e/fixture-sandbox.mjs new file mode 100644 index 000000000..78c51742e --- /dev/null +++ b/apps/web/e2e/fixture-sandbox.mjs @@ -0,0 +1,47 @@ +const now = "2026-09-23T08:00:00Z"; +const node = (id, name, online = true) => ({ id, name, provider: "docker", online, provider_ready: online, diagnostic: "", + last_seen_at: now, max_active: 4, max_retained: 16, active: id === "node-local" ? 1 : 0, reserved: 0, + retained: 0, cleanup_pending: 0, created_at: now, running: id === "node-local" ? 1 : 0, snapshots: 0, + cpu_count: online ? 8 : null, available_memory_bytes: online ? 8589934592 : null, available_disk_bytes: online ? 34359738368 : null, +}); +let nodes = []; +let calls = []; +let provider = "docker"; +let diagnostic = ""; +export function resetSandboxFixture() { + nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; +} +resetSandboxFixture(); +export function handleSandboxFixture(request, response, url, sendJson, sendError) { + const path = url.pathname; + if (path === "/__fixture/sandbox-diagnostic") { + const value = url.searchParams.get("value") ?? ""; + if (!["", "node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable"].includes(value)) { sendError(response, 400, "Unknown diagnostic"); return true; } + diagnostic = value; + 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-microsandbox") { provider = "microsandbox"; 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; + } + if (path === "/v1/sandbox/nodes") { sendJson(response, { data: nodes.map(({ id, name, online }) => ({ id, name, available: online })) }); return true; } + if (/^\/v1\/agents\/sessions\/[^/]+\/sandbox-placement$/.test(path)) { + sendJson(response, { node_id: "node-local", node_name: "Core server", available: !diagnostic, state: "active", compute_phase: "running", diagnostic }); return true; + } + 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 }); + 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") { + const id = path.split("/").at(-1); + if (id === "node-local") sendError(response, 409, "Node has active allocations or retained resources.", "runtime_node_in_use"); + else { nodes = nodes.filter((entry) => entry.id !== id); sendJson(response, { id, deleted: true }); } + } else if (path.endsWith("/nodes")) sendJson(response, { data: nodes }); + else sendError(response, 404, "Unknown sandbox fixture route."); + return true; +} diff --git a/apps/web/e2e/sandbox-manager.spec.ts b/apps/web/e2e/sandbox-manager.spec.ts new file mode 100644 index 000000000..547a22c2b --- /dev/null +++ b/apps/web/e2e/sandbox-manager.spec.ts @@ -0,0 +1,152 @@ +import { expect, test, type Page } from "@playwright/test"; +const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +async function connectAdmin(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: "Nodes", exact: true })).toBeVisible(); +} +test.beforeEach(async ({ page, request }) => { + await request.post(`${fixture}/__fixture/reset`); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toBeVisible(); +}); +test("admin access, node health, guarded removal and enrollment remain separate from project credentials", async ({ page, request }) => { + await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); + expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0); + await page.getByLabel("Deployment admin key").fill("project-key"); + await page.getByRole("button", { name: "Connect admin", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText("deployment admin key is required"); + await page.getByRole("button", { name: "Disconnect admin" }).click(); + await connectAdmin(page); + 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"); + await page.reload(); + await expect(page.getByLabel("Deployment admin key")).toHaveValue(""); + 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 }); + await connectAdmin(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.getByRole("button", { name: "Disconnect admin" })).toBeInViewport(); + await page.getByLabel("Core URL reachable from the node").scrollIntoViewIfNeeded(); + await expect(page.getByLabel("Core URL reachable from the node")).toBeInViewport(); +}); +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(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b"); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + const advanced = dialog.getByRole("button", { name: /Advanced settings/ }); + if (await advanced.getAttribute("aria-expanded") !== "true") await advanced.click(); + await expect(dialog.getByLabel("Sandbox node", { exact: true })).toHaveValue(""); + await dialog.getByLabel("Sandbox node", { exact: true }).selectOption("node-local"); + const creates: Array> = []; + await page.route("**/v1/agents/sessions", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + creates.push(route.request().postDataJSON()); + await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "runtime_node_unavailable", message: "Selected sandbox node is unavailable.", type: "server_error" } }) }); + }); + await dialog.getByRole("button", { name: "Create Session", exact: true }).click(); + await expect(dialog.getByRole("alert")).toContainText("Selected sandbox node is unavailable"); + await expect(dialog.getByLabel("Sandbox node", { exact: true })).toHaveValue("node-local"); + expect(creates).toHaveLength(1); + expect(creates[0]?.x_agents_core).toEqual({ sandbox_node_id: "node-local" }); +}); +test("Session details show the actual Core placement", async ({ page }) => { + 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("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 connectAdmin(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" }, + data: { agent_id: "agent_b", environment: { type: "none" }, input: "Read placement", metadata: { title: "Placement second" }, stream: false }, + }); + expect(second.status()).toBe(201); + const secondId = (await second.json()).id; + let releaseOld: () => void = () => {}; + const oldReleased = new Promise((resolve) => { releaseOld = resolve; }); + let oldRequested = false; + await page.route("**/v1/agents/sessions/*/sandbox-placement", async (route) => { + const isSecond = route.request().url().includes(secondId); + if (!isSecond) { oldRequested = true; await oldReleased; } + await route.fulfill({ contentType: "application/json", body: JSON.stringify({ node_id: isSecond ? "new-node" : "old-node", node_name: isSecond ? "Second placement" : "Old placement", available: true, state: "active", compute_phase: "running" }) }).catch(() => {}); + }); + await page.reload(); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.locator('.session-row-action[aria-label="Manage Lifecycle Agent"]').click(); + await expect.poll(() => oldRequested).toBe(true); + await page.getByRole("button", { name: "Close dialog", exact: true }).click(); + await page.locator('.session-row-action[aria-label="Manage Placement second"]').click(); + await expect(page.getByRole("dialog")).toContainText("Second placement"); + 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 connectAdmin(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 connectAdmin(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 connectAdmin(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); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 34cd139e6..2ba9698e8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { Layers3, Settings2 } from "lucide-react"; +import { Settings2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AgentCoreError } from "@agents-core-web/agents-client"; @@ -16,6 +16,9 @@ import type { UpdateAgentInput, } from "@agents-core-web/agents-client"; +import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; +import { SandboxProvider } from "./features/sandbox/SandboxContext"; +import { SystemNavigation } from "./components/SystemNavigation"; import { ConnectionModal } from "./components/ConnectionModal"; import { CreateMenu } from "./components/CreateMenu"; import { ProductNavigation, type ProductView } from "./components/ProductNavigation"; @@ -134,13 +137,13 @@ import { waitForStreamReconnect, } from "./lib/stream-reconnect"; -type View = ProductView | "system"; +type View = ProductView | "system" | "sandbox"; function viewFromLocation(): View { if (typeof window === "undefined") return "dashboard"; const candidate = window.location.hash.slice(1); if (candidate === "templates" && __AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) return "templates"; - return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" + return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" || candidate === "sandbox" ? candidate : "dashboard"; } @@ -1671,6 +1674,7 @@ export function App() { metadata: input.metadata, stream: input.stream, vaultIds: vaultPlan.vaultIds, + sandboxNodeId: input.sandboxNodeId, }); const openSession = (session: AgentSession) => { @@ -2226,7 +2230,7 @@ export function App() { }, []); return ( -
+
Skip to main content
); } diff --git a/apps/web/src/components/SystemNavigation.tsx b/apps/web/src/components/SystemNavigation.tsx new file mode 100644 index 000000000..6561a2078 --- /dev/null +++ b/apps/web/src/components/SystemNavigation.tsx @@ -0,0 +1,12 @@ +import { Layers3, Server } from "lucide-react"; +export type SystemView = "system" | "sandbox"; +export function SystemNavigation({ active, onSelect }: { active: SystemView | null; onSelect: (view: SystemView) => void }) { + return ; +} diff --git a/apps/web/src/features/sandbox/NodeHealth.tsx b/apps/web/src/features/sandbox/NodeHealth.tsx new file mode 100644 index 000000000..0e7805e53 --- /dev/null +++ b/apps/web/src/features/sandbox/NodeHealth.tsx @@ -0,0 +1,14 @@ +import { SandboxDiagnostic } from "./SandboxDiagnostic"; +import type { SandboxNode } from "@agents-core-web/agents-client"; +function bytes(value: number | null): string { + if (value === null) return "Unavailable"; + return `${(value / 1024 ** 3).toLocaleString(undefined, { maximumFractionDigits: 1 })} GiB`; +} +export function NodeHealth({ node }: { node: SandboxNode }) { + return
+ {node.online ? "Online" : "Offline"} · {!node.online ? "Provider status unconfirmed" : node.provider_ready ? "Provider ready" : "Provider unavailable"} + + Last seen: {node.last_seen_at ? new Date(node.last_seen_at).toLocaleString() : "Never"} + {node.online ? {node.cpu_count ?? "Unavailable"} CPUs · {bytes(node.available_memory_bytes)} memory free · {bytes(node.available_disk_bytes)} disk free : Host metrics unavailable (stale heartbeat)} +
; +} diff --git a/apps/web/src/features/sandbox/SandboxContext.tsx b/apps/web/src/features/sandbox/SandboxContext.tsx new file mode 100644 index 000000000..ac91fdebc --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxContext.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { SandboxProjectClient } from "@agents-core-web/agents-client"; +import { isLocalProxyBaseUrl, type CoreConnection } from "../../lib/connection"; + +const SandboxContext = createContext(null); +export function SandboxProvider({ connection, children }: { connection: CoreConnection; children: ReactNode }) { + const client = useMemo(() => new SandboxProjectClient({ + baseUrl: connection.baseUrl, + token: isLocalProxyBaseUrl(connection.baseUrl) ? undefined : connection.token, + }), [connection]); + return {children}; +} +export function useSandboxClient() { return useContext(SandboxContext); } + +export function sandboxAdminBaseUrl(projectBaseUrl: string): string { + if (isLocalProxyBaseUrl(projectBaseUrl)) return "/core/v1/sandbox"; + return `${projectBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "")}/core/v1/sandbox`; +} diff --git a/apps/web/src/features/sandbox/SandboxDiagnostic.tsx b/apps/web/src/features/sandbox/SandboxDiagnostic.tsx new file mode 100644 index 000000000..285ceda06 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxDiagnostic.tsx @@ -0,0 +1,9 @@ +import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; + +export function SandboxDiagnostic({ diagnostic }: { diagnostic?: string }) { + const message = sandboxDiagnosticMessage(diagnostic); + if (!message) return null; + return
+ {message.label}{message.advice} +
; +} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css new file mode 100644 index 000000000..9723d266c --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -0,0 +1,23 @@ +.sandbox-manager { display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; gap: 24px; padding: 28px; min-width: 0; min-height: 0; flex: 1; overflow-y: auto; } +.sandbox-manager .form-stack, .sandbox-manager section, .sandbox-heading > div { min-width: 0; } +.sandbox-manager h1 { font-size: 24px; font-weight: 600; margin: 0 0 8px; } +.sandbox-manager h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; } +.sandbox-manager p { color: var(--fg-muted); line-height: 1.6; margin: 0 0 12px; } +.sandbox-heading, .sandbox-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.sandbox-access, .sandbox-enrollment { max-width: 760px; } +.sandbox-summary { display: grid; grid-template-columns: 1fr 1fr 2fr; margin: 0; border: 1px solid var(--line); } +.sandbox-summary > div { min-width: 0; padding: 18px; } +.sandbox-summary dt { font-size: 12px; color: var(--fg-muted); margin-bottom: 8px; } +.sandbox-summary dd { margin: 0; overflow-wrap: anywhere; } +.sandbox-table-scroll { overflow-x: auto; border: 1px solid var(--line); max-width: 100%; } +.sandbox-manager table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; } +.sandbox-manager th, .sandbox-manager td { padding: 14px; border-bottom: 1px solid var(--line); white-space: nowrap; } +.sandbox-manager th { font-size: 12px; font-weight: 500; color: var(--fg-muted); } +.sandbox-manager td small { display: block; color: var(--fg-muted); margin-top: 4px; } +.sandbox-manager .sandbox-error { color: var(--danger, #c44242); } +.sandbox-confirm { padding: 16px; border: 1px solid var(--line); margin-top: 16px; overflow-wrap: anywhere; } +.sandbox-manager textarea { font-family: monospace; font-size: 12px; resize: vertical; } +@media (max-width: 600px) { .sandbox-manager { padding: 18px 14px; } .sandbox-summary { grid-template-columns: 1fr; } } + +.sandbox-diagnostic { max-width: 360px; white-space: normal; line-height: 1.5; margin: 6px 0; } +.sandbox-diagnostic small { display: block; color: var(--fg-muted); margin-top: 4px; } diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx new file mode 100644 index 000000000..5dcac3c90 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -0,0 +1,112 @@ +import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode } from "@agents-core-web/agents-client"; +import { isValidDirectCoreBaseUrl } from "../../lib/connection"; +import { sandboxAdminBaseUrl } from "./SandboxContext"; +import { SandboxDiagnostic } from "./SandboxDiagnostic"; +import { NodeHealth } from "./NodeHealth"; +import { enrollmentCommand } from "./enrollment-command"; +import "./SandboxManagerView.css"; + +function message(error: unknown): string { + return error instanceof Error ? error.message : "The sandbox request failed."; +} + +export function SandboxManagerView({ coreBaseUrl }: { coreBaseUrl: string }) { + const [draft, setDraft] = useState(""); + const [credential, setCredential] = useState(""); + function connect(event: FormEvent) { + event.preventDefault(); + if (draft.trim()) { setCredential(draft.trim()); setDraft(""); } + } + return
+

Hosted Sandbox Manager

Deployment provider, runtime nodes and Session allocations.

+ {credential ? : null} +
+ {!credential ?
+

Deployment administrator access

+

Enter the separate deployment admin key. It stays in memory until you leave this page or disconnect.

+ + +
: } +
; +} + +function SandboxManager({ coreBaseUrl, credential }: { coreBaseUrl: string; credential: string }) { + const client = useMemo(() => new SandboxAdminClient({ baseUrl: sandboxAdminBaseUrl(coreBaseUrl), token: credential }), [coreBaseUrl, credential]); + const [snapshot, setSnapshot] = useState<{ deployment: SandboxDeployment; nodes: SandboxNode[]; allocations: SandboxAllocation[] } | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [revision, setRevision] = useState(0); + const [removeId, setRemoveId] = useState(null); + const [enrollment, setEnrollment] = useState<{ token: string; expires_at: string } | null>(null); + const [coreUrl, setCoreUrl] = useState(() => coreBaseUrl.startsWith("http") ? coreBaseUrl.replace(/\/v1\/?$/, "") : ""); + const lifetime = useRef(null); + useEffect(() => { + const controller = new AbortController(); lifetime.current = controller; + return () => { controller.abort(); lifetime.current = null; }; + }, []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); setError(null); + void (async () => { + const [deployment, nodes] = await Promise.all([client.retrieveDeployment({ signal: controller.signal }), client.listNodes({ signal: controller.signal })]); + const allocations = await Promise.all(nodes.data.map((node) => client.listAllocations(node.id, { signal: controller.signal }))); + if (!controller.signal.aborted) setSnapshot({ deployment, nodes: nodes.data, allocations: allocations.flatMap((page) => page.data) }); + })().catch((error) => { if (!controller.signal.aborted) setError(message(error)); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [client, revision]); + async function enroll() { + const controller = lifetime.current; + if (!controller || busy) return; + setBusy(true); setError(null); setEnrollment(null); + try { + const result = await client.createEnrollment({ signal: controller.signal }); + if (!controller.signal.aborted) setEnrollment(result); + } catch (error) { if (!controller.signal.aborted) setError(message(error)); } + finally { if (!controller.signal.aborted) setBusy(false); } + } + async function remove() { + const controller = lifetime.current; + if (!controller || !removeId || busy) return; + setBusy(true); setError(null); + try { + const result = await client.removeNode(removeId, { signal: controller.signal }); + if (!result.deleted || result.id !== removeId) throw new Error("Core did not confirm node removal. Refresh to check its state."); + if (!controller.signal.aborted) { setRemoveId(null); setRevision((v) => v + 1); } + } catch (error) { if (!controller.signal.aborted) setError(message(error)); } + finally { if (!controller.signal.aborted) setBusy(false); } + } + return
+
{loading ? Loading sandbox state… : null}
+ {error ?

{error}{snapshot ? " Previously loaded state is shown below." : ""}

: null} + {snapshot ? <> +
+
Provider
{snapshot.deployment.provider === "docker" ? "Docker" : "microsandbox"}
+
Maintenance
{snapshot.deployment.maintenance ? "Enabled" : "Off"}
+
Installation
{snapshot.deployment.installation_id}
+
+

Nodes

Local nodes run on the Core server. Capacity and counts are reported by Core.

+ {snapshot.nodes.length ?
+ {snapshot.nodes.map((node) => )} +
NodeHealthActive / limitReservedRetained / limitRunning / snapshotsCleanup pendingActions
{node.name}{node.id}{node.active} / {node.max_active}{node.reserved}{node.retained} / {node.max_retained}{node.running} / {node.snapshots}{node.cleanup_pending}
:

No nodes registered. Add a node to provide hosted capacity.

} + {removeId ?

Remove node {removeId}? Core rejects removal while allocations or retained resources remain.

: null} +
+

Allocations

+ {snapshot.allocations.length ?
+ {snapshot.allocations.map((allocation) => )} +
SessionNodeRecorded stateRecorded computeHealth
{allocation.session_id}{snapshot.nodes.find((node) => node.id === allocation.node_id)?.name ?? allocation.node_id}{allocation.state}{allocation.compute_phase}{!allocation.diagnostic ? "No reported issue" : null}
:

No sandbox allocations.

} +
+

Add node

+

Install parsar-sandbox-node and prepare its {snapshot.deployment.provider} provider configuration on the target host. Adjust the absolute paths, node name and capacity in the command before running it.

+ + {!enrollment ? : <> +

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

+