Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ workbench.
- `/console/config` reports the node installer (`node_installer`,
`node_installer_sha256`) and the self-hosted executor installer
(`self_hosted_installer`, `self_hosted_installer_sha256`); an installer is
offered only with a 64-hex digest. Signing in grants administration, so sandbox
offered only with a 64-hex digest. It also lists the providers it has node files
for (`node_artifacts`); without the deployment's provider, Add node says so and
issues no command. Signing in grants administration, so sandbox
administration is available unless the console explicitly reports
`sandbox_admin: false`; then the Nodes page explains that it is not configured
and the fleet figures show as unavailable.
Expand Down
11 changes: 6 additions & 5 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71";
* with a node enrolled with an earlier address), `credentials: "none"` a Core without a
* credential encryption key, which cannot store a model provider's key, and
* `installers: "none"` a console without its node installation payload, so it serves neither
* the node nor the self-hosted installer.
* the node nor the self-hosted installer. `nodeArtifacts` lists the providers the console has
* node files for, both by default; as in the console, microsandbox needs Docker's files too.
*/
export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none"; installers?: "none" }
export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] }

/** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */
export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) {
await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}${options.credentials ? `&credentials=${options.credentials}` : ""}${options.installers ? `&installers=${options.installers}` : ""}`);
await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}${options.credentials ? `&credentials=${options.credentials}` : ""}${options.installers ? `&installers=${options.installers}` : ""}${options.nodeArtifacts ? `&artifacts=${options.nodeArtifacts.join(",")}` : ""}`);
}

/** Opens the console already signed in, in English. */
Expand All @@ -36,8 +37,8 @@ export async function failNext(request: APIRequestContext, failure: { method: st
await request.post(`${fixture}/__fixture/fail-next`, { data: failure });
}

/** Registers a node, or changes one, as a host running an enrollment command would. */
export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string }) {
/** Registers a node, or changes one, as a host running the last enrollment command (or, with its `enrollment_id`, another one) would. */
export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string; enrollment_id?: string }) {
await request.post(`${fixture}/__fixture/node`, { data: node });
}

Expand Down
33 changes: 20 additions & 13 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function e2bDeployment() {
return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } };
}

function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true) {
function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true, artifacts = "docker,microsandbox") {
// Self-hosted Sessions get their remote_url from public_url, as in Core.
const base = buildDemo(undefined, address === "local" ? LOCAL_URL : PUBLIC_URL);
const now = Math.floor(Date.now() / 1000);
Expand Down Expand Up @@ -114,10 +114,13 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d
deployment: null,
// Whether the console has its node installation payload, and so serves both installers.
installers,
// The providers whose node files the console serves (/console/config node_artifacts).
nodeArtifacts: artifacts.split(",").filter(Boolean),
};
state.deployment = sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment();
// Each node reports the address it enrolled with; in "stale" mode the first one enrolled before public_url changed.
state.nodes.forEach((node, index) => { node.core_url = address === "stale" && index === 0 ? OLD_URL : publicUrl(); });
// The seeded nodes enrolled before Core recorded enrollment IDs.
state.nodes.forEach((node, index) => { node.core_url = address === "stale" && index === 0 ? OLD_URL : publicUrl(); node.enrollment_id = null; });
if (sandbox === "e2b") Object.assign(state, { nodes: [], allocations: [] });
}
reset();
Expand Down Expand Up @@ -188,10 +191,12 @@ async function consoleRoute(request, response, url) {
}
if (url.pathname === "/console/config") {
// As Core's console: signing in grants administration, so it reports only its installers,
// both served from the node installation payload, and without that payload neither.
// both served from the node installation payload, and without that payload neither, and the
// providers whose node files that payload holds (always a list, empty without it).
const served = state.installers;
return send(response, 200, {
node_installer: served, node_installer_sha256: served ? "a".repeat(64) : "",
node_artifacts: served ? state.nodeArtifacts : [],
self_hosted_installer: served, self_hosted_installer_sha256: served ? SELF_HOSTED_INSTALLER_SHA256 : "",
});
}
Expand Down Expand Up @@ -331,12 +336,14 @@ async function sandboxRoute(request, response, path) {
}
if (path === "/nodes") return send(response, 200, { data: state.nodes });
if (path === "/enrollment-tokens" && request.method === "POST") {
// As Core: a one-time token valid for ten minutes, whose limits the node it enrolls takes;
// As Core: a one-time token valid for ten minutes, whose limits and enrollment ID the node it enrolls takes;
// only microsandbox keeps a retained limit above the active one.
const input = await body(request);
const maxActive = input.max_active ?? 1;
state.enrollmentLimits = { max_active: maxActive, max_retained: state.deployment?.provider === "microsandbox" ? input.max_retained ?? maxActive : maxActive };
return send(response, 201, { token: `enroll_fixture_${state.nextId++}`, expires_at: new Date(Date.now() + 10 * 60_000).toISOString() });
const serial = state.nextId++;
const enrollmentId = `00000000-0000-4000-8000-${String(serial).padStart(12, "0")}`;
state.enrollment = { max_active: maxActive, max_retained: state.deployment?.provider === "microsandbox" ? input.max_retained ?? maxActive : maxActive, enrollment_id: enrollmentId };
return send(response, 201, { token: `enroll_fixture_${serial}`, expires_at: new Date(Date.now() + 10 * 60_000).toISOString(), enrollment_id: enrollmentId });
}
let m;
if ((m = path.match(/^\/nodes\/([^/]+)\/allocations$/))) return send(response, 200, { data: state.allocations.filter((entry) => entry.node_id === m[1]) });
Expand Down Expand Up @@ -405,12 +412,12 @@ async function executorCredentialRoute(request, response, projectId, environment

/**
* A node as it first registers with the last enrollment command: not yet connected,
* provider not ready, with the token's limits. Like Core, it has no diagnostic
* field until one is reported.
* provider not ready, with the token's limits and enrollment ID. Like Core, it has no
* diagnostic field until one is reported.
*/
function registeredNode(nodeId) {
const limits = state.enrollmentLimits ?? { max_active: 1, max_retained: 1 };
return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() };
const { enrollment_id = null, ...limits } = state.enrollment ?? { max_active: 1, max_retained: 1 };
return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), enrollment_id, online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() };
}

const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-provider$/;
Expand Down Expand Up @@ -488,16 +495,16 @@ async function harnessRoute(request, response, path) {
async function fixtureRoute(request, response, url) {
if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true });
if (url.pathname === "/__fixture/reset" && request.method === "POST") {
reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("credentials") ?? "configured", url.searchParams.get("installers") !== "none");
reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("credentials") ?? "configured", url.searchParams.get("installers") !== "none", url.searchParams.get("artifacts") ?? undefined);
return send(response, 200, { ok: true });
}
if (url.pathname === "/__fixture/fail-next" && request.method === "POST") {
state.failNext = await body(request); // { method, path, status, code?, message? }
return send(response, 200, { ok: true });
}
if (url.pathname === "/__fixture/node" && request.method === "POST") {
// { id, ...fields }: registers the node on first use, then applies the fields (online, provider_ready, diagnostic);
// an empty diagnostic removes the field, as Core omits it.
// { id, ...fields }: registers the node on first use, then applies the fields (online, provider_ready, diagnostic,
// or another command's enrollment_id); an empty diagnostic removes the field, as Core omits it.
const { id: nodeId, ...fields } = await body(request);
let node = state.nodes.find((entry) => entry.id === nodeId);
if (!node) state.nodes.push(node = registeredNode(nodeId));
Expand Down
21 changes: 18 additions & 3 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { expectManagementBoundary, openConsole, setNode, writes } from "./consol

test.afterEach(async ({ request }) => expectManagementBoundary(request));

test("adds a node: host requirements, a countdown, the same command after closing, a new one after expiry, then registration", async ({ page, request }) => {
test("adds a node: host requirements, a countdown, the same command after closing, a new one after expiry, then its own node's registration", async ({ page, request }) => {
await page.clock.install();
// Core counts a token's ten minutes on its own clock; the page's clock stands in for it, so fast-forwarding expires a command.
await page.route("**/core/v1/sandbox/enrollment-tokens", async (route) => {
Expand All @@ -19,6 +19,7 @@ test("adds a node: host requirements, a countdown, the same command after closin
await expect(add.getByText("Docker at /var/run/docker.sock for that user, enforcing CPU and memory limits")).toBeVisible();
await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeVisible();
await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB")).toBeVisible();
await expect(add.getByText(/^Can reach http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes must reach https:\/\/core\.example\.com$/)).toBeVisible();
await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0);
// The fixture console runs on loopback, where another machine can't download from it.
await expect(add.getByRole("note")).toContainText("other machines can't reach");
Expand All @@ -34,12 +35,16 @@ test("adds a node: host requirements, a countdown, the same command after closin
const progress = add.getByRole("status", { name: "Registration progress" });
await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/);

// Closing keeps the command for the next opening.
// Closing keeps the command for the next opening. Meanwhile another command's node, with the
// same limits, registers: it reports another enrollment ID, so it is not this command's node.
const first = await field.inputValue();
await add.getByRole("button", { name: "Close dialog" }).click();
await expect(add).toBeHidden();
await setNode(request, { id: "node-other", name: "edge-05", enrollment_id: "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a" });
await page.getByRole("button", { name: "Add node" }).click();
await expect(field).toHaveValue(first);
await expect(page.getByRole("table", { name: "Sandbox nodes" })).toContainText("edge-05");
await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/);

// Once expired, a new command is issued only on request, for the same limits.
await page.clock.fastForward("10:30");
Expand All @@ -52,7 +57,7 @@ test("adds a node: host requirements, a countdown, the same command after closin
await expect(field).not.toHaveValue(first);

// The node registers while the dialog is closed and the command lapses: reopening reads the
// node list, and the command's node outranks its expiry.
// node list, and the node reporting the command's enrollment ID outranks its expiry.
await add.getByRole("button", { name: "Close dialog" }).click();
await expect(add).toBeHidden();
await setNode(request, { id: "node-new", name: "edge-04" });
Expand Down Expand Up @@ -90,6 +95,16 @@ test("adds a node: host requirements, a countdown, the same command after closin
await expect(add.getByLabel("Sandboxes at once")).toHaveValue("2");
});

test("says the console has no node files for the provider and issues no command", async ({ page, request }) => {
// A thin bundle: the console holds no node files at all.
await openConsole(page, request, "nodes", { nodeArtifacts: [] });
await page.getByRole("button", { name: "Add node" }).click();
const add = page.getByRole("dialog", { name: "Add node" });
await expect(add.getByRole("status")).toHaveText("This console has no node files for Docker. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.");
await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0);
expect(await writes(request)).toEqual([]);
});

test("removes a node after confirmation", async ({ page, request }) => {
await openConsole(page, request, "nodes");
await page.getByRole("button", { name: "Remove edge-03" }).click();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/overview/test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function node(id: string, overrides: Partial<SandboxNode> = {}): SandboxN
cpu_count: 8, available_memory_bytes: 1024, available_disk_bytes: 2048,
running: 1, snapshots: 0, last_seen_at: "2026-09-24T00:00:00Z",
max_active: 4, max_retained: 8, active: 1, reserved: 0, retained: 0, cleanup_pending: 0,
created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example",
created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example", enrollment_id: null,
...overrides,
};
}
Expand Down
Loading