diff --git a/AGENTS.md b/AGENTS.md index acd8b64d3..a4fb63ce9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,9 @@ Existing code still breaks these rules in places. The bullets below are examples - Protocol definitions spread over several files, such as the Sandbox Provider contract across `services/core/internal/sandbox/` and `services/core/internal/providercontract/`. - Support discovered by type assertion, such as daemon workspace reads in `apps/daemon/internal/dispatch/workspace_read.go` and Core's observation source selection in `services/core/cmd/server/main.go`. - Harness-specific code in shared places, such as Core engine profiles in `services/core/internal/engine/.go`, daemon discovery and registration, the installer's Harness list and default in `deploy/install/config.schema.json`, and Core's own default Harness when `OAC_DEFAULT_HARNESS` is unset. -- Vendor-specific configuration, routes and UI outside the adapter, such as the E2B selection and store fields (`services/core/internal/sandbox/selection.go`), the `/core/v1/sandbox/e2b/*` routes, E2B credential hooks in `providers.Adapter` and the E2B Web views. +- Vendor-specific UI outside the adapter, such as E2B Web views. Configuration storage and management now use the adapter codec contract described in the [Sandbox Provider guide](docs/sandbox-provider.md#register-the-provider-kind). - Host-local state spread over several `~/.oac/` directories, such as the Runtime's `~/.oac/daemon/`, `~/.oac/runtime//` and `~/.oac/environments//`. -- Persistence and vendor types in the Core and machine OpenAPI documents, such as the `store.*` and `e2b.*` definitions in `contracts/agents-api/core.openapi.yaml`. +- Persistence and vendor types in the Core and machine OpenAPI documents, such as the `store.*` definitions in `contracts/agents-api/core.openapi.yaml`. ## Documentation diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index eff4e675b..a7cb44c35 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -80,18 +80,18 @@ function nodeRollout(previous = 0) { } function unconfiguredDeployment(generation = 0, ownerEpoch = 3) { - return { installation_id: INSTALLATION_ID, provider: "", core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; } function configuredDeployment() { - return { installation_id: INSTALLATION_ID, provider: "docker", core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "docker", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; // E2B runs sandboxes in its cloud: no nodes, only what Core holds there. function e2bDeployment() { - return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: templateBuild } }; + return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } }; } function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true, artifacts = "docker,microsandbox") { @@ -331,16 +331,16 @@ function nodeDetail(node) { } async function sandboxRoute(request, response, path, url) { - const e2bTemplates = ["/e2b/templates", "/e2b/templates/template/builds"]; + const e2bTemplates = ["/providers/e2b/discovery"]; if (e2bTemplates.includes(path)) { if (request.method !== "POST") return error(response, 405, "Method not allowed."); const input = await body(request); const knownEndpoint = [ ["https://sandbox.sandbase.ai", "sandbox.sandbase.ai"], ["https://api.e2b.app", "e2b.app"], - ].some(([apiURL, domain]) => input.api_url === apiURL && input.domain === domain); - if (input.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection."); - if (path === "/e2b/templates") return send(response, 200, { templates: [{ id: "template", names: ["fixture-runtime"] }] }); + ].some(([apiURL, domain]) => input.configuration?.api_url === apiURL && input.configuration?.domain === domain); + if (input.credential?.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection."); + if (!input.query?.template) return send(response, 200, { templates: [{ id: "template", names: ["fixture-runtime"] }] }); return send(response, 200, { builds: [{ id: "94be54a1-138c-4f30-bc87-b13686272dbe", cpus: 2, memory_mib: 2048 }] }); } // Retired even for authenticated callers; never reinterpret maintenance as reset. @@ -393,16 +393,16 @@ async function sandboxRoute(request, response, path, url) { if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, OAC_PUBLIC_URL for Core).", "sandbox_configuration_error"); // Synthetic classifier outcomes only; never persist or echo submitted keys. if (e2b) { - if (!input.e2b?.template || (initialize && !input.e2b.api_key) || (Object.hasOwn(input.e2b ?? {}, "api_key") && !input.e2b.api_key)) return error(response, 400, "The E2B API key was rejected.", "e2b_api_key_invalid"); - if (input.e2b.api_key === "fixture-other-team-key") return error(response, 409, "This E2B key cannot manage the retained deployment. Reset before changing teams.", "e2b_team_mismatch"); - if (input.e2b.api_key === "fixture-invalid-key") return error(response, 400, "The E2B API key was rejected.", "e2b_api_key_invalid"); + if (!input.configuration?.template || (initialize && !input.credential?.api_key) || (Object.hasOwn(input, "credential") && !input.credential?.api_key)) return error(response, 400, "The E2B API key was rejected.", "sandbox_credential_invalid"); + if (input.credential?.api_key === "fixture-other-team-key") return error(response, 409, "This E2B key cannot manage the retained deployment. Reset before changing teams.", "sandbox_credential_ownership"); + if (input.credential?.api_key === "fixture-invalid-key") return error(response, 400, "The E2B API key was rejected.", "sandbox_credential_invalid"); } // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; const previous = state.deployment; const specification = { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }; - const explicitKey = e2b && Object.hasOwn(input.e2b, "api_key"); - const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.e2b.template === previous.e2b?.template); + const explicitKey = e2b && Object.hasOwn(input, "credential"); + const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.configuration.template === previous.configuration?.template); // Omission can be a no-op; every explicit key, including identical bytes, // takes the verified replacement path and advances the target generation. if (sameSelection && !explicitKey) return send(response, 200, previous); @@ -416,7 +416,7 @@ async function sandboxRoute(request, response, path, url) { resources: held, rollout: e2b ? { ...noNodeRollout(), previous_generation_sandboxes: held.allocations + held.pending } : nodeRollout(held.allocations + held.pending), specification, - ...(e2b ? { e2b: { template: input.e2b?.template ?? "", api_url: input.e2b?.api_url ?? state.deployment.e2b?.api_url ?? "https://api.e2b.app", domain: input.e2b?.domain ?? state.deployment.e2b?.domain ?? "e2b.app", credential_configured: true, template_build: templateBuild } } : {}), + ...(e2b ? { configuration: { template: input.configuration?.template ?? "", api_url: input.configuration?.api_url ?? state.deployment.configuration?.api_url ?? "https://api.e2b.app", domain: input.configuration?.domain ?? state.deployment.configuration?.domain ?? "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } } : { configuration: {}, metadata: {}, credential_configured: false }), suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, }; return send(response, 200, state.deployment); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 380df6b76..e89b35994 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -261,13 +261,10 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, await page.getByRole("button", { name: "Save configuration" }).click(); await expect(page.getByRole("heading", { name: "Sandbox configuration", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); - expect(submitted).toMatchObject({ provider: "e2b", e2b: { - api_key: "fixture-private-key", template: "template:94be54a1-138c-4f30-bc87-b13686272dbe", - api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai", - } }); + expect(submitted).toMatchObject({ provider: "e2b", configuration: { template: "template:94be54a1-138c-4f30-bc87-b13686272dbe", api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" } , credential: { api_key: "fixture-private-key" } }); expect(await writes(request)).toEqual([ - "POST /core/v1/sandbox/e2b/templates", - "POST /core/v1/sandbox/e2b/templates/template/builds", + "POST /core/v1/sandbox/providers/e2b/discovery", + "POST /core/v1/sandbox/providers/e2b/discovery", "POST /core/v1/sandbox/deployment", ]); }); @@ -308,7 +305,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async const runtime = { source_commit: "0".repeat(40), image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}`, microsandbox_ref: `oac-runtime@sha256:${"b".repeat(64)}`, runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) }; const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime }; - let deployment = { installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, + let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64), suspension: null }; let submitted: Record | null = null; @@ -330,7 +327,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async expect(submitted).not.toHaveProperty("core_url"); }); -test("keeps the page usable when Core refuses a sandbox change, and shows Core's reason", async ({ page, request }) => { +test("keeps the page usable when Core refuses a sandbox change, and shows a safe refusal", async ({ page, request }) => { await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 403, message: "This console is read-only." }); await page.getByRole("button", { name: "Own machines" }).click(); @@ -339,12 +336,12 @@ test("keeps the page usable when Core refuses a sandbox change, and shows Core's const save = page.getByRole("button", { name: "Save configuration" }); await save.click(); // A clear refusal changed nothing: no "couldn't confirm" dialog, and the same page to try again. - await expect(page.getByText("This console is read-only.")).toBeVisible(); + await expect(page.getByText("Core rejected the sandbox configuration.")).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); - // One code covers several reasons, so a conflict shows Core's own. + // Only fixed safe copy is shown for configuration errors. await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 409, code: "sandbox_deployment_conflict", message: "Another administrator changed the deployment; it is now at generation 2." }); await save.click(); - await expect(page.getByText("Another administrator changed the deployment; it is now at generation 2.")).toBeVisible(); + await expect(page.getByText("The sandbox deployment cannot change in its current state.")).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); await save.click(); const added = page.getByRole("dialog", { name: "Add node" }); diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index 11d5f0b5c..e3d56da3e 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -4,7 +4,7 @@ import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@oac/age import { expectManagementBoundary, failNext, openConsole, setDeployment, setNode, writes } from "./console"; const deploymentPath = "/core/v1/sandbox/deployment"; -const templateDiscoveryPath = "/core/v1/sandbox/e2b/templates"; +const templateDiscoveryPath = "/core/v1/sandbox/providers/e2b/discovery"; const rollout = (page: Page) => page.getByRole("region", { name: "Configuration rollout", exact: true }); const fact = (scope: Locator, label: string) => scope.locator("dt").filter({ hasText: new RegExp(`^${label}`) }).locator("..").locator("dd"); async function inspectRollout(page: Page, values: Record) { @@ -181,15 +181,15 @@ test("E2B omitted-key updates keep the saved key while explicit same-key replace const initial = await deploymentRead(page); await editE2B(page); const omitted = await saveConfiguration(page); - expect(omitted.input).toMatchObject({ provider: "e2b", expected_generation: 1, e2b: { template: initial.e2b!.template } }); - expect(omitted.input.e2b).not.toHaveProperty("api_key"); + expect(omitted.input).toMatchObject({ provider: "e2b", expected_generation: 1, configuration: { template: initial.configuration!.template } }); + expect(omitted.input).not.toHaveProperty("credential"); expect((await omitted.response.json()).generation).toBe(1); await expect(page.getByRole("dialog", { name: "Change resources", exact: true })).toBeHidden(); const key = "fixture-same-team-key"; for (const generation of [1, 2]) { await editE2B(page, key); const explicit = await saveConfiguration(page); - expect(explicit.input).toMatchObject({ provider: "e2b", expected_generation: generation, e2b: { template: initial.e2b!.template, api_key: key } }); + expect(explicit.input).toMatchObject({ provider: "e2b", expected_generation: generation, configuration: { template: initial.configuration!.template } , credential: { api_key: key } }); const current = await explicit.response.json() as SandboxDeployment; expect(current.generation).toBe(generation + 1); expect(current.resources).toEqual(initial.resources); diff --git a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx index ee8c03aaf..427c1c816 100644 --- a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx +++ b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx @@ -6,7 +6,7 @@ import { SandboxResetNotice } from "./SandboxResetNotice"; const reset: SandboxReset = { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 2, on_offline_nodes: 1, offline_nodes: [{ node_id: "n1", name: "Node 1", resources: 1 }] } }; -const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, pending: 0 }, suspension: null }); +const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, pending: 0 }, suspension: null }); const render = (value: SandboxDeployment | undefined, failed = false) => renderToStaticMarkup( {}} />); describe("reset notices on read-only surfaces", () => { diff --git a/apps/web/src/features/fleet/fleet-queries.test.ts b/apps/web/src/features/fleet/fleet-queries.test.ts index 2663adad9..963af5991 100644 --- a/apps/web/src/features/fleet/fleet-queries.test.ts +++ b/apps/web/src/features/fleet/fleet-queries.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxScope } from "../sandbox/sandbox-queries"; import { fleetQuery } from "./fleet-queries"; -const deployment: SandboxDeployment = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null, +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index 95c7ea02b..966d264d8 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -11,7 +11,7 @@ import { consoleConfigQuery, fleetQuery, type FleetSnapshot } from "./fleet-quer import { FleetReadNotice } from "./FleetReadNotice"; import { fleetSnapshot, useSandboxFleet } from "./use-sandbox-fleet"; -const configured: SandboxDeployment = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; +const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; function Probe() { const { state, deployment } = useSandboxFleet(); diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 14402658b..1e1262607 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -234,10 +234,10 @@ function CloudSection({ deployment }: { deployment: SandboxDeployment }) { const { navigate } = useConsoleNavigation(); // An E2B selection may adopt its template build's size instead of saving one. const resources = sandboxSize(deployment); - const build = deployment.e2b?.template_build; + const build = deployment.metadata?.template_build; const disk = build?.resources.root_disk_mib ?? null; const status = templateBuildStatus(build); - const template = deployment.e2b?.template; + const template = deployment.configuration?.template; return (
): string { function cloudHost(fleet: FleetSnapshot | null): CloudHost | null { if (fleet?.deployment.provider !== "e2b") return null; - return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.e2b?.template || null }; + return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.configuration?.template || null }; } /** Core and its sandbox nodes (or E2B's cloud) as a topology; each opens a popover with the way onward. */ diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 1af2a70be..356c075dc 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -5,7 +5,7 @@ import type { FleetState } from "../fleet/use-sandbox-fleet"; import { checklistStorageKey, checklistView, gettingStartedSteps, nextStepAfterNode, rememberInstallation } from "./getting-started"; import { node, project } from "./test-fixtures"; -const deployment = (overrides: Partial = {}): SandboxDeployment => ({ +const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, suspension: null, ...overrides, }); @@ -63,7 +63,7 @@ describe("Getting started steps", () => { expect(sandboxes(fleet(deployment(), []))).toMatchObject({ state: "todo", action: "add-node" }); expect(sandboxes(fleet(deployment(), [node("n1", { provider_ready: false }), node("n2", { online: false })]))).toMatchObject({ state: "todo", action: "nodes" }); expect(sandboxes(fleet(deployment()))).toMatchObject({ state: "done" }); - const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", e2b: { template: "t", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } }); + const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", configuration: { template: "t", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } }); expect(sandboxes(fleet(e2b("building"), []))).toMatchObject({ state: "todo", cloud: true }); expect(sandboxes(fleet(e2b("ready"), []))).toMatchObject({ state: "done", cloud: true }); // Saved before Core recorded the build: Core admitted it, so it counts as ready. diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 92477c184..f1f803ce9 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -69,7 +69,7 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { const { deployment, nodes } = fleet.snapshot; if (!deployment.provider) return { state: "todo", action: "setup", cloud: false }; if (deployment.provider === "e2b") { - return { state: templateBuildStatus(deployment.e2b?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true }; + return { state: templateBuildStatus(deployment.metadata?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true }; } if (nodes.some(nodeServingReady)) return { state: "done", action: "nodes", cloud: false }; return { state: "todo", action: nodes.length ? "nodes" : "add-node", cloud: false }; diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index a59d15841..692432f6a 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -34,7 +34,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset const spec = deployment.specification; // An E2B selection may adopt its template build's size instead of saving one. const size = sandboxSize(deployment); - const build = deployment.e2b?.template_build; + const build = deployment.metadata?.template_build; const buildSize = templateBuildSize(deployment); const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); const canEdit = fresh && !deployment.reset; @@ -58,17 +58,17 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset {deployment.provider === "e2b" ?
-
{t("Sandbox API URL")}
{deployment.e2b?.api_url}
-
{t("Sandbox data-plane domain")}
{deployment.e2b?.domain}
+
{t("Sandbox API URL")}
{deployment.configuration?.api_url}
+
{t("Sandbox data-plane domain")}
{deployment.configuration?.domain}
-
{t("Template build")}{deployment.e2b?.template || t("Unknown state")}
+
{t("Template build")}{deployment.configuration?.template || t("Unknown state")}
{[ t(buildStatusLabel[templateBuildStatus(build)]), ...(buildSize ? [sizeLabel(buildSize)] : []), ...(build?.resources.root_disk_mib != null ? [t("{{disk}} disk", { disk: formatBytes(build.resources.root_disk_mib * MIB) })] : []), ].join(" · ")}
-
{t("E2B credential")}
{t(deployment.e2b?.credential_configured ? "Configured" : "Not configured")}
+
{t("E2B credential")}
{t(deployment.credential_configured ? "Configured" : "Not configured")}
: null} @@ -83,7 +83,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset key={editKey} coreUrl={deployment.core_url} expectedGeneration={deployment.generation} - current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.e2b?.template, e2bAPIURL: deployment.e2b?.api_url, e2bDomain: deployment.e2b?.domain } : undefined} + current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined} disabled={disabled || !canEdit} editing onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }} diff --git a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx index 06290f867..7d95ba13c 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx @@ -5,7 +5,7 @@ import { SandboxResetControls } from "./SandboxResetControls"; const reset: SandboxReset = { clear: "auto", requested_at: "2020-01-01T10:00:00Z", deadline_at: "2020-01-01T11:00:00Z", forced_at: null, remaining: { busy: 2, idle: 1, cleanup: 3, on_offline_nodes: 2, offline_nodes: [{ node_id: "offline-a", name: "Offline A", resources: 2 }] } }; -const deployment = (value: SandboxReset | null): SandboxDeployment => ({ rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, pending: 1 }, suspension: null }); +const deployment = (value: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, pending: 1 }, suspension: null }); const render = (value: SandboxReset | null, stale = false) => renderToStaticMarkup( true} onCancel={async () => true} />); describe("authoritative reset progress", () => { diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx index 4d1a7e246..58cf365b9 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -6,7 +6,7 @@ import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; import { NodeRolloutStatus } from "./NodeRolloutStatus"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; -const deployment: SandboxDeployment = { installation_id: "i", owner_epoch: 1, generation: 4, provider: "docker", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, pending: 2 }, reset: null, suspension: null, +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "i", owner_epoch: 1, generation: 4, provider: "docker", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, pending: 2 }, reset: null, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, update_required: 3, unknown: 4 } } }; describe("authoritative configuration rollout", () => { diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index e6ea4f068..63fb3316a 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -233,8 +233,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ...(sized ? { resources } : {}), ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), }; - if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { ...e2bUpdateSelection(template, apiKey), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); - else await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); + if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { ...e2bUpdateSelection(template, apiKey), configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); + else await onSubmit({ ...selection, ...(provider === "e2b" ? { credential: { api_key: apiKey.trim() }, configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); } catch (error) { // A configuration Core rejected is explained here; the page reports every other failure. const reason = sandboxConfigurationRejection(error, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); @@ -242,10 +242,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setRejection(reason); setFieldRejection(error); if (error instanceof AgentCoreError && error.param) { - if (["e2b.api_key", "e2b.template", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "e2b_team_mismatch") setStep("e2b"); + if (["credential", "configuration", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("e2b"); else if (error.param === "runtime" || error.param.startsWith("resources.")) setStep("advanced"); } - setResetRequired(error instanceof AgentCoreError && ["e2b_team_mismatch", "sandbox_reset_required"].includes(error.code ?? "")); + setResetRequired(error instanceof AgentCoreError && ["sandbox_credential_ownership", "sandbox_reset_required"].includes(error.code ?? "")); setAddressRejected(error instanceof AgentCoreError && error.code === "sandbox_configuration_error"); } finally { setApiKey(""); @@ -292,7 +292,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab - {t("Console → API Keys")}