Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The console runs beside the administrator's own Core, with execution, files and
- **Sandbox reset** is an explicit administrator operation in System → Sandbox configuration. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment credentials. A new configuration is then a separate deliberate save.
- **Online sandbox configuration** changes the same backend's resources, Runtime or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation.
- **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write.
- **E2B template onboarding** accepts the builder's non-secret JSON through Import template build, or a discovered or manually entered exact template build. The [template builder guide](../../services/core/deploy/e2b/README.md#import-a-build-into-web) owns the file contract and operator steps. Import and endpoint edits clear the entered key; the operator reviews the endpoint before entering a key for it. Empty catalogs link to the builder guide. Template discovery, build discovery and save failures remain distinguishable. Import and discovery never imply a verified Runtime or bypass Core's admission checks.
- **E2B deployments** have no machines: Nodes offers a link to System's sandbox configuration. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no node column or Add node action; a sandbox's dialog adds its disk use.
- **microsandbox** suspends idle sandboxes into snapshots, so its nodes show how many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox metrics and Overview; a node's allocations show how long each has been suspended and about when Core reclaims it. Docker never suspends and shows none of it.
- **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment whose template build is not reported as not ready), a default model provider on the default harness (on any enabled harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function openConsole(page: Page, request: APIRequestContext, hash =
export async function selectFixtureE2BBuild(page: Page) {
await page.getByLabel("E2B API key").fill("fixture-private-key");
await page.getByLabel("Template", { exact: true }).selectOption("template");
await page.getByLabel("Template build").selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe");
await page.getByLabel("Template build", { exact: true }).selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe");
}

/** Makes the next matching write fail once with the given status. */
Expand Down
121 changes: 121 additions & 0 deletions apps/web/e2e/e2b-template-import.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { readFileSync } from "node:fs";
import { expect, test, type Page } from "@playwright/test";
import { expectManagementBoundary, openConsole, writes } from "./console";

const fixture = JSON.parse(readFileSync(new URL("../../../services/core/deploy/e2b/testdata/template-manifests.json", import.meta.url), "utf8"))[0].value;
const build = { ...fixture, api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai", template: "template:94be54a1-138c-4f30-bc87-b13686272dbe" };
const discoveryPath = "/core/v1/sandbox/providers/e2b/discovery";
async function upload(page: Page, value: unknown) {
await page.getByLabel("Import template build", { exact: true }).setInputFiles({ name: "build.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(value)) });
}

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

test("imports a build locally, forgets the old key and saves only validated configuration fields", async ({ page, request }, info) => {
await openConsole(page, request, "system?id=sandbox", { sandbox: "none" });
await page.getByRole("button", { name: "E2B cloud", exact: true }).click();
await page.getByLabel("E2B API key", { exact: true }).fill("old-fixture-key");
await upload(page, build);
await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue("");
await expect(page.getByLabel("Sandbox API URL", { exact: true })).toHaveValue(build.api_url);
await expect(page.getByLabel("Template build", { exact: true })).toHaveValue(build.template);
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled();
// Give a pending discovery debounce time to fire; import must cancel it.
await page.waitForTimeout(650);
expect(await writes(request)).toEqual([]);
await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key");
await expect(page.getByLabel("Template build", { exact: true })).toHaveValue(build.template);
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeEnabled();
await page.screenshot({ path: info.outputPath("import-en.png"), fullPage: true });
await page.getByRole("button", { name: "Next", exact: true }).click();
const sent = page.waitForRequest((r) => r.method() === "POST" && r.url().endsWith("/sandbox/deployment"));
await page.getByRole("button", { name: "Save configuration", exact: true }).click();
expect((await sent).postDataJSON()).toEqual({ provider: "e2b", expected_generation: 0, configuration: { api_url: build.api_url, domain: build.domain, template: build.template }, credential: { api_key: "fixture-private-key" } });
await expect(page.getByRole("heading", { name: "Sandbox configuration", level: 1 })).toBeVisible();
const storage = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }));
expect(storage).not.toContain("fixture-private-key");
expect(storage).not.toContain(build.template);
});

test("rejects credentials in a file and clears entered credentials when changing endpoints", async ({ page, request }) => {
await openConsole(page, request, "system?id=sandbox", { sandbox: "none" });
await page.getByRole("button", { name: "E2B cloud", exact: true }).click();
await upload(page, { ...build, api_key: "must-not-be-imported" });
await expect(page.getByRole("alert")).toContainText("valid OpenAgentCore template build JSON");
await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue("");
expect(await writes(request)).toEqual([]);
await upload(page, build);
await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key");
await page.getByLabel("Sandbox API URL", { exact: true }).fill("https://custom.sandbase.ai");
await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue("");
await expect(page.getByLabel("Template build", { exact: true })).toHaveValue("");
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled();
});

test("offers build guidance for an empty catalog and identifies both discovery failure stages", async ({ page, request }) => {
await openConsole(page, request, "system?id=sandbox", { sandbox: "none" });
await page.getByRole("button", { name: "E2B cloud", exact: true }).click();
let mode = "empty";
await page.route(`**${discoveryPath}`, async (route) => {
const query = route.request().postDataJSON().query;
if (mode === "empty") return route.fulfill({ json: { templates: [] } });
if (mode === "templates-error" || query.template) return route.fulfill({ status: 503, json: { error: { message: "fixture", code: "sandbox_verification_unconfirmed" } } });
return route.fulfill({ json: { templates: [{ id: "template", names: ["fixture-runtime"] }] } });
});
await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key");
await expect(page.getByText("No templates are visible to this key.", { exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Build an OpenAgentCore template", exact: true })).toHaveAttribute("href", /deploy\/e2b\/README.md#build-a-template$/);
mode = "templates-error";
await page.getByLabel("E2B API key", { exact: true }).fill("fixture-second-key");
await expect(page.getByRole("alert")).toContainText("Template discovery failed");
mode = "builds-error";
await page.getByRole("button", { name: "Try again", exact: true }).click();
await page.getByLabel("Template", { exact: true }).selectOption("template");
await expect(page.getByRole("alert")).toContainText("Build discovery failed");
await page.getByRole("button", { name: "Enter an exact template build", exact: true }).click();
await page.getByLabel("Template build", { exact: true }).fill("template:latest");
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled();
await page.getByLabel("Template build", { exact: true }).fill(build.template);
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeEnabled();
});

test("a delayed import cannot overwrite a later endpoint edit", async ({ page, request }) => {
await openConsole(page, request, "system?id=sandbox", { sandbox: "none" });
await page.getByRole("button", { name: "E2B cloud", exact: true }).click();
await page.evaluate(() => {
const original = File.prototype.text;
File.prototype.text = function () {
return new Promise<string>((resolve, reject) => {
(window as unknown as { finishBuildImport: () => void }).finishBuildImport = () => { void original.call(this).then(resolve, reject); };
});
};
});
await upload(page, build);
await page.getByLabel("Sandbox API URL", { exact: true }).fill("https://custom.sandbase.ai");
await page.evaluate(() => (window as unknown as { finishBuildImport: () => void }).finishBuildImport());
await expect(page.getByLabel("Sandbox API URL", { exact: true })).toHaveValue("https://custom.sandbase.ai");
await expect(page.getByText("Build imported.", { exact: false })).toHaveCount(0);
expect(await writes(request)).toEqual([]);
});

test("an imported build still surfaces Core admission rejection and supports Chinese", async ({ page, request }, info) => {
await openConsole(page, request, "system?id=sandbox", { sandbox: "none" });
await page.getByRole("button", { name: "E2B cloud", exact: true }).click();
await upload(page, build);
await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key");
await page.route("**/core/v1/sandbox/deployment", async (route) => {
if (route.request().method() !== "POST") return route.continue();
return route.fulfill({ status: 400, json: { error: { message: "fixture", code: "sandbox_configuration_invalid", param: "configuration" } } });
});
await page.getByRole("button", { name: "Next", exact: true }).click();
await page.getByRole("button", { name: "Save configuration", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connect E2B", exact: true })).toBeVisible();
await expect(page.getByRole("alert")).toContainText("Select a ready immutable E2B template build with matching resources.");
await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue("");
await expect(page.getByLabel("Template build", { exact: true })).toHaveValue(build.template);
await page.getByRole("button", { name: "Language and appearance" }).click();
await page.getByRole("menuitemradio", { name: "简体中文" }).click();
await expect(page.getByLabel("导入模板构建文件", { exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "构建 OpenAgentCore 模板", exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath("import-zh-rejected.png"), fullPage: true });
});
6 changes: 3 additions & 3 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ test("uses the official E2B preset and clears a selected build when the key chan
await expect(page.getByRole("button", { name: "Next" })).toBeEnabled();
await page.getByLabel("E2B API key").fill("changed-fixture-key");
await expect(page.getByLabel("Template", { exact: true })).toHaveValue("");
await expect(page.getByLabel("Template build")).toHaveValue("");
await expect(page.getByLabel("Template build", { exact: true })).toHaveValue("");
await expect(page.getByRole("button", { name: "Next" })).toBeDisabled();
});

Expand All @@ -295,9 +295,9 @@ test("shows the retained E2B build while a replacement key is checked", async ({
await page.getByRole("button", { name: "Change resources" }).click();
const edit = page.getByRole("dialog", { name: "Change resources" });
const saved = "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b";
await expect(edit.getByLabel("Template build")).toHaveValue(saved);
await expect(edit.getByLabel("Template build", { exact: true })).toHaveValue(saved);
await edit.getByLabel("E2B API key").fill("replacement-fixture-key");
await expect(edit.getByLabel("Template build")).toHaveValue(saved);
await expect(edit.getByLabel("Template build", { exact: true })).toHaveValue(saved);
await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled();
});

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/sandbox/SandboxSetupWizard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { validEndpoint } from "./SandboxSetupWizard";
import { validEndpoint } from "./e2b-template-manifest";

describe("E2B endpoint input", () => {
it("accepts the official default and a paired compatible service", () => {
Expand Down
Loading