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: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<harness>.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/<kind>/` and `~/.oac/environments/<environment-id>/`.
- 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

Expand Down
26 changes: 13 additions & 13 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
19 changes: 8 additions & 11 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});
Expand Down Expand Up @@ -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<string, unknown> | null = null;
Expand All @@ -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();
Expand All @@ -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" });
Expand Down
8 changes: 4 additions & 4 deletions apps/web/e2e/sandbox-generation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/fleet/SandboxResetNotice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SandboxResetNotice deployment={value} failed={failed} onRetry={() => {}} />);

describe("reset notices on read-only surfaces", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/fleet/fleet-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(); });
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/fleet/use-sandbox-fleet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading