diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index ee010ed18..0111a8d4f 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -63,8 +63,9 @@ jobs: OAC_TEST_OFFICIAL_SDK_PYTHON: python run: | python services/core/tests/official_schema_test.py - python services/core/tests/official_client.py + # The Go Workers need an unclaimed deployment; Core claims it for its installation ID. go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient)$' -count=1 + python services/core/tests/official_client.py - uses: ./.github/actions/e2b-provider if: inputs.container - name: Verify the distribution's Core image diff --git a/AGENTS.md b/AGENTS.md index 500d502b4..e91a1f448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Do not multiply entities without necessity. The long-term goal is minimal code, Each setting and each piece of data is written in one place and read from that place: no second copy, no environment-variable or file fallback and no alias. A new setting joins its category and lives beside its peers. -The categories are [process settings](docs/configuration.md#process-settings), [derived files](docs/configuration.md#how-oac-apply-works), [secrets](docs/configuration.md#installation-directory), and Core's database for [runtime settings](docs/configuration.md#runtime-settings-web) and execution data. [Configuration](docs/configuration.md) owns the installation layout and the settings themselves. +The categories are [process settings](docs/configuration.md#process-settings), [secrets](docs/configuration.md#compose-installations), and Core's database for [runtime settings](docs/configuration.md#runtime-settings-web) and execution data. [Configuration](docs/configuration.md) owns the installation layout and the settings themselves. ### Pre-release: no compatibility layers diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 94ad4fd43..3addd7584 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -367,8 +367,8 @@ Dialogs are 448px Paper cards (960px when wide) with 8px corners, a 52px header - **ConfirmDialog**: the one grammar for destructive actions. The body states what will be deleted and its consequences; the footer holds Cancel (outline) and the confirm button (danger), whose label changes while busy. Core's reason for a rejection, or an uncertain-outcome warning, appears in red inside the dialog. The Skill page's delete dialogs follow the same grammar; deleting a whole Skill also requires typing its name. Archiving a project says in bold that it can't be undone, then how many active keys it revokes (the project read's count, or more when its loaded key list shows more) and that assets and accepted work stay; with active keys it too requires typing the project's name, shown in mono with its inner spaces kept (surrounding spaces are forgiven, Unicode compared in NFC). While the project list is read again Archive waits; if that read failed, a red line says the count may be out of date and Archive stays disabled. - **Key dialogs**: name fields carry their rules in a help tip and their problem in red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. - **Executor credential dialog** (640px): the shown-once notice, then a prompt to save the JSON privately before Done. Download credential file is primary; Copy credential is secondary. Installation commands are not repeated here. Done forgets the credential; closing preserves it in the pending card. The native installer reads the unchanged JSON file: its absolute path is entered during interactive installation or passed with `--credential-file`; tokens never enter command arguments. -- **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. For Docker, explain that membership in the docker group is root-equivalent. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to System or the Overview. -- **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images (and, for microsandbox, keeps its image store and data). The command requires root or sudo; there is no user-service alternative. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public URL, which the dialog reads again if it is not at hand: until then one line says it is being checked, a failed read says so with Try again, and a public URL other machines can't use (loopback, or none) gets a line saying the service stays on the host and no command can be given. Done dismisses it and focus returns to the page heading. +- **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. The docker group's root-equivalent access is stated once, in **Use Docker instead of microsandbox?**. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to System or the Overview. +- **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images; the uninstaller prints what it keeps. The command requires root or sudo; there is no user-service alternative. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public URL, which the dialog reads again if it is not at hand: until then one line says it is being checked, a failed read says so with Try again, and a public URL other machines can't use (loopback, or none) gets a line saying the service stays on the host and no command can be given. Done dismisses it and focus returns to the page heading. - **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists what it gives up, each point a 600 Ink lead over a Graphite line: weaker isolation (containers share the host kernel; microsandbox gives each sandbox its own microVM), root-equivalent access (the node's account joins the docker group) and limited use (trusted workloads, or hosts without KVM). The footer holds Use Docker (outline) and Keep microsandbox (primary), which takes focus; closing or Escape keeps microsandbox too. - **Edit node**: the name, then the sandbox limit with one 12px Graphite line under it once the node's heartbeat has the host's CPUs and memory: the host, each sandbox's size and at most how many fit. The Nodes list and a node's Capacity show "Active / limit" for Docker and microsandbox alike, so a saved limit shows where it was set. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key) together, then curl and Python (with the pinned SDK), each listing the project's Agents and creating a Session with a first message (`environment`, an inline `agent` with `model: ""`, and `input`). A copy the clipboard refuses selects the sample and says so in red underneath. One Graphite line says to put a model the model provider serves in place of ``, and that running an Agent needs a model provider: in each request, saved on the Agent, or the deployment default. An active project's page shows the same samples as a section without any key: the Shell block exports a quoted placeholder, and a Graphite line above the samples says to use a key issued for this project, shown only once at issuance. When the public address is loopback, a note above the samples says the API is reachable only on the Core machine; without a public address only a note to set one shows. Before the installation is read, a skeleton holds the first sample's place. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 5a8afa95e..f2022d3a8 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -32,7 +32,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions needing attention, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, with a popover glance at each, the attention table, usage by project), Core metrics (the Core process's CPU and memory, execution slots and the Turn queue, connected daemons, the database and background jobs), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Agent metrics' By Agent table opens an Agent's page and, from its failed Turns, its Sessions in the Session log. - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. - **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, capacity, host figures, allocations and individual node operations). Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Removal offers the host's uninstall command. System owns installation facts, the Domain and HTTPS secondary page, each harness's default model configuration, startup settings, and a link to the Sandbox configuration secondary page. That page owns setup, resource edits, rollout details and reset. Setup selects a backend, size and Runtime, then asks for a deliberate save; own-machine setup continues to Add node. -- A node whose provider is not ready names the reason (Docker unreachable, no Docker limits, missing Runtime image, no KVM, missing microsandbox components, a host too small) and its fix in the help tip beside its status, wherever that status shows. +- A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, a host too small) and its fix in the help tip beside its status, wherever that status shows. - A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes list and its page its status is Old address, with "Remove and add again", never Available. - **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. diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 603d38da2..1a25f2350 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -12,17 +12,15 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; * `sandbox` the sandbox deployment, `nodes: "none"` a deployment no node has joined, and * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" * (loopback: only the Core machine reaches the API, and every sandbox selection is rejected) or "stale" (public, - * 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 + * with a node enrolled with an earlier address), and `installers: "none"` a console without its node installation payload, so it serves neither * 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"; nodeArtifacts?: ("docker" | "microsandbox")[] } +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; 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}` : ""}${options.nodeArtifacts ? `&artifacts=${options.nodeArtifacts.join(",")}` : ""}`); + 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.installers ? `&installers=${options.installers}` : ""}${options.nodeArtifacts ? `&artifacts=${options.nodeArtifacts.join(",")}` : ""}`); } const v1Requests: string[] = []; diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index dd7572437..b0a7601ac 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -85,7 +85,7 @@ export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "http sessions.sort((a, b) => b.created_at - a.created_at); const nodes = [ { rollout: { state: "ready", ready_generation: 1 }, id: "node-local", name: "core-01", provider: "docker", online: true, provider_ready: true, cpu_count: 16, available_memory_bytes: 38 * 2 ** 30, available_disk_bytes: 410 * 2 ** 30, running: 5, snapshots: 2, last_seen_at: new Date((now - 8) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 5, reserved: 1, retained: 2, cleanup_pending: 0, created_at: new Date((now - 86400 * 30) * 1000).toISOString() }, - { rollout: { state: "failed", ready_generation: null, diagnostic: "docker_limits_unsupported" }, id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, diagnostic: "docker_limits_unsupported", cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, + { rollout: { state: "failed", ready_generation: null, diagnostic: "host_unsupported" }, id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, diagnostic: "host_unsupported", cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, { rollout: { state: "unknown", ready_generation: 1 }, id: "node-edge", name: "edge-03", provider: "docker", online: false, provider_ready: false, cpu_count: 8, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: new Date((now - 5400) * 1000).toISOString(), max_active: 4, max_retained: 8, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date((now - 86400 * 3) * 1000).toISOString() }, ]; const hosted = sessions.filter((session) => session.environment.type === "openai_hosted"); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index c3883c869..aeff30833 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -92,7 +92,7 @@ function e2bDeployment() { 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") { +function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", installers = true, artifacts = "docker,microsandbox") { // Self-hosted Sessions get their remote_url from public_url, as in Core. const screenshots = process.env.OAC_WEB_SCREENSHOT_DEMO === "1"; const now = Math.floor(Date.now() / 1000); @@ -112,8 +112,6 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d executorCredentials: new Map(), // How config.json's public_url is set: "public", "local" or "stale". installation: address, - // "none": Core has no credential encryption key, so it cannot store a provider's key. - credentialKey: credentials !== "none", // Startup state and deployment default model provider per harness; API keys are never kept. // The demo deployment's default harness has a default model; a fresh install has none. harnesses: { @@ -574,8 +572,6 @@ async function harnessRoute(request, response, path) { if (!support(harness).protocols.includes(input.model_provider?.protocol)) return error(response, 400, "This harness does not support this protocol.", "model_provider_protocol_unsupported"); const problem = providerProblem(harness, input.model_provider ?? {}); if (problem) return error(response, 400, problem, "invalid_request_error"); - // As Core's error mapping: sealing the key needs the credential encryption key. - if (!state.credentialKey) return error(response, 503, "Credential encryption is not configured on this service.", "credential_storage_unavailable"); entry.provider = { object: "core.model_configuration", harness, model: input.model, harness_config: input.harness_config ?? {}, model_provider: { protocol: input.model_provider.protocol, base_url: input.model_provider.base_url, api_key_configured: true, @@ -596,7 +592,7 @@ 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", url.searchParams.get("artifacts") ?? undefined); + 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("installers") !== "none", url.searchParams.get("artifacts") ?? undefined); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/deployment" && request.method === "POST") { diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index c48b1beea..be9449e51 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -24,7 +24,7 @@ test("shows the deployment's health on Overview and each monitor page", async ({ await page.getByRole("button", { name: "Sandbox metrics" }).click(); await expect(page.getByRole("table").first()).toContainText("core-01"); // A degraded node names why its provider is not ready. - await expect(page.locator(".status-with-help").filter({ hasText: /^Provider not ready/ }).getByRole("button", { name: "Docker limits unsupported", exact: true })).toBeVisible(); + await expect(page.locator(".status-with-help").filter({ hasText: /^Provider not ready/ }).getByRole("button", { name: "Host unsupported", exact: true })).toBeVisible(); }); test("opens a Session's conversation from the Session log, read-only", async ({ page, request }) => { diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index f404a5de6..6c9bb2c4d 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -15,14 +15,12 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await openConsole(page, request, "nodes"); await page.getByRole("button", { name: "Add node" }).click(); const add = page.getByRole("dialog", { name: "Add node" }); - // What a Docker host needs for the default command, which installs the node with sudo. - await expect(add.getByText("Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)")).toBeVisible(); + // What a host needs for the default command, which installs the node with sudo. + await expect(add.getByText("What the sandbox backend needs on the host; the installer checks it and names anything missing")).toBeVisible(); await expect(add.getByText("SELinux is not enforcing")).toBeVisible(); await expect(add.getByText("In sudo mode, one Core per host: a host already running a sudo-mode node for another Core is refused.")).toBeVisible(); await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB; about 2 GB of disk for the Runtime image")).toBeVisible(); await expect(add.getByText("Reaches https://core.example.com, as do its sandboxes")).toBeVisible(); - await expect(add.getByText("oac-node joins the docker group, which is equivalent to root on this host.")).toBeVisible(); - await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0); // Node installation only offers a system service; there is no user-mode alternative. await expect(add.getByText("No sudo on this host?")).toHaveCount(0); await expect(add.getByLabel("One-time enrollment command without sudo", { exact: true })).toHaveCount(0); @@ -93,9 +91,9 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await expect(problem).toContainText("Not connected yet"); await expect(problem).toContainText("sudo journalctl -u oac-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service"); await expect(add.getByText("No sudo on this host?")).toHaveCount(0); - // Connected, it reports why Docker isn't ready; once ready, the node is connected. - await setNode(request, { id: "node-new", online: true, diagnostic: "docker_limits_unsupported" }); - await expect(problem).toContainText("Docker limits unsupported"); + // Connected, it reports why its provider isn't ready; once ready, the node is connected. + await setNode(request, { id: "node-new", online: true, diagnostic: "host_unsupported" }); + await expect(problem).toContainText("Host unsupported"); await expect(progress).toContainText("Waiting for Docker"); await setNode(request, { id: "node-new", provider_ready: true, diagnostic: "" }); await expect(progress).toHaveText("edge-04 · Connected"); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts index a731ed974..50a77be5e 100644 --- a/apps/web/e2e/system.spec.ts +++ b/apps/web/e2e/system.spec.ts @@ -129,25 +129,6 @@ test("sets, replaces and clears a harness's default model configuration, and kee expect(browserState).not.toContain(KEY); }); -test("reports a Core without a credential key as a configuration error, without rereading", async ({ page, request }) => { - await openConsole(page, request, "system", { fresh: true, credentials: "none" }); - const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); - await codex.getByRole("button", { name: "Set the default model configuration for Codex" }).click(); - const set = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); - await set.getByLabel("Base URL").fill("https://model.example/v1"); - await set.getByLabel("API key").fill(KEY); - await set.getByLabel("Default model ID").fill("fixture-model"); - const reads: string[] = []; - page.on("request", (sent) => { if (sent.method() === "GET" && new URL(sent.url()).pathname === "/core/v1/harnesses") reads.push(sent.url()); }); - await set.getByRole("button", { name: "Save" }).click(); - await expect(set.getByRole("alert")).toHaveText("Core has no credential encryption key configured, so it can't store keys. Installer-based installs configure this automatically; for manual deployments, set OAC_CREDENTIAL_KEY_FILE for Core."); - await expect(set.getByRole("button", { name: "Save" })).toBeEnabled(); - expect(reads).toEqual([]); - expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-configuration"]); - await set.getByRole("button", { name: "Cancel" }).click(); - await expect(codex).toContainText("Not set"); -}); - test("reports an unconfirmed save, reads the default model configurations again once and never repeats the write", async ({ page, request }) => { await openConsole(page, request, "system", { fresh: true }); const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); diff --git a/apps/web/src/components/InstallationNotice.test.tsx b/apps/web/src/components/InstallationNotice.test.tsx index 81a8ab535..5545cd40b 100644 --- a/apps/web/src/components/InstallationNotice.test.tsx +++ b/apps/web/src/components/InstallationNotice.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { InstallationNotice } from "./InstallationNotice"; const installation: CoreInstallation = { - object: "core.installation", installation_id: null, public_url: "http://127.0.0.1:8091", api_base_url: "http://127.0.0.1:8091/v1", + object: "core.installation", installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", public_url: "http://127.0.0.1:8091", api_base_url: "http://127.0.0.1:8091/v1", source_commit: null, local_only: true, configuration: { settings: [] }, address_bindings: { nodes: 0, nodes_on_other_address: 0, hosted_sandboxes: 0, self_hosted_executors: 0 }, }; diff --git a/apps/web/src/features/api-keys/HowToCall.tsx b/apps/web/src/features/api-keys/HowToCall.tsx index a7c14739e..8c49e2bca 100644 --- a/apps/web/src/features/api-keys/HowToCall.tsx +++ b/apps/web/src/features/api-keys/HowToCall.tsx @@ -64,14 +64,12 @@ export function callSamples(apiBaseUrl: string, apiKey: string | null, keyPlaceh /** * The samples, or why there are none. The console never sends these requests. - * When Core is reachable only on its own machine it says so, and without a - * public address it says to set one instead of guessing. + * When Core is reachable only on its own machine it says so. */ function HowToCallBody({ apiKey }: { apiKey: string | null }) { const { t } = useTranslation("keys"); const { t: tCommon } = useTranslation("common"); const installation = useQuery(installationQuery); - const base = installation.data?.api_base_url ?? null; if (installation.data === undefined) { return installation.isError && !installation.isFetching @@ -79,8 +77,7 @@ function HowToCallBody({ apiKey }: { apiKey: string | null }) { // The first sample's place, as the console's other first reads hold theirs. :
; } - if (base === null) return

{t("howToCall.noAddress")}

; - const samples = callSamples(base, apiKey, t("howToCall.keyPlaceholder")); + const samples = callSamples(installation.data.api_base_url, apiKey, t("howToCall.keyPlaceholder")); return ( <> {installation.data.local_only ?

{t("howToCall.localOnly")}

: null} diff --git a/apps/web/src/features/fleet/fleet-model.ts b/apps/web/src/features/fleet/fleet-model.ts index 08abf927c..c35e58747 100644 --- a/apps/web/src/features/fleet/fleet-model.ts +++ b/apps/web/src/features/fleet/fleet-model.ts @@ -29,14 +29,15 @@ export interface CapacitySummary { maxRetained: number; reserved: number; cleanupPending: number; - /** Sandboxes held suspended (microsandbox only): placed, but not counted as active. */ + /** Sandboxes held suspended (where the Provider declares checkpoint support): placed, but not counted as active. */ suspended: number; } /** * Sandboxes a node holds suspended: Core counts every unreleased placement as * retained and only the ones not suspended as active, so the difference is - * what sleeps as a snapshot. Only microsandbox suspends; elsewhere it is 0. + * what sleeps as a snapshot. Only a Provider that declares checkpoint support + * suspends; elsewhere it is 0. */ export function suspendedSandboxes(node: SandboxNode): number { return Math.max(0, node.retained - node.active); diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index e3b70c6a8..66d59f109 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -108,10 +108,10 @@ export function SandboxMetricsPage() { const [openNode, setOpenNode] = useState(null); const [openRuntime, setOpenRuntime] = useState(null); const rows = useMemo(() => (runtimeState.load ? hostedRuntimeRows(runtimeState.load, "", fleet) : []), [fleet, runtimeState.load]); - // E2B runs sandboxes in its cloud: no machines, so no node table, node column or node dialog. - const cloud = fleet?.deployment.provider === "e2b"; - // Only microsandbox suspends sandboxes into snapshots; its nodes also show how many sleep. - const suspends = fleet?.deployment.provider === "microsandbox"; + // A direct Provider runs sandboxes in its cloud: no machines, so no node table, node column or node dialog. + const cloud = fleet?.deployment.mode === "direct"; + // Only a Provider that declares checkpoint support suspends sandboxes; its nodes also show how many sleep. + const suspends = Boolean(fleet?.deployment.suspension); return (
@@ -205,6 +205,7 @@ export function SandboxMetricsPage() { stale={fleetObservationStale(fleetState)} load={runtimeState.load} range={range} + suspends={suspends} onClose={() => setOpenNode(null)} /> row.observation.session_id === openRuntime) ?? null} showNode={!cloud} onClose={() => setOpenRuntime(null)} /> @@ -310,7 +311,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R {durable ? : history.isError ?

{t("sandbox.charts.historyFailed", { reason: history.error instanceof Error ? history.error.message : "" })}

: history.isFetched ?

{t("sandbox.charts.historyUnavailable")}

: null} - + ); } @@ -322,7 +323,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R {t("sandbox.runtimeSection")} {usage?.hosted ? ( - {t(fleet?.deployment.provider === "microsandbox" ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", { + {t(fleet?.deployment.suspension ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", { n: formatInteger(usage.hosted, locale), sleeping: formatInteger(usage.sleeping, locale), cpu: usage.cpuUsageCores === null ? MISSING : t("sandbox.cores", { value: formatCores(usage.cpuUsageCores, locale) }), @@ -448,8 +449,10 @@ function useLast(value: T | null): T | null { * A node in a dialog: the host figures it reports with each heartbeat, and * CPU and memory of the hosted sandboxes placed on it over the page's range. */ -function NodeDialog({ node, rows, load, range, stale, onClose }: { +function NodeDialog({ node, rows, load, range, stale, suspends, onClose }: { stale: boolean; + /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ + suspends: boolean; node: SandboxNode | null; rows: readonly HostedRuntimeRow[]; load: HostedRuntimeLoad | null; @@ -503,8 +506,8 @@ function NodeDialog({ node, rows, load, range, stale, onClose }: {
{t("sandbox.targetPreparation")}
{t("sandbox.servingGeneration")}
{shown.rollout.ready_generation ?? MISSING}{t("sandbox.servingGenerationHelp")}
{t("sandbox.slots")}
{formatInteger(shown.active, locale)} / {formatInteger(shown.max_active, locale)}
- {shown.provider === "microsandbox" ?
{t("sandbox.suspended")}
{formatInteger(suspendedSandboxes(shown), locale)}
: null} - {shown.provider === "microsandbox" ?
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
: null} + {suspends ?
{t("sandbox.suspended")}
{formatInteger(suspendedSandboxes(shown), locale)}
: null} + {suspends ?
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
: null}
{t("sandbox.cpus")}
{cpu}
{t("sandbox.memory")}
{memory}
{t("sandbox.freeDiskColumn")}
{online ? formatBytes(shown.available_disk_bytes) : MISSING}
diff --git a/apps/web/src/features/overview/FleetOverview.render.test.tsx b/apps/web/src/features/overview/FleetOverview.render.test.tsx index 2b363ee88..7604690bc 100644 --- a/apps/web/src/features/overview/FleetOverview.render.test.tsx +++ b/apps/web/src/features/overview/FleetOverview.render.test.tsx @@ -10,6 +10,7 @@ describe("fleet overview popovers", () => { const html = renderToStaticMarkup( void; coreLabel: string; coreTone: Tone; @@ -124,7 +126,7 @@ export function FleetOverview({ nodes, cloud, coreLabel, coreTone, stale, onOpen } > - + ); })} @@ -141,7 +143,7 @@ function Fact({ label, children }: { label: string; children: ReactNode }) { } /** A node at a glance: reachability (with the reason a degraded provider is not ready), sandbox slots and what the node has left. */ -function NodeGlance({ node, health, stale }: { node: SandboxNode; health: NodeHealth; stale: boolean }) { +function NodeGlance({ node, health, stale, suspends }: { node: SandboxNode; health: NodeHealth; stale: boolean; suspends: boolean }) { const { t, i18n } = useTranslation("overview"); const locale = i18n.resolvedLanguage; const now = Math.floor(Date.now() / 1000); @@ -160,7 +162,7 @@ function NodeGlance({ node, health, stale }: { node: SandboxNode; health: NodeHe {node.rollout.ready_generation ?? MISSING}{t("fleet.servingGenerationHelp")} {seen === null ? t("fleet.facts.never") : formatRelative(seen, now, locale)} {count(node.active)}/ {count(node.max_active)} - {node.provider === "microsandbox" ? {count(suspendedSandboxes(node))} : null} + {suspends ? {count(suspendedSandboxes(node))} : null} {node.cpu_count === null ? MISSING : t("fleet.facts.cores", { count: node.cpu_count })} {formatBytes(node.available_memory_bytes)} {formatBytes(node.available_disk_bytes)} diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index e0e366b71..0b8975260 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -206,7 +206,7 @@ export function OverviewPage() { help={t("kpi.slotsHelp")} value={capacity ? <>/ {formatInteger(capacity.maxActive, locale)} : MISSING} sub={capacity - ? fleet?.deployment.provider === "microsandbox" + ? fleet?.deployment.suspension ? t("tiles.nodesOnlineSuspended", { online: capacity.online, total: capacity.nodes, suspended: capacity.suspended }) : t("tiles.nodesOnline", { online: capacity.online, total: capacity.nodes }) : fleetDetail(fleetState, t)} @@ -313,7 +313,7 @@ function fleetDetail(state: FleetState, t: TFunction<"overview">): string { } function cloudHost(fleet: FleetSnapshot | null): CloudHost | null { - if (fleet?.deployment.provider !== "e2b") return null; + if (fleet?.deployment.mode !== "direct") return null; return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.configuration?.template || null }; } @@ -345,6 +345,7 @@ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; co navigate("system", { id: "sandbox" })} coreLabel={t(`coreStatus.${core}`)} coreTone={coreTone[core]} diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 56a9990a7..0194079ae 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -54,7 +54,7 @@ describe("Getting started steps", () => { }); it("keeps local-only installations to do even with a ready node or cloud deployment", () => { for (const provider of ["docker", "e2b"] as const) { - const steps = gettingStartedSteps({ sandboxReset: false, fleet: fleet(deployment({ provider })), projects: [], sessions: 1, harnesses: [], localOnly: true }); + const steps = gettingStartedSteps({ sandboxReset: false, fleet: fleet(deployment({ provider, mode: provider === "e2b" ? "direct" : "nodes" })), projects: [], sessions: 1, harnesses: [], localOnly: true }); expect(steps.sandboxes).toMatchObject({ state: "todo", action: "nodes", cloud: provider === "e2b" }); } }); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 8a781e398..915595455 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -41,10 +41,10 @@ export function gettingStartedSteps(input: { const { sessions } = input; return { sandboxes: input.sandboxReset !== false - ? { state: input.sandboxReset === "failed" ? "unknown" : input.sandboxReset ? "todo" : null, action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.provider === "e2b" } + ? { state: input.sandboxReset === "failed" ? "unknown" : input.sandboxReset ? "todo" : null, action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.mode === "direct" } : input.localOnly === undefined || input.localOnly === "failed" ? { state: input.localOnly === "failed" ? "unknown" : null, action: "nodes", cloud: false } - : input.localOnly ? { state: "todo", action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.provider === "e2b" } : sandboxStep(input.fleet), + : input.localOnly ? { state: "todo", action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.mode === "direct" } : sandboxStep(input.fleet), model: modelStep(input.harnesses), key: keyStep(input.projects), session: { @@ -56,8 +56,8 @@ export function gettingStartedSteps(input: { /** * Own machines are ready once the deployment is saved and a node is online - * with its provider ready; E2B once the deployment is saved, since Core admits - * only a ready template build. Only a build Core reports as not ready leaves + * with its provider ready; a direct Provider (E2B) once the deployment is + * saved, since Core admits only a ready template build. Only a build Core reports as not ready leaves * the step to do; a selection saved before Core recorded its build has no * status and counts as done. */ @@ -65,10 +65,10 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { if (fleet.status !== "ready") { return { state: fleet.status === "failed" ? "unknown" : null, action: "nodes", cloud: false }; } - if (fleet.error) return { state: "unknown", action: "nodes", cloud: fleet.snapshot.deployment.provider === "e2b" }; + if (fleet.error) return { state: "unknown", action: "nodes", cloud: fleet.snapshot.deployment.mode === "direct" }; const { deployment, nodes } = fleet.snapshot; if (!deployment.provider) return { state: "todo", action: "setup", cloud: false }; - if (deployment.provider === "e2b") { + if (deployment.mode === "direct") { 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 }; diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx index fcd1bbf38..cc8a41c0f 100644 --- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -12,7 +12,6 @@ export interface NodeCleanup { name: string; installationId: string; scriptDigest: string; - provider: string; /** The Core address the node enrolled with, when it is no longer the deployment's; else null. */ oldAddress: string | null; } @@ -23,7 +22,7 @@ export interface NodeCleanup { * The installer first confirms with Core, at the node's own address, that the * node is removed, which holds from the removal on. The command uses sudo unless the shell is already root. A node enrolled with an earlier address may find it gone; then * `--force` skips only that confirmation. Nothing deletes sandboxes, volumes or - * images. Like Add node's, the command downloads from the installation's public + * images; the uninstaller prints what it kept. Like Add node's, the command downloads from the installation's public * URL, which the dialog reads (again, if it is not at hand): until it is read, if * the read fails (with Try again), or while other machines can't use it, the * dialog says so in place of the command. It never opens empty. @@ -42,15 +41,12 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle {installation.isError ?

{join(stays, t("The installation couldn't be read, so no command can be issued."))}

:

{t("Checking this installation's public URL…")}

} - : cleanup && !sourceUrl ?
-

{join(stays, installation.data?.local_only && installation.data.public_url - ? t("Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.", { url: installation.data.public_url }) - : t("An uninstall command needs a public URL that other machines can reach, and this installation has none."))}

+
: cleanup && installation.data && !sourceUrl ?
+

{join(stays, t("Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.", { url: installation.data.public_url }))}

: cleanup ?

{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}

-

{join(t("It never deletes sandboxes, volumes or images."), - ...(cleanup.provider === "microsandbox" ? [t("It keeps microsandbox's image store and sandbox data, and prints how to remove them by hand.")] : []))}

+

{t("It never deletes sandboxes, volumes or images.")}

{cleanup.oldAddress !== null ?
{t("Old Core address gone?")}
diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index c5bad4f34..9483c0879 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -44,7 +44,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale coreUrl: string; targetGeneration?: number; stale: boolean; - /** The deployment's idle suspension policy; only microsandbox has one. */ + /** The deployment's idle suspension policy; null unless its Provider declares checkpoint support. */ suspension: SandboxDeployment["suspension"]; }) { const { t, i18n } = useTranslation("sandbox"); @@ -53,8 +53,8 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale const now = Math.floor(Date.now() / 1000); const own = allocations.filter((allocation) => allocation.node_id === node.id); const reporting = !stale && node.online; - // Only microsandbox suspends sandboxes into snapshots; Docker retains nothing. - const suspends = node.provider === "microsandbox"; + // Only a Provider that declares checkpoint support suspends sandboxes; the node shares the deployment's. + const suspends = suspension !== null; const state = nodeState(node, own, stale, coreUrl); // As in the list, an old address is the status to act on; the node's health would only distract. const diagnostic = stale || state === "old_address" ? "" : nodeDiagnostic(node); @@ -108,7 +108,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale {t("Configuration generation")} {t("Recorded state")} {t("Recorded compute")} - {/* Only microsandbox changes compute phase; under Docker it is always disabled. */} + {/* Only a Provider with a suspension policy changes compute phase; elsewhere it is always disabled. */} {suspension ? {t("In this state")}{t("How long the sandbox has been in its compute state. For a suspended one, the reclaim time is estimated from when it was suspended and the deployment's retention; Core reclaims it around then. Older allocations show a dash until their state next changes.")} : null} {t("Issue")} {t("Created")} diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 3e562e68e..0cfe46ec8 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -13,17 +13,19 @@ import { sandboxesThatFit } from "./deployment-specification"; /** * A node's name and sandbox limits (`PATCH /core/v1/sandbox/nodes/{id}`). Core - * takes all three together. Only microsandbox suspends sandboxes, so only it - * shows the retained limit; for Docker the saved one is kept, raised to at least + * takes all three together. Only a deployment whose Provider suspends sandboxes + * shows the retained limit; otherwise the saved one is kept, raised to at least * the active limit because Core requires it. Under the limit, the host's CPUs * and memory from the node's last heartbeat, each sandbox's size and how many * of those the host holds. */ -export function NodeEditDialog({ client, node, size, onClose, onSaved }: { +export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved }: { client: SandboxAdminClient; node: SandboxNode | null; /** Each sandbox's CPUs and memory, from the deployment. */ size: SandboxResources | null; + /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ + suspends: boolean; onClose: () => void; onSaved: () => void; }) { @@ -36,7 +38,6 @@ export function NodeEditDialog({ client, node, size, onClose, onSaved }: { const [retained, setRetained] = useState(String(node?.max_retained ?? 8)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const suspends = node?.provider === "microsandbox"; const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); const activeLimit = whole(active); const retainedLimit = suspends ? whole(retained) : Math.max(node?.max_retained ?? 0, activeLimit ?? 0); diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 8bcc30e87..9a1ef808a 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -10,7 +10,7 @@ import { formatBytes } from "../../lib/format"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { installationQuery } from "../../lib/installation"; import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; -import { sandboxRequestError } from "../../lib/sandbox-labels"; +import { sandboxProviderLabel, sandboxRequestError } from "../../lib/sandbox-labels"; import { checklistOpenFor, modelStep, nextStepAfterNode } from "../overview/getting-started"; import { harnessesQuery } from "../system/harness-queries"; import { nodeSourceUrl } from "./core-origin"; @@ -36,10 +36,11 @@ const DEFAULT_RETAINED = "8"; /** * Add node: the administrator sets the node's sandbox limits, then Core issues a * one-time enrollment command that approves them - * (`POST /core/v1/sandbox/enrollment-tokens`). Only microsandbox suspends - * sandboxes, so only it asks for a retained limit; Docker retains exactly the - * sandboxes it runs at once. The command downloads the installer from the - * installation's public URL, never the browser's address, and runs it with + * (`POST /core/v1/sandbox/enrollment-tokens`). Only a deployment with a + * suspension policy, which its Provider's checkpoint support declares, asks for + * a retained limit; otherwise a node retains exactly the sandboxes it runs at + * once. The command downloads the installer from the installation's public + * URL, never the browser's address, and runs it with * sudo (or directly as root), which installs the node as a system service. * The log hint names that system service. No command is issued until the installation * is read: one whose public URL other machines can't use (loopback, as @@ -91,8 +92,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // The deployment's core_url is the same address, but the installation is read again on each opening, so a fix shows at once. const publicUrl = installation.data ? nodeSourceUrl(installation.data) : null; const available = consoleConfig.node_installer; - const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null; - const backend = provider === "microsandbox" ? "microsandbox" : "Docker"; + const provider = deployment.mode === "nodes" && deployment.provider ? deployment.provider : null; + const backend = sandboxProviderLabel(deployment.provider, locale); // Nodes and their sandboxes reach Core at its public URL, so a loopback one serves no other machine; // and without the provider's node files the installer would fail on the host. Either way no command // is issued, nor before the installation is read: a failed read (an older Core, say) proves nothing. @@ -108,7 +109,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, ? { text: t("This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.", { provider: backend }) } : null; // Core takes whole numbers from 1 to a million, with the retained limit at least the active one. - const suspends = deployment.provider === "microsandbox"; + const suspends = deployment.suspension !== null; const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); const inRange = (limit: number | null): limit is number => limit !== null && limit >= 1 && limit <= 1_000_000; const activeLimit = whole(active); @@ -236,7 +237,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, size: size ? t("{{cpus}} CPU · {{memory}}", { cpus: size.cpus, memory: formatBytes(size.memory_mib * 2 ** 20) }) : "", }; const requirements = provider ? <> - + : null; const limitsForm = `${id}-limits`; const footer = !available || (!enrollment && blocker) ? undefined diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index 64c4d616a..6b071309a 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; -import type { InitializeSandboxDeployment, UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@oac/agents-client"; +import { deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxDeployment, type StartSandboxReset } from "@oac/agents-client"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -80,7 +80,7 @@ function DeploymentConfiguration() { return false; } async function initialize(input: InitializeSandboxDeployment) { - if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && input.provider !== "e2b" && !installation.data?.local_only) navigate("nodes", {}, "add-node"); + if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && deploymentContract.providers[input.provider].mode === "nodes" && !installation.data?.local_only) navigate("nodes", {}, "add-node"); } async function update(input: UpdateSandboxDeployment) { return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true, true); @@ -94,7 +94,7 @@ function DeploymentConfiguration() { return <> - {snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b" ? : null} + {snapshot?.deployment.mode === "nodes" ? : null} } />
diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 692432f6a..94e585299 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -43,7 +43,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset

{t("Deployment provider")}

{t("One provider serves this deployment. Reset it before choosing a different backend.")} - {deployment.provider === "e2b" ? ` ${t("Core creates E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""} + {deployment.mode === "direct" ? ` ${t("Core creates E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""}
{spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 00feda0eb..ace738101 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -111,7 +111,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const { deployment } = snapshot; setCleanup({ node: { name: target.name || target.id, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, - provider: deployment.provider, oldAddress: onOldAddress(target, deployment.core_url) ? target.core_url : null, + oldAddress: onOldAddress(target, deployment.core_url) ? target.core_url : null, }, open: true }); } } @@ -124,7 +124,9 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const nodesConfirmed = confirmed && compatible && !inventoryOlder && !query.isError && !snapshot?.nodesError; const nodes = snapshot?.nodes ?? []; const allocations = snapshot?.allocations ?? []; - const hostedNodes = Boolean(snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b"); + const hostedNodes = snapshot?.deployment.mode === "nodes"; + // Nodes share the deployment's Provider, which declares whether sandboxes suspend. + const suspends = Boolean(snapshot?.deployment.suspension); // Getting started asks for Add node on arrival. A request the first settled read cannot // serve (no own-machines deployment, active reset, a failed read) is dropped, so the // dialog never opens later on its own. @@ -198,6 +200,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig client={client} node={editTarget} size={snapshot ? sandboxSize(snapshot.deployment) : null} + suspends={suspends} onClose={() => setEditTarget(null)} onSaved={() => { const saved = editTarget; @@ -223,7 +226,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{status} - {snapshot && !hostedNodes ? navigate("system", { id: "sandbox" })}>{tSandboxNav("open")}} /> : null} + {snapshot && !hostedNodes ? navigate("system", { id: "sandbox" })}>{tSandboxNav("open")}} /> : null} {snapshot?.deployment.reset && hostedNodes ?

{tSandboxNav("reset")}

: null} {snapshot?.deployment.provider ? <> {staleNodes.length ?

{staleNodes.length === 1 @@ -231,7 +234,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: new Intl.ListFormat(i18n.resolvedLanguage, { type: "conjunction" }).format(staleNodes) })}

: null} {hostedNodes ?
{nodes.length - ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> + ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> : nodesConfirmed ? : null}
: null} : null} diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 3dc2460f5..7f10b8ca6 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -14,7 +14,7 @@ import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; -import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; +import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; import { sandboxAdmin } from "./sandbox-queries"; @@ -66,11 +66,13 @@ export function validEndpoint(apiURL: string, domain: string): boolean { } /** - * Per-sandbox presets around the deployment default: half and double of it. - * Disks apply to microsandbox only, the one provider that enforces them. + * Per-sandbox presets around the Provider's declared default size: half and + * double of it, disks included where the default declares them. A Provider + * whose configuration selects the size declares none and has no presets. */ -function presets(provider: SandboxProvider): Record { +function presets(provider: SandboxProvider): Record | null { const standard = defaultSandboxResources(provider); + if (!standard) return null; const scale = (factor: number): SandboxResources => ({ cpus: Math.max(1, standard.cpus * factor), memory_mib: standard.memory_mib * factor, @@ -92,15 +94,17 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese const same = (a: SandboxResources, b: SandboxResources) => a.cpus === b.cpus && a.memory_mib === b.memory_mib && (a.root_disk_mib ?? 0) === (b.root_disk_mib ?? 0) && (a.environment_disk_mib ?? 0) === (b.environment_disk_mib ?? 0); const all = presets(provider); - return (Object.keys(all) as Preset[]).find((key) => same(all[key], resources)) ?? null; + return all ? (Object.keys(all) as Preset[]).find((key) => same(all[key], resources)) ?? null : null; } /** * Hosted sandbox setup as pages, one decision each: where sandboxes run, * which backend (own machines, microsandbox preselected) or the E2B account, - * how big each sandbox is (own machines only: E2B sandboxes take the template - * build's size), then a review. Advanced settings hold the complete form. The Runtime - * release comes from this console's distribution manifest when it serves one. + * how big each sandbox is (only for a Provider that declares a default size: + * E2B sandboxes take the template build's size), then a review. The Provider's + * declarations decide the size, disk and Runtime inputs. Advanced settings + * hold the complete form. The Runtime release comes from this console's + * distribution manifest when it serves one. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size and Runtime; another backend starts from its * defaults and this console's Runtime. E2B updates can retain the saved key. @@ -121,11 +125,15 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const { t, i18n } = useTranslation("sandbox"); const { t: tCommon } = useTranslation("common"); const id = useId(); - const [step, setStep] = useState(editing ? current?.provider === "e2b" ? "e2b" : "size" : "where"); - const [where, setWhere] = useState(current ? (current.provider === "e2b" ? "direct" : "nodes") : null); + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null; + const [step, setStep] = useState(editing ? currentMode === "direct" ? "e2b" : "size" : "where"); + const [where, setWhere] = useState(currentMode); const [provider, setProvider] = useState(current?.provider ?? null); + const policy = provider ? deploymentContract.providers[provider] : null; const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null; - const [resources, setResources] = useState(current?.specification?.resources ?? defaultSandboxResources("docker")); + // Choosing a Provider that declares a default size replaces this placeholder. + const [resources, setResources] = useState(current?.specification?.resources ?? { cpus: 0, memory_mib: 0 }); const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); const [apiKey, setApiKey] = useState(""); const [replacementRequested, setReplacementRequested] = useState(false); @@ -190,14 +198,14 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const matched = useQuery({ queryKey: ["sandbox-runtime-release"], queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), staleTime: Infinity, retry: false }); const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; - const needsRuntime = provider === "docker" || provider === "microsandbox"; + const needsRuntime = policy?.runtime ?? false; const runtimeReady = !needsRuntime || isRuntimeRelease(release); // Initial setup requires a key; an update may retain the committed key. const keyReady = e2bKeyReady(Boolean(editing), replacementRequested, apiKey); const connectionChanged = Boolean(editing && (apiURL.trim() !== (current?.e2bAPIURL || E2B_PRESETS.official.apiURL) || domain.trim() !== (current?.e2bDomain || E2B_PRESETS.official.domain))); const e2bReady = provider !== "e2b" || (keyReady && validTemplate(template.trim()) && validEndpoint(apiURL.trim(), domain.trim()) && (!editing || !connectionChanged || apiKey.trim().length > 0)); - // Core sizes E2B sandboxes from the template build, so E2B sends no resources. - const sized = provider !== null && provider !== "e2b"; + // A Provider without a declared default size takes it from its configuration, so the selection sends no resources. + const sized = Boolean(policy?.default_resources); const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources)); const ready = provider !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; @@ -205,12 +213,13 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); const back = () => setStep(step === "advanced" ? "review" : order[Math.max(0, index - 1)]!); - // The saved backend keeps its size and Runtime; another starts from its standard size and this console's Runtime. + // The saved backend keeps its size and Runtime; another starts from its declared default size and this console's Runtime. function choose(next: SandboxProvider) { if (next !== provider) { setRejection(null); const kept = current ? savedSpecification(next, current.provider, current.specification) : null; - setResources(kept?.resources ?? presets(next).standard); + const proposed = kept?.resources ?? defaultSandboxResources(next); + if (proposed) setResources(proposed); setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); setRuntime({}); } @@ -238,7 +247,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab 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"); + const reason = sandboxConfigurationRejection(error, locale); if (reason === null) throw error; setRejection(reason); setFieldRejection(error); @@ -263,7 +272,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ setWhere("direct"); choose("e2b"); setStep("e2b"); }} /> - { setWhere("nodes"); setApiKey(""); if (provider === null || provider === "e2b") choose("microsandbox"); setStep("backend"); }} /> + { setWhere("nodes"); setApiKey(""); if (policy?.mode !== "nodes") choose("microsandbox"); setStep("backend"); }} />
); @@ -333,15 +342,15 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ); } else if (step === "size") { - const options = presets(provider ?? "docker"); + const options = provider ? presets(provider) : null; // A saved size outside the presets stays on offer as the current one. const kept = saved && provider && presetOf(provider, saved.resources) === null ? saved.resources : null; - const disks = (value: SandboxResources) => (provider === "microsandbox" ? diskLabel(value) : undefined); + const disks = (value: SandboxResources) => (policy?.disk ? diskLabel(value) : undefined); page = (
{kept ? { setSize("current"); setResources(kept); setStep("review"); }} /> : null} - {(Object.keys(options) as Preset[]).map((key) => ( + {options && (Object.keys(options) as Preset[]).map((key) => (
-
{t("Sandboxes run on")}
{where === "direct" ? t("E2B cloud") : `${t("Own machines")} · ${provider === "docker" ? "Docker" : "microsandbox"}`}
-
{t("Each sandbox")}
{sized ? sizeLabel(resources) : t("From the template build")}{provider === "microsandbox" ? {diskLabel(resources)} : null}
+
{t("Sandboxes run on")}
{where === "direct" ? t("E2B cloud") : `${t("Own machines")} · ${sandboxProviderLabel(provider ?? "", locale)}`}
+
{t("Each sandbox")}
{sized ? sizeLabel(resources) : t("From the template build")}{policy?.disk ? {diskLabel(resources)} : null}
{provider === "e2b" ?
{t("Template build")}
{template || "—"}
: null} {provider === "e2b" ?
{t("Sandbox API URL")}
{apiURL || "https://api.e2b.app"}
: null} {provider === "e2b" ?
{t("Sandbox data-plane domain")}
{domain || "e2b.app"}
: null} @@ -418,7 +427,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ setSize("custom"); setResources({ ...resources, cpus }); setFieldRejection(null); }} /> { setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} /> - {provider === "microsandbox" ? <> + {policy?.disk ? <> { setResources({ ...resources, root_disk_mib }); setFieldRejection(null); }} /> { setResources({ ...resources, environment_disk_mib }); setFieldRejection(null); }} /> : null} diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index cf9188f5f..eb36c2d8e 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -1,11 +1,8 @@ -/** A provider whose nodes install from files this console serves. */ -export type NodeArtifactProvider = "docker" | "microsandbox"; - export interface SandboxConsoleConfig { node_installer: boolean; node_installer_sha256: string; /** The providers whose node files this console serves; a null or malformed value reads as none. */ - node_artifacts: NodeArtifactProvider[]; + node_artifacts: string[]; } const SHA256 = /^[a-f0-9]{64}$/; @@ -27,8 +24,8 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise entry === "docker" || entry === "microsandbox") : []; +function nodeArtifacts(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; } /** Whether a node of this provider can install from the console's files. */ diff --git a/apps/web/src/features/sandbox/core-origin.test.ts b/apps/web/src/features/sandbox/core-origin.test.ts index 36232bab9..ef555ea5d 100644 --- a/apps/web/src/features/sandbox/core-origin.test.ts +++ b/apps/web/src/features/sandbox/core-origin.test.ts @@ -6,6 +6,5 @@ describe("node command source", () => { expect(nodeSourceUrl({ public_url: "https://core.example.com:8443", local_only: false })).toBe("https://core.example.com:8443"); expect(nodeSourceUrl({ public_url: "http://10.0.0.5:8080", local_only: false })).toBe("http://10.0.0.5:8080"); expect(nodeSourceUrl({ public_url: "http://localhost:8080", local_only: true })).toBeNull(); - expect(nodeSourceUrl({ public_url: null, local_only: false })).toBeNull(); }); }); diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index dea953213..6d4de7982 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -5,7 +5,7 @@ import type { CoreInstallation } from "@oac/agents-client"; * pass it: the installation's public URL, whose reverse proxy sends * `/node-install/*` to this console. Unlike the browser's address, it is the * same from every machine. Core accepts only origins nodes may use, so it is - * null only when other machines can't reach it (`local_only`) or it is missing. + * null only when other machines can't reach it (`local_only`). */ export function nodeSourceUrl(installation: Pick): string | null { if (installation.local_only) return null; diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index eac961bff..89f333bc8 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultSandboxResources, distributionRuntime, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; -import standardSizes from "./standard-sizes.json"; -import type { SandboxSpecification } from "@oac/agents-client"; +import { deploymentContract, type SandboxProvider, type SandboxSpecification } from "@oac/agents-client"; const manifest = { platform: "linux/amd64", source_commit: "0".repeat(40), images: { runtime: `sha256:${"a".repeat(64)}` }, image_manifest_digests: { runtime: `sha256:${"b".repeat(64)}` }, runtime_ref: `oac-runtime@sha256:${"b".repeat(64)}`, @@ -27,20 +26,18 @@ describe("deployment resources and Runtime", () => { expect(savedSpecification("e2b", "docker", current)).toBeNull(); expect(savedSpecification("e2b", "e2b", { resources: current.resources })).toEqual({ resources: current.resources }); }); - it("keeps the installer's Standard sizes structure", () => { - expect(Object.keys(standardSizes).sort()).toEqual(["docker", "microsandbox"]); - expect(Object.keys(standardSizes.docker).sort()).toEqual(["cpus", "memory_mib"]); - expect(Object.keys(standardSizes.microsandbox).sort()).toEqual(["cpus", "environment_disk_mib", "memory_mib", "root_disk_mib"]); - for (const provider of ["docker", "microsandbox"] as const) { - for (const value of Object.values(standardSizes[provider])) expect(Number.isInteger(value) && value > 0).toBe(true); + it("proposes a copy of each declared default size, which the declared bounds accept", () => { + for (const provider of Object.keys(deploymentContract.providers) as SandboxProvider[]) { + const declared = deploymentContract.providers[provider].default_resources; const resources = defaultSandboxResources(provider); - expect(resources).toEqual(standardSizes[provider]); - expect(resources).not.toBe(standardSizes[provider]); - expect(validSandboxResources(provider, resources)).toBe(true); + expect(resources).toEqual(declared); + if (resources) { + expect(resources).not.toBe(declared); + expect(validSandboxResources(provider, resources)).toBe(true); + } } }); it("respects CPU, memory and supported disk bounds", () => { - for (const provider of ["docker", "microsandbox", "e2b"] as const) expect(validSandboxResources(provider, defaultSandboxResources(provider))).toBe(true); expect(validSandboxResources("docker", { cpus: 0, memory_mib: 2048 })).toBe(false); expect(validSandboxResources("e2b", { cpus: 256, memory_mib: 2048 })).toBe(false); expect(validSandboxResources("docker", { cpus: 2, memory_mib: 511 })).toBe(false); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 8859539f3..af0d3393c 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,5 +1,4 @@ import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; -import standardSizes from "./standard-sizes.json"; interface Manifest { platform?: string; @@ -10,14 +9,16 @@ interface Manifest { microsandbox?: { runtime_sha256?: string; firmware_sha256?: string }; } -export function defaultSandboxResources(provider: SandboxProvider): SandboxResources { - return { ...(provider === "microsandbox" ? standardSizes.microsandbox : standardSizes.docker) }; +/** The size the Provider declares for setup to propose; null when its configuration selects the size. */ +export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null { + const size: SandboxResources | null = deploymentContract.providers[provider].default_resources; + return size && { ...size }; } -/** Core's resource rule: optional disk fields stay zero unless the provider supports disk limits. */ +/** Core's resource rule: optional disk fields stay zero unless the Provider declares disk limits. */ export function validSandboxResources(provider: SandboxProvider, resources: SandboxResources): boolean { return deploymentContract.resources.every((rule) => { - const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : provider === "microsandbox" ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; + const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : deploymentContract.providers[provider].disk ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; const value = resources[rule.name] ?? 0; return Number.isInteger(value) && value >= min && value <= max; }); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index 2663c1792..a81e7046b 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -1,3 +1,5 @@ +import type { SandboxProvider } from "@oac/agents-client"; + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; /** @@ -43,7 +45,7 @@ const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HT * environment or sudo's command line. */ export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest }: { - token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string; + token: string; coreUrl: string; sourceUrl: string; provider: SandboxProvider; installationId: string; scriptDigest: string; }): string { return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`; } diff --git a/apps/web/src/features/sandbox/node-commands.tsx b/apps/web/src/features/sandbox/node-commands.tsx index 8d0763c64..9841556ae 100644 --- a/apps/web/src/features/sandbox/node-commands.tsx +++ b/apps/web/src/features/sandbox/node-commands.tsx @@ -67,8 +67,7 @@ function PrerequisiteList({ items, values }: { items: HostPrerequisite[]; values } /** What the host needs for the default command, which installs the node as a system service. */ -export function HostRequirements({ provider, sized, values, open, onToggle }: { - provider: "docker" | "microsandbox"; +export function HostRequirements({ sized, values, open, onToggle }: { sized: boolean; values: RequirementValues; open: boolean; @@ -77,10 +76,9 @@ export function HostRequirements({ provider, sized, values, open, onToggle }: { const { t } = useTranslation("sandbox"); return
onToggle(event.currentTarget.open)}> {t("Host requirements")} - +

{t("The command creates the oac-node service user and a system service. It installs no software; if something is missing it stops and says what to install.")}

- {provider === "docker" ?

{t("oac-node joins the docker group, which is equivalent to root on this host.")}

: null}
; } diff --git a/apps/web/src/features/sandbox/node-enrollment.test.ts b/apps/web/src/features/sandbox/node-enrollment.test.ts index 683c925df..74025f841 100644 --- a/apps/web/src/features/sandbox/node-enrollment.test.ts +++ b/apps/web/src/features/sandbox/node-enrollment.test.ts @@ -31,7 +31,7 @@ describe("node enrollment", () => { const connected = { ...fresh, online: true }; expect(enrollmentProgress(connected, 0, 1_000)).toEqual({ stage: "connected", problem: "" }); expect(enrollmentProgress(connected, 0, NODE_READY_WAIT_MS)).toEqual({ stage: "connected", problem: "provider_unavailable" }); - expect(enrollmentProgress({ ...connected, diagnostic: "kvm_unavailable" }, 0, 1_000)).toEqual({ stage: "connected", problem: "kvm_unavailable" }); + expect(enrollmentProgress({ ...connected, diagnostic: "host_unsupported" }, 0, 1_000)).toEqual({ stage: "connected", problem: "host_unsupported" }); expect(enrollmentProgress({ ...connected, provider_ready: true }, 0, NODE_READY_WAIT_MS * 2)).toEqual({ stage: "ready", problem: "" }); }); diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 7e2f6678a..33b2e3ff5 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -23,14 +23,9 @@ export interface HostPrerequisite { * systemd as the init system, and SELinux not enforcing; `other_node` refuses a * host that already runs a sudo-mode node for another installation, since * sudo-mode nodes share the oac-node account; - * - Docker: `provider_group` needs rootful Docker Engine running, its socket - * group-accessible (0660) and, through `device_group`, owned by the docker - * group, which the service user joins; and CPU and memory limits enforced (the - * node is ready only then: services/core/internal/sandbox/providers/probe.go). - * It installs nothing; - * - microsandbox: `provider_group` needs /dev/kvm, readable and writable by all or - * group-accessible in the kvm group, and `prepare_runtime` the libraries its - * binaries link (the ldd check); + * - the sandbox backend: `provider_group`, `device_group` and `prepare_runtime` + * check what the deployment's Provider needs on the host, name what is missing + * and install nothing; * - `host_capacity`: the host's CPUs and memory hold one sandbox of the * deployment's size (else the node reports capacity_insufficient); the Runtime * image needs about 2 GB of disk; @@ -40,14 +35,12 @@ export interface HostPrerequisite { * (`provider_config`). * `sized` says whether the deployment's sandbox size is known for the capacity item. */ -export function hostRequirements(provider: "docker" | "microsandbox", sized: boolean): HostPrerequisite[] { +export function hostRequirements(sized: boolean): HostPrerequisite[] { return [ { label: "Linux amd64 with systemd; Python 3.9+, curl, sha256sum and flock; root or sudo" }, { label: "SELinux is not enforcing" }, { label: "In sudo mode, one Core per host: a host already running a sudo-mode node for another Core is refused." }, - provider === "docker" - ? { label: "Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)" } - : { label: "/dev/kvm in the kvm group (hardware or nested virtualization) and the libraries microsandbox links (glibc)" }, + { label: "What the sandbox backend needs on the host; the installer checks it and names anything missing" }, { label: sized ? "CPUs and memory for at least one sandbox: {{size}}; about 2 GB of disk for the Runtime image" : "CPUs and memory for at least one sandbox; about 2 GB of disk for the Runtime image" }, { label: "Reaches {{core}}, as do its sandboxes" }, ]; diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index db2fc3937..4931b8c91 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -18,7 +18,7 @@ function cache(provider: SandboxDeployment["provider"]) { const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false, gcTime: Infinity } } }); const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "install", owner_epoch: 1, generation: 2, provider, core_url: "https://core.example", - mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, + mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; const config: SandboxConsoleConfig = { node_installer: false, node_installer_sha256: "", node_artifacts: [] }; diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts index 3c14e9dc9..643c2fab4 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.test.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts @@ -83,7 +83,7 @@ describe("sandbox reset reads", () => { const nodes = vi.spyOn(sandboxAdmin, "listNodes"); const client = cache(); for (const generation of [0, 3]) { - read.mockResolvedValueOnce(deployment({ provider: "", reset: null, generation })); + read.mockResolvedValueOnce(deployment({ provider: "", mode: "", reset: null, generation })); expect((await client.fetchQuery(sandboxSnapshotQuery)).deployment.generation).toBe(generation); } expect(nodes).not.toHaveBeenCalled(); diff --git a/apps/web/src/features/sandbox/sandbox-queries.ts b/apps/web/src/features/sandbox/sandbox-queries.ts index 00266196a..81029f64e 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.ts @@ -73,7 +73,7 @@ export const sandboxSnapshotQuery = queryOptions({ signal.throwIfAborted(); let nodes: SandboxNode[] = []; try { - if (deployment.provider && deployment.provider !== "e2b") nodes = (await sandboxAdmin.listNodes({ signal })).data; + if (deployment.mode === "nodes") nodes = (await sandboxAdmin.listNodes({ signal })).data; const allocations = await Promise.all(nodes.map((node) => sandboxAdmin.listAllocations(node.id, { signal }))); signal.throwIfAborted(); return { deployment, nodes, allocations: allocations.flatMap((page) => page.data), nodesError: null, readAt }; diff --git a/apps/web/src/features/sandbox/standard-sizes.json b/apps/web/src/features/sandbox/standard-sizes.json deleted file mode 100644 index 4461dbf50..000000000 --- a/apps/web/src/features/sandbox/standard-sizes.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "docker": { - "cpus": 2, - "memory_mib": 2048 - }, - "microsandbox": { - "cpus": 2, - "memory_mib": 4096, - "root_disk_mib": 8192, - "environment_disk_mib": 8192 - } -} diff --git a/apps/web/src/features/sandbox/standard-sizes.md b/apps/web/src/features/sandbox/standard-sizes.md deleted file mode 100644 index 8c39ef49b..000000000 --- a/apps/web/src/features/sandbox/standard-sizes.md +++ /dev/null @@ -1,23 +0,0 @@ -# Standard sandbox sizes - -`standard-sizes.json` is the single source of truth for the default Standard size of each sandbox on a self-hosted backend. The console setup wizard offers it as Standard and derives Small (half) and Large (double) from it. - -## Structure and units - -```json -{ - "docker": { "cpus": 2, "memory_mib": 2048 }, - "microsandbox": { "cpus": 2, "memory_mib": 4096, "root_disk_mib": 8192, "environment_disk_mib": 8192 } -} -``` - -- `cpus`: whole CPUs, a positive integer. -- `memory_mib`, `root_disk_mib`, `environment_disk_mib`: MiB, positive integers. -- `docker` has exactly `cpus` and `memory_mib`; it has no disk fields. -- `microsandbox` has exactly all four fields. - -The values must stay within the bounds that Core and `validSandboxResources` accept. - -## Readers - -The Web setup wizard is the only reader, through `defaultSandboxResources` in `deployment-specification.ts`. `deployment-specification.test.ts` pins the structure so that an accidental change fails. diff --git a/apps/web/src/features/system/ModelProviderDialog.tsx b/apps/web/src/features/system/ModelProviderDialog.tsx index 3e555058e..9a145278f 100644 --- a/apps/web/src/features/system/ModelProviderDialog.tsx +++ b/apps/web/src/features/system/ModelProviderDialog.tsx @@ -116,9 +116,6 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { setRejection(caught); if (caught.param === "harness_config" || ["context_window", "max_output_tokens", "model_provider.context_window", "model_provider.max_output_tokens"].includes(caught.param ?? "")) setAdvancedOpen(true); setError(coreError(caught, tCommon)); - } else if (caught instanceof AgentCoreError && caught.code === "credential_storage_unavailable") { - // A deployment without a credential key stores nothing: a configuration error, not an unknown outcome. - setError(t("models.form.noCredentialKey")); } else { setError(t("models.form.uncertain")); onReread(); diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 0fc42d3cd..2213ae7bb 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -42,11 +42,11 @@ export function SystemPage() { const facts = about ? (
- {about.public_url ? {about.public_url} : {t("installation.notSet")}} + {about.public_url} - {about.api_base_url ? : {t("installation.notSet")}} + - {about.installation_id ? : {t("installation.unknown")}} + {about.source_commit ? {about.source_commit.slice(0, 12)} : {t("installation.unknown")}}
diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index 3e7c7ccee..a3b5061df 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -19,7 +19,6 @@ export const coreErrors = { "project_exists": "A Project with this name already exists.", "project_api_key_exists": "An active API key with this name already exists.", "executor_credential_exists": "An executor credential with this name already exists.", - "credential_storage_unavailable": "Core credential storage is unavailable. Check its credential encryption configuration.", "internal_error": "Core could not complete the request.", "sandbox_generation_stale": "Core has a newer sandbox configuration. Refresh and review it before submitting again.", "sandbox_reset_required": "Reset the sandbox deployment before changing this configuration.", @@ -37,7 +36,7 @@ export const coreErrors = { "sandbox_specification_mismatch": "The saved sandbox specification does not match the deployment. Refresh to check the configuration.", "sandbox_operation_unsupported": "The selected sandbox provider does not support this operation.", "environment_unavailable": "The Session's environment is no longer available.", - "execution_unavailable": "Execution is not available on this Core.", + "execution_unavailable": "Execution is temporarily unavailable. Try again later.", "runtime_history_unavailable": "Runtime history is unavailable on this Core.", "runtime_history_unsupported": "Runtime history is not supported for this Session.", "core_metrics_unavailable": "Core metrics could not be read. Try again later.", diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 42f9c705a..194eab50a 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -108,7 +108,6 @@ export const keys = { loading: "Reading the API address", failed: "The API address couldn't be read.", localOnly: "For access from other machines, set OAC_PUBLIC_URL to an address they can reach.", - noAddress: "Core has no public API address yet. Set OAC_PUBLIC_URL.", model: "Replace {{model}} with a model name your model provider serves, or remove the model field to use this deployment's default model configuration. Running an Agent needs a model provider: pass one in each request, save one on the Agent, or rely on the deployment default.", keyPlaceholder: "", projectKey: "Set OPENAI_API_KEY to an API key issued for this project. A key is shown only once, when it is issued; if it's lost, issue a new one.", diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 88d1c8058..72b06c5b3 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -11,7 +11,6 @@ export const system = { apiBaseUrlHelp: "Applications use it as OPENAI_BASE_URL, with a Project API key as OPENAI_API_KEY.", copyApiBaseUrl: "Copy API base URL", localOnly: "Only reachable on the Core machine", - notSet: "Not set", id: "Installation ID", sourceCommit: "Source commit", unknown: "Unknown", @@ -87,7 +86,6 @@ export const system = { save: "Save", saving: "Saving…", uncertain: "Core did not confirm the change. The default model configurations were read again; check them before trying again.", - noCredentialKey: "Core has no credential encryption key configured, so it can't store keys. Installer-based installs configure this automatically; for manual deployments, set OAC_CREDENTIAL_KEY_FILE for Core.", }, clearDialog: { title: "Clear default model configuration", diff --git a/apps/web/src/i18n/locales/en/vaults.ts b/apps/web/src/i18n/locales/en/vaults.ts index 4160c743c..3155078da 100644 --- a/apps/web/src/i18n/locales/en/vaults.ts +++ b/apps/web/src/i18n/locales/en/vaults.ts @@ -30,7 +30,6 @@ export const vaults = { newToken: "New bearer token", token: "Bearer token", tokenHelp: "Write only. It is sent once, immediately cleared, and never stored in browser state, metadata, previews, or logs.", }, errors: { - storageUnavailable: "Credential encryption is not configured on this Core. Configure OAC_CREDENTIAL_KEY_FILE and restart Core before creating or replacing a token.", auth: "Core authentication failed. The Credential was not confirmed.", missing: "The Vault or Credential is no longer available. Refresh before trying again.", tooLarge: "The Credential request exceeded Core's accepted size.", invalidFields: "Core rejected the Credential fields. Check the name, exact HTTPS URL, and token format.", credentialUncertain: "The Credential write outcome was not confirmed. The catalog was refreshed; review it before explicitly trying again.", diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index 384f54c21..55bb3c34e 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -18,7 +18,6 @@ export const coreErrors = { "project_exists": "此项目名称已被使用。", "project_api_key_exists": "此名称已被使用中的 API Key 占用。", "executor_credential_exists": "此名称的执行器凭证已存在。", - "credential_storage_unavailable": "Core 凭证存储不可用,请检查凭证加密配置。", "internal_error": "Core 未能完成请求。", "sandbox_generation_stale": "Core 的沙箱配置已更新。请刷新并检查后再提交。", "sandbox_reset_required": "请先重置沙箱部署,再更改此配置。", @@ -36,7 +35,7 @@ export const coreErrors = { "sandbox_specification_mismatch": "已保存的沙箱规格与部署不一致。请刷新检查配置。", "sandbox_operation_unsupported": "所选沙箱提供商不支持此操作。", "environment_unavailable": "此 Session 的环境已不可用。", - "execution_unavailable": "此 Core 不提供执行功能。", + "execution_unavailable": "执行暂时不可用,请稍后重试。", "runtime_history_unavailable": "此 Core 上的 Runtime 历史不可用。", "runtime_history_unsupported": "此 Session 不支持 Runtime 历史。", "core_metrics_unavailable": "无法读取 Core 指标,请稍后重试。", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index f019b7619..1ba5804bd 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -110,7 +110,6 @@ export const keys: TranslationShape = { loading: "正在读取 API 地址", failed: "无法读取 API 地址。", localOnly: "从其他机器调用前,请先把 OAC_PUBLIC_URL 设为它们能访问的地址。", - noAddress: "Core 尚未配置公开 API 地址,请设置 OAC_PUBLIC_URL。", model: "把 {{model}} 换成模型服务提供的模型名;也可以删掉 model 字段,使用本部署的默认模型配置。运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", keyPlaceholder: "<项目 API key>", projectKey: "把 OPENAI_API_KEY 设为这个项目签发的 API key。key 只在签发时显示一次;丢失后请签发新 key。", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 64c1d76f6..4a204f05d 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -13,7 +13,6 @@ export const system: TranslationShape = { apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", copyApiBaseUrl: "复制 API 基础地址", localOnly: "只能在 Core 所在的机器上访问", - notSet: "未设置", id: "安装 ID", sourceCommit: "源码提交", unknown: "未知", @@ -89,7 +88,6 @@ export const system: TranslationShape = { save: "保存", saving: "正在保存…", uncertain: "Core 没有确认这次修改。已重新读取默认模型配置,请先核对再重试。", - noCredentialKey: "Core 没有配置凭据加密密钥,因此无法保存 key。用安装器安装的会自动配置;手动部署时,请为 Core 设置 OAC_CREDENTIAL_KEY_FILE。", }, clearDialog: { title: "清除默认模型配置", diff --git a/apps/web/src/i18n/locales/zh-CN/vaults.ts b/apps/web/src/i18n/locales/zh-CN/vaults.ts index 52a7ef2ef..ca25fd7c0 100644 --- a/apps/web/src/i18n/locales/zh-CN/vaults.ts +++ b/apps/web/src/i18n/locales/zh-CN/vaults.ts @@ -8,5 +8,5 @@ export const vaults: TranslationShape = { detail: { back: "返回", facts: "Vault 详情", id: "ID", created: "创建时间", metadata: "元数据", credentials: "Credential", credentialsHelp: "静态 bearer 令牌只可写入:Core 不会返回令牌,所以这里只显示元数据。替换令牌不会在 provider 侧撤销旧令牌。", name: "名称", url: "MCP 服务 URL", auth: "认证方式", updated: "更新时间", addCredential: "添加 Credential", deleteVault: "删除 Vault", staticBearer: "静态 bearer", oauth: "OAuth", tokenHidden: "令牌已隐藏" }, createForm: { name: "名称", namePlaceholder: "运行时 Credential", nameHelp: "必填。去除首尾空格后的名称最多 256 个 UTF-8 字节。", metadata: "元数据", optional: "可选", metadataHelp: "输入值为字符串的 JSON 对象。留空会转为 {{emptyObject}};编码后的元数据最大为 64 KiB。", metadataWarning: "Vault 元数据是公开的。切勿在此填写密钥、令牌、密码、凭据或私有连接信息。", creating: "创建中…", create: "创建 Vault" }, credentialDialog: { replaceTitle: "替换令牌 · {{name}}", addTitle: "添加静态 bearer Credential", saving: "保存中…", create: "创建 Credential", notConfirmed: "Credential 未确认", name: "名称", namePlaceholder: "内部 MCP", exactUrl: "精确 MCP 服务 URL", urlHelp: "此 URL 必须与 Agent MCP 定义完全一致。创建此资源不会发起网络请求。", replaceWarning: "旧令牌不会被读取或显示。正在运行的工作可能已经持有它;替换操作不会撤销 provider 侧令牌。", newToken: "新 bearer 令牌", token: "Bearer 令牌", tokenHelp: "只可写入。令牌只发送一次,随后立即清除,且绝不会保存到浏览器状态、元数据、预览或日志中。" }, - errors: { storageUnavailable: "此 Core 未配置 Credential 加密。请配置 OAC_CREDENTIAL_KEY_FILE 并重启 Core,再创建或替换令牌。", auth: "Core 认证失败,Credential 未确认。", missing: "Vault 或 Credential 已不存在。请刷新后重试。", tooLarge: "Credential 请求超过 Core 接受的大小。", invalidFields: "Core 拒绝了 Credential 字段。请检查名称、精确 HTTPS URL 和令牌格式。", credentialUncertain: "未能确认 Credential 写入结果。目录已刷新;再次明确重试前请先检查。", bearer: "请输入非空的 RFC 6750 bearer 令牌。空白或其他仅能保存的不透明值无法在当前运行时执行。", credentialName: "Credential 名称必须为 1 至 256 个 UTF-8 字节。", exactUrl: "请输入不含凭据、查询参数、片段、空白或反斜杠的精确 HTTPS MCP URL。", createUncertain: "未能确认 Vault 创建结果。草稿保持不变;再次明确重试前请检查当前 Core 状态。", vaultName: "名称必须为 1 至 256 个 UTF-8 字节。", metadataUnknown: "无法验证元数据。", metadataJson: "元数据必须是有效 JSON。", metadataShape: "元数据必须是值为字符串的 JSON 对象。", metadataStrings: "元数据值必须全部为字符串;不支持嵌套值、数组、数字、布尔值和 null。", metadataLarge: "元数据经 UTF-8 JSON 编码后最大为 64 KiB。", mismatchedMetadata: "OpenAgentCore 返回的 Vault 元数据不匹配。", uniqueAttachments: "附加的 Vault 不能重复。", catalogIncomplete: "尚未从此 Core 完整加载 Credential 元数据。", vaultMissing: "所选 Vault 已不在当前目录中。", credentialMismatch: "MCP 服务 {{server}} 引用了不可用或 URL 不匹配的 Credential。", credentialVaultMissing: "MCP 服务 {{server}} 引用的 Credential 所属 Vault 不可用。", multipleCredentials: "匿名 MCP 服务 {{server}} 在所选 Vault 中匹配到多个 Credential。请选择唯一匹配的 Vault,或明确配置一个 Credential。", matchedVaultMissing: "MCP 服务 {{server}} 匹配到的 Credential 所属 Vault 不可用。" }, + errors: { auth: "Core 认证失败,Credential 未确认。", missing: "Vault 或 Credential 已不存在。请刷新后重试。", tooLarge: "Credential 请求超过 Core 接受的大小。", invalidFields: "Core 拒绝了 Credential 字段。请检查名称、精确 HTTPS URL 和令牌格式。", credentialUncertain: "未能确认 Credential 写入结果。目录已刷新;再次明确重试前请先检查。", bearer: "请输入非空的 RFC 6750 bearer 令牌。空白或其他仅能保存的不透明值无法在当前运行时执行。", credentialName: "Credential 名称必须为 1 至 256 个 UTF-8 字节。", exactUrl: "请输入不含凭据、查询参数、片段、空白或反斜杠的精确 HTTPS MCP URL。", createUncertain: "未能确认 Vault 创建结果。草稿保持不变;再次明确重试前请检查当前 Core 状态。", vaultName: "名称必须为 1 至 256 个 UTF-8 字节。", metadataUnknown: "无法验证元数据。", metadataJson: "元数据必须是有效 JSON。", metadataShape: "元数据必须是值为字符串的 JSON 对象。", metadataStrings: "元数据值必须全部为字符串;不支持嵌套值、数组、数字、布尔值和 null。", metadataLarge: "元数据经 UTF-8 JSON 编码后最大为 64 KiB。", mismatchedMetadata: "OpenAgentCore 返回的 Vault 元数据不匹配。", uniqueAttachments: "附加的 Vault 不能重复。", catalogIncomplete: "尚未从此 Core 完整加载 Credential 元数据。", vaultMissing: "所选 Vault 已不在当前目录中。", credentialMismatch: "MCP 服务 {{server}} 引用了不可用或 URL 不匹配的 Credential。", credentialVaultMissing: "MCP 服务 {{server}} 引用的 Credential 所属 Vault 不可用。", multipleCredentials: "匿名 MCP 服务 {{server}} 在所选 Vault 中匹配到多个 Credential。请选择唯一匹配的 Vault,或明确配置一个 Credential。", matchedVaultMissing: "MCP 服务 {{server}} 匹配到的 Credential 所属 Vault 不可用。" }, }; diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 51e991350..cf6eaa7ff 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -147,13 +147,11 @@ export const chinese = { "SELinux is not enforcing": "SELinux 不是 enforcing 模式", "In sudo mode, one Core per host: a host already running a sudo-mode node for another Core is refused.": "sudo 模式下,每台主机只能接入一个 Core:已为其他 Core 运行 sudo 模式节点的主机会被拒绝。", "If the command is interrupted or the download stalls, run it again: unfinished downloads restart, and verified files are reused.": "如果命令被中断或下载卡住,重新运行即可:未完成的下载会从头开始,已校验的完整文件会复用。", - "Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)": "Docker Engine 以 rootful 模式运行,套接字属于 docker 组、权限 0660,且能限制 CPU 和内存(cgroup v2)", - "/dev/kvm in the kvm group (hardware or nested virtualization) and the libraries microsandbox links (glibc)": "属于 kvm 组的 /dev/kvm(硬件或嵌套虚拟化),以及 microsandbox 链接的库(glibc)", + "What the sandbox backend needs on the host; the installer checks it and names anything missing": "沙箱后端在主机上所需的条件;安装程序会检查并指出缺少的项", "Copy command with --force": "复制命令(--force)", "Checking this installation's public URL…": "正在检查这个安装的公网地址…", "The installation couldn't be read, so no command can be issued.": "无法读取安装信息,暂时不能生成命令。", "It never deletes sandboxes, volumes or images.": "它不会删除任何沙箱、卷或镜像。", - "It keeps microsandbox's image store and sandbox data, and prints how to remove them by hand.": "它会保留 microsandbox 的镜像存储和沙箱数据,并打印手动删除的方法。", "Old Core address gone?": "旧 Core 地址已失效?", "{{name}} still points at the old Core address {{address}}.": "{{name}} 仍指向旧的 Core 地址 {{address}}。", "If this node's old Core address no longer responds, first remove it on the Nodes page, then add --force to the uninstall command.": "如果这个节点的旧 Core 地址已无法访问,请先在“节点”页移除它,再在卸载命令后加上 --force。", @@ -162,12 +160,10 @@ export const chinese = { "CPUs and memory for at least one sandbox; about 2 GB of disk for the Runtime image": "CPU 和内存至少够一个沙箱;Runtime 镜像约需 2 GB 磁盘", "Reaches {{core}}, as do its sandboxes": "能访问 {{core}},它的沙箱也要能访问", "The command creates the oac-node service user and a system service. It installs no software; if something is missing it stops and says what to install.": "命令会创建 oac-node 服务用户和一个系统服务。它不安装任何软件;缺少什么时会停下并说明要装什么。", - "oac-node joins the docker group, which is equivalent to root on this host.": "oac-node 会加入 docker 组,这在这台主机上等同于 root 权限。", "Set a public address other machines can reach before adding nodes.": "添加节点前,请先设置其他机器能访问的公开地址。", "Clean up the host": "清理主机", "{{name}} is removed from Core. To remove its service and files from the host, run:": "{{name}} 已从 Core 移除。要删除它在主机上的服务和文件,请运行:", "{{name}} is removed from Core, but its service and files stay on the host.": "{{name}} 已从 Core 移除,但它的服务和文件仍留在主机上。", - "An uninstall command needs a public URL that other machines can reach, and this installation has none.": "卸载命令需要其他机器能访问的公开地址,而当前安装没有。", "Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.": "其他机器无法访问本安装的公开地址 {{url}},因此无法生成卸载命令。", "Uninstall command": "卸载命令", "Copy {{command}}": "复制 {{command}}", @@ -288,21 +284,17 @@ export const chinese = { "Sandbox ownership mismatch": "沙箱归属不一致", "Reconcile the assigned resource and its ownership record before resuming execution.": "请核对已分配资源及其归属记录,再恢复执行。", "Sandbox provider unavailable": "沙箱后端不可用", - "Restore the provider on the assigned node, then refresh. A connected node alone does not confirm that its sandbox provider is ready.": "请恢复已分配节点上的运行后端,然后刷新。节点在线并不代表其沙箱后端已就绪。", + "Restore the provider on the assigned node, then refresh. Running the install command again on the host checks its requirements and names the fix; a manually registered node logs the local error.": "请恢复已分配节点上的运行后端,然后刷新。在该主机上重新运行安装命令,会检查主机要求并指出修复方法;手动注册的节点会在日志中记录本地错误。", "Sandbox state needs attention": "沙箱状态需要检查", "Inspect the assigned node and resource, then refresh.": "请检查已分配节点和资源,然后刷新。", - "Docker unavailable": "Docker 不可用", - "The node can't reach the Docker daemon. Check that Docker is running and the node can use its socket.": "节点连不上 Docker 守护进程。请确认 Docker 正在运行,且节点能访问它的 socket。", - "Docker limits unsupported": "Docker 无法限制资源", - "Docker on this host doesn't enforce CPU and memory limits. Enable cgroup limits.": "这台主机上的 Docker 不能限制 CPU 和内存。请启用 cgroup 限制。", + "Host unsupported": "主机不满足要求", + "The host lacks a capability its sandbox provider requires. Running the install command again on the host checks its requirements and names the fix; a manually registered node logs the local error.": "主机缺少沙箱后端所需的能力。在该主机上重新运行安装命令,会检查主机要求并指出修复方法;手动注册的节点会在日志中记录本地错误。", + "Provider files missing": "后端文件缺失", + "Pinned provider files are missing or fail their checksum. Run the install command again.": "指定版本的后端文件缺失或校验不通过。请重新运行安装命令。", "Runtime download failed": "Runtime 下载失败", "Runtime files could not be downloaded or verified. Check the node's network access and the configured Runtime release.": "Runtime 文件下载或验证失败。请检查节点网络连接及配置的 Runtime 发布版本。", "Runtime image missing": "缺少 Runtime 镜像", "The pinned Runtime image isn't on the host. Run the install command again.": "主机上没有指定版本的 Runtime 镜像。请重新运行安装命令。", - "KVM unavailable": "KVM 不可用", - "/dev/kvm isn't available to the node. Enable virtualization or use a KVM-capable host.": "节点无法使用 /dev/kvm。请开启虚拟化,或换一台支持 KVM 的主机。", - "microsandbox components missing": "microsandbox 组件缺失", - "microsandbox components are missing or fail their checksum. Run the install command again.": "microsandbox 组件缺失或校验不通过。请重新运行安装命令。", "Host too small": "主机资源不足", "The host has less CPU or memory than one sandbox needs. Use a bigger host or a smaller sandbox size.": "主机的 CPU 或内存不够运行一个沙箱。请换一台更大的主机,或调小沙箱规格。", "Connecting to this console's Core…": "正在连接此控制台的 Core…", diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index 439cefe4b..1d61ce7e1 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -12,8 +12,8 @@ describe("sandbox localization", () => { expect(sandboxStateLabel("internal-value", "zh")).toBe("未知状态"); }); it("localizes all diagnostic labels and advice", () => { - for (const code of ["node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable", "docker_unavailable", - "docker_limits_unsupported", "runtime_download_failed", "runtime_image_unavailable", "kvm_unavailable", "microsandbox_artifacts_unavailable", "capacity_insufficient", "unknown"]) { + for (const code of ["node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable", "host_unsupported", + "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", "capacity_insufficient", "unknown"]) { const message = sandboxDiagnosticMessage(code, "zh"); expect(message?.label).toMatch(/[\u4e00-\u9fff]/); expect(message?.advice).toMatch(/[\u4e00-\u9fff]/); diff --git a/apps/web/src/lib/sandbox-diagnostic.test.ts b/apps/web/src/lib/sandbox-diagnostic.test.ts index 6c26cfb9f..4ead7d8fb 100644 --- a/apps/web/src/lib/sandbox-diagnostic.test.ts +++ b/apps/web/src/lib/sandbox-diagnostic.test.ts @@ -17,11 +17,11 @@ describe("sandbox diagnostics", () => { expect(sandboxDiagnosticMessage("provider_unavailable")?.label).toBe("Sandbox provider unavailable"); }); it("names why a node's provider is not ready, reading an unknown code as provider_unavailable", () => { - expect(sandboxDiagnosticMessage(nodeProviderDiagnostic({ online: true, provider_ready: false, diagnostic: "kvm_unavailable" }))?.label).toBe("KVM unavailable"); + expect(sandboxDiagnosticMessage(nodeProviderDiagnostic({ online: true, provider_ready: false, diagnostic: "host_unsupported" }))?.label).toBe("Host unsupported"); expect(nodeProviderDiagnostic({ online: true, provider_ready: false, diagnostic: "future_code" as SandboxNodeDiagnostic })).toBe("provider_unavailable"); expect(nodeProviderDiagnostic({ online: true, provider_ready: true, diagnostic: "" })).toBe(""); // An offline node's last code may no longer apply. - expect(nodeProviderDiagnostic({ online: false, provider_ready: false, diagnostic: "docker_unavailable" })).toBe(""); + expect(nodeProviderDiagnostic({ online: false, provider_ready: false, diagnostic: "host_unsupported" })).toBe(""); }); it("does not expose an unknown raw error or turn it into a healthy state", () => { const message = sandboxDiagnosticMessage("private-provider-error-with-secret"); diff --git a/apps/web/src/lib/sandbox-diagnostic.ts b/apps/web/src/lib/sandbox-diagnostic.ts index 20caf6db5..37d350819 100644 --- a/apps/web/src/lib/sandbox-diagnostic.ts +++ b/apps/web/src/lib/sandbox-diagnostic.ts @@ -22,16 +22,16 @@ const diagnostics: Record = { }, provider_unavailable: { label: "Sandbox provider unavailable", - advice: "Restore the provider on the assigned node, then refresh. A connected node alone does not confirm that its sandbox provider is ready.", + advice: "Restore the provider on the assigned node, then refresh. Running the install command again on the host checks its requirements and names the fix; a manually registered node logs the local error.", }, - // Fixed Runtime preparation and provider readiness diagnostics; Core sends only the code. - docker_unavailable: { - label: "Docker unavailable", - advice: "The node can't reach the Docker daemon. Check that Docker is running and the node can use its socket.", + // Provider-neutral readiness classes; Core sends only the code, and the node keeps the local detail. + host_unsupported: { + label: "Host unsupported", + advice: "The host lacks a capability its sandbox provider requires. Running the install command again on the host checks its requirements and names the fix; a manually registered node logs the local error.", }, - docker_limits_unsupported: { - label: "Docker limits unsupported", - advice: "Docker on this host doesn't enforce CPU and memory limits. Enable cgroup limits.", + artifacts_unavailable: { + label: "Provider files missing", + advice: "Pinned provider files are missing or fail their checksum. Run the install command again.", }, runtime_download_failed: { label: "Runtime download failed", @@ -41,14 +41,6 @@ const diagnostics: Record = { label: "Runtime image missing", advice: "The pinned Runtime image isn't on the host. Run the install command again.", }, - kvm_unavailable: { - label: "KVM unavailable", - advice: "/dev/kvm isn't available to the node. Enable virtualization or use a KVM-capable host.", - }, - microsandbox_artifacts_unavailable: { - label: "microsandbox components missing", - advice: "microsandbox components are missing or fail their checksum. Run the install command again.", - }, capacity_insufficient: { label: "Host too small", advice: "The host has less CPU or memory than one sandbox needs. Use a bigger host or a smaller sandbox size.", diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index ca0356b5c..16ca46a78 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -132,9 +132,9 @@ Core writes this record in the same transaction that creates the Session. Later | Field | Meaning | | --- | --- | | `object` | `core.installation` | -| `installation_id` | The installation ID from `OAC_INSTALLATION_ID_FILE` ([Compose installations](../../docs/configuration.md#compose-installations)); null when Core runs without the sandbox manager | -| `public_url` | The [`public_url`](../../docs/configuration.md#settings) setting: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset | -| `api_base_url` | `public_url` followed by `/v1`, the `OPENAI_BASE_URL` for Project API keys. Null when `public_url` is null | +| `installation_id` | The installation ID from `OAC_INSTALLATION_ID_FILE` ([Compose installations](../../docs/configuration.md#compose-installations)) | +| `public_url` | The [`public_url`](../../docs/configuration.md#settings) setting: the origin applications, nodes, sandboxes and self-hosted executors use | +| `api_base_url` | `public_url` followed by `/v1`, the `OPENAI_BASE_URL` for Project API keys | | `local_only` | True when `public_url` names a loopback host, which only the Core host reaches | | `source_commit` | The full source commit Core was built from; null for development builds | | `configuration` | The process settings Core loaded from its environment, under `settings` | diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index d731dec8a..d72cde51b 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -90,12 +90,11 @@ These codes have null `param` and no `details`. [Sandbox deployment](./sandbox-d | 409 | `sandbox_deployment_conflict` | The sandbox deployment cannot change in its current state | | 409 | `sandbox_specification_mismatch` | The saved deployment specification is no longer valid for its provider | | 409 | `runtime_node_in_use` | The node still holds allocations, snapshots, reservations or pending cleanup | -| 409 | `environment_unavailable` | The Session's environment is no longer available, such as an archive on a Core without execution | +| 409 | `environment_unavailable` | The Session's environment is no longer available, such as a hosted environment that failed to provision | | 409 | `runtime_history_unsupported` | Runtime history is not supported for the Session | | 500 | `internal_error` | Core could not complete the operation | | 503 | `runtime_node_unavailable` | No sandbox node is available or has capacity | -| 503 | `credential_storage_unavailable` | Core has no credential encryption key | -| 503 | `execution_unavailable` | Execution is not available on this Core | +| 503 | `execution_unavailable` | Execution is not available, such as while Core shuts down | | 503 | `runtime_history_unavailable` | Durable Runtime history is not configured or temporarily unavailable | | 503 | `core_metrics_unavailable` | Core metrics could not be read | | 503 | `file_transfer_unavailable` | Bounded content transfer is unavailable | diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 2f78e6de3..a764c0d86 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -427,17 +427,15 @@ definitions: address_bindings: $ref: '#/definitions/deployment.AddressBindings' api_base_url: - description: public_url followed by /v1; null when public_url is null. + description: public_url followed by /v1. type: string - x-nullable: true configuration: allOf: - $ref: '#/definitions/api.InstallationConfiguration' description: The process settings Core loaded. installation_id: - description: The ID in OAC_INSTALLATION_ID_FILE; null when Core runs without the sandbox manager. + description: The ID in OAC_INSTALLATION_ID_FILE. type: string - x-nullable: true local_only: description: True when public_url names a loopback host, reachable only from the Core host. type: boolean @@ -446,9 +444,8 @@ definitions: - core.installation type: string public_url: - description: 'OAC_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset.' + description: 'OAC_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use.' type: string - x-nullable: true source_commit: description: Full source commit Core was built from; null for development builds. type: string @@ -946,12 +943,10 @@ definitions: description: Fixed reason for the last reported unreadiness; absent while the provider is ready. Clients treat an unknown value as provider_unavailable. enum: - provider_unavailable - - docker_unavailable - - docker_limits_unsupported + - host_unsupported + - artifacts_unavailable - runtime_download_failed - runtime_image_unavailable - - kvm_unavailable - - microsandbox_artifacts_unavailable - capacity_insufficient type: string enrollment_id: @@ -1037,12 +1032,10 @@ definitions: description: Fixed reason for the last reported unreadiness; absent while the provider is ready. Clients treat an unknown value as provider_unavailable. enum: - provider_unavailable - - docker_unavailable - - docker_limits_unsupported + - host_unsupported + - artifacts_unavailable - runtime_download_failed - runtime_image_unavailable - - kvm_unavailable - - microsandbox_artifacts_unavailable - capacity_insufficient type: string enrollment_id: @@ -1106,12 +1099,10 @@ definitions: diagnostic: enum: - provider_unavailable - - docker_unavailable - - docker_limits_unsupported + - host_unsupported + - artifacts_unavailable - runtime_download_failed - runtime_image_unavailable - - kvm_unavailable - - microsandbox_artifacts_unavailable - capacity_insufficient type: string ready_generation: @@ -1272,7 +1263,7 @@ definitions: suspension: allOf: - $ref: '#/definitions/deployment.Suspension' - description: Idle suspension policy; microsandbox only, otherwise null. + description: Idle suspension policy; null unless the selected Provider declares checkpoint support. x-nullable: true type: object projects.APIKey: diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index f9ff767ed..46631231b 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -35,7 +35,7 @@ The installer calls these machine routes on Core: | `POST /api/v1/agent-daemon/installation` | Grant | The frozen binding: `version`, `protocol_version`, `environment_id`, `remote_url`, `workspace_directory`, `harness` | | `POST /api/v1/agent-daemon/installation/claim` | Grant | `{"executor_token":"SECRET"}`; 204 | -An invalid or expired grant returns 401 `installation_authorization_invalid`. Without matching installers the grant routes return 503 `installation_unavailable`. Core signs each grant with the installation's [`secrets/core/credential.key`](../../docs/configuration.md#compose-installations); without a configured key, the Session responses and Core-key read above and the grant routes return 503 `credential_storage_unavailable`. A malformed secret returns 400. Artifact routes carry no credential, and the grant is sent only to Core, never to an artifact host. +An invalid or expired grant returns 401 `installation_authorization_invalid`. Without matching installers the grant routes return 503 `installation_unavailable`. Core signs each grant with the installation's [`secrets/core/credential.key`](../../docs/configuration.md#compose-installations). A malformed secret returns 400. Artifact routes carry no credential, and the grant is sent only to Core, never to an artifact host. ## Core-key routes diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index b6a4a7636..206b61d5f 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -49,7 +49,7 @@ Every source applies to every Environment type, because the key reaches only the An empty Session execution extension is invalid. An explicit null provider requests inheritance; an empty or partial provider object is invalid. Unknown, duplicate or output-only saved-provider fields are rejected. A saved Agent without a Harness may save a valid bundle; its Harness compatibility is checked at Session admission. A provider-only Agent update keeps the saved Harness and validates the merged combination under the row lock. The Session's inline `agent.x_agents_core` accepts `harness` and `harness_config`; the provider override belongs at the request's top level. -Core reads the Agent configuration and encrypted bundle from one database snapshot; an explicit complete Session override needs no decryption of the saved bundle. The Session's own encrypted snapshot is written atomically with the Session and its Environment. Existing Sessions never consult the Agent again: edits, key replacement, deletion, suspension and restarts cannot change their model, Harness or provider. A missing or wrong encryption key fails closed; keep the same [credential key](../../docs/configuration.md#compose-installations) across restarts. There is no Turn-level override. +Core reads the Agent configuration and encrypted bundle from one database snapshot; an explicit complete Session override needs no decryption of the saved bundle. The Session's own encrypted snapshot is written atomically with the Session and its Environment. Existing Sessions never consult the Agent again: edits, key replacement, deletion, suspension and restarts cannot change their model, Harness or provider. A wrong encryption key fails closed; keep the same [credential key](../../docs/configuration.md#compose-installations) across restarts. There is no Turn-level override. Every creation request records caller intent before resolving saved Agents, templates, credentials or deployment defaults, so changing or removing them does not change its retry identity. A matching creation retry recovers the committed Session before resolving the Agent or provider again and enqueues no further input. Streaming is outside the retry identity. The [TypeScript client](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md#saved-agent-and-deployment-defaults) shows saved Agents and deployment defaults. @@ -125,7 +125,7 @@ The deployment default is a runtime setting stored in Core: one complete model c | `PUT /core/v1/harnesses/{harness}/model-configuration` | Replace `{model_provider, model, harness_config}` using the shared provider and native-parameter validators | | `DELETE /core/v1/harnesses/{harness}/model-configuration` | Remove it; idempotent, 204 | -PUT requires `model` and a complete `model_provider`; `harness_config` defaults to `{}`. Reads return `model`, `harness_config`, the safe `model_provider` view (`protocol`, `base_url`, optional token limits and `api_key_configured`), observations and `updated_at`, never the key. The bundle is encrypted with its own purpose and bound to the Harness. Each write records an administrator audit entry (`resource_type: deployment_model_provider`, the Harness as `resource_id`, action `set` or `delete`, `project_id` null) without the key. A missing or wrong encryption key fails closed: writes, and Session creation that needs the default, return 503 `credential_storage_unavailable`. +PUT requires `model` and a complete `model_provider`; `harness_config` defaults to `{}`. Reads return `model`, `harness_config`, the safe `model_provider` view (`protocol`, `base_url`, optional token limits and `api_key_configured`), observations and `updated_at`, never the key. The bundle is encrypted with its own purpose and bound to the Harness. Each write records an administrator audit entry (`resource_type: deployment_model_provider`, the Harness as `resource_id`, action `set` or `delete`, `project_id` null) without the key. A default sealed under another encryption key fails closed: reading it, and Session creation that needs it, return 500 `internal_error` until the default is set again. Session creation decrypts the default of the resolved Harness and freezes it in the Session's encrypted snapshot like any other bundle, so changing or removing the default never reaches existing Sessions. The execution-configuration read shows the frozen safe view with source `deployment`. A missing or invalid provider snapshot fails closed, with no fallback to another model or provider. diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index e9e390731..b5bfe951a 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -17,7 +17,7 @@ Every frame is one JSON text message whose `version` equals `node.ProtocolVersio Core counts a node as online while it is connected under the current owner epoch and its last heartbeat is less than 45 seconds old. A heartbeat establishes provider readiness and the last host measurements, never Session activity. -Health carries `provider_ready`, an optional fixed `diagnostic`, `observed_at`, `active_operations` (at most 32) and the host measurements that the [Runtime telemetry API](./runtime-observability-api.md#node-host-observations-and-history) reports. A node without generation management probes its provider for every report; an unready provider reports one fixed diagnostic code, classified from typed probe errors, and the probe text and host paths stay on the node. Core stores an unknown code as `provider_unavailable`. The [nodes guide](../../docs/getting-started/nodes.md#readiness-codes) lists the codes and their causes. A generation-managing node reports readiness per generation instead, as described below. +Health carries `provider_ready`, an optional fixed `diagnostic`, `observed_at`, `active_operations` (at most 32) and the host measurements that the [Runtime telemetry API](./runtime-observability-api.md#node-host-observations-and-history) reports. A node without generation management probes its provider for every report; an unready provider reports the Provider-neutral readiness class of its first failed check, from the class error the probe wraps, and the probe text, vendor detail and host paths stay on the node. A `diagnostic` in node or generation health that is not a declared class makes the frame invalid. The [nodes guide](../../docs/getting-started/nodes.md#readiness-codes) lists the classes and their causes. A generation-managing node reports readiness per generation instead, as described below. ## Provider requests @@ -121,6 +121,6 @@ A fresh installation also records the verified checksums of its Runtime files se An interrupted download repairs only missing bytes at the original paths. When collection comes before any import attempt, the preparation journal proves that the generation has no imported native image. A generation whose native executable is missing and whose import may have started stays retained: missing files never prove native absence, and an empty native inventory never erases receipt or store history. -The diagnostic codes are authored in `services/core/internal/sandbox/node_diagnostic.go`. The shared `services/core/internal/sandbox/testdata/node-diagnostics.json` fixture checks the Go mapping, OpenAPI source annotations and generated enums, and the TypeScript client declaration. Web uses the client normalizer and checks localized messages for every declared code. Update these projections with a code change; unknown codes normalize to `provider_unavailable`. +The readiness classes, one exported error and one code each, are authored in `services/core/internal/sandbox/node_diagnostic.go`. The shared `services/core/internal/sandbox/testdata/node-diagnostics.json` fixture checks the Go mapping, OpenAPI source annotations and generated enums, and the TypeScript client declaration. Web uses the client normalizer and checks localized messages for every declared code. Update these projections with a code change; clients read an unknown code as `provider_unavailable`. Preparation diagnostics keep fixed typed causes. Only artifact transfer, checksum or release-provenance failures report `runtime_download_failed`; the private preparer signals that class through its exit category, without Core or the node parsing stderr. Provider, ownership, cancellation and unclassified failures keep their typed code or `provider_unavailable`. No raw provider text crosses the protocol. diff --git a/contracts/agents-api/runtime-observability.md b/contracts/agents-api/runtime-observability.md index a30353900..c91351dac 100644 --- a/contracts/agents-api/runtime-observability.md +++ b/contracts/agents-api/runtime-observability.md @@ -83,7 +83,7 @@ CPU quietness, heartbeat age, connection state and keepalive time are not idle t ### Periodic sampling -Periodic collection runs only with the execution worker (Core started with `OAC_PUBLIC_URL`; see the [Core environment](../../docs/configuration.md#appendix-core-environment-without-the-installer)) and under the worker's database lease. A Core without it stores no history and answers every history read with 503; current reads work on either. +Periodic collection runs in the execution Worker, under its database lease. The sampler sweeps once at startup and again each sampling interval after the previous sweep ends. A sweep is a keyset scan, in Session ID order, of the Sessions that are not deleted, are `openai_hosted` and have no released allocation. It reads pages of 32 Sessions through the same resolver and sources as current reads, with eight concurrent reads and two seconds per source. The sampler checks the lease before each page and every 100 ms during a sweep, cancels in-flight reads when ownership is lost, and checks it again before handing each record to export. A failed row does not stop the sweep, and an incomplete sweep is repeated at the next interval. diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index eea486ebc..fc96faa95 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -86,7 +86,7 @@ For E2B, post `{"configuration": {"api_url": "…", "domain": "…"}, "credentia GET and successful writes return `installation_id`, `provider`, `core_url` (read-only: the installation public URL, present even before configuration), `mode`, `generation`, `owner_epoch`, `reset`, `rollout`, `suspension`, `resources` and `credential_configured`. A configured deployment also returns `specification`, `specification_digest`, `configuration` and `metadata`: the adapter's public projection of its selectors and of the observations it recorded, never raw stored values or secrets. E2B returns `configuration.template`, `configuration.api_url`, `configuration.domain` and, once recorded, `metadata.template_build`. Docker and microsandbox return empty `configuration` and `metadata` objects and `credential_configured: false`; an unconfigured deployment has neither object. - `metadata.template_build` is `{status, resources: {cpus, memory_mib, root_disk_mib}}`: the build as Core read it through the pinned SDK when the selection was saved. GET never calls E2B, so it stays cheap during an E2B outage. Validation admits only a `ready` build whose CPU count and memory equal the selected values; `root_disk_mib` is the build's native disk size, which Core does not enforce. Unknown values are null, `metadata: {}` means no observation was recorded, and an identical PUT without a credential does not refresh it. -- `suspension` is `{idle_seconds, retention_seconds}` for microsandbox, the only provider Core suspends (currently 300 and 86400); Docker, E2B and unconfigured deployments return null. +- `suspension` is `{idle_seconds, retention_seconds}`, Core's [suspension policy](../../docs/sandbox-provider.md#suspension), when the selected Provider declares checkpoint support; any other deployment, including an unconfigured one, returns null. - Request `resources` and response `specification.resources` are per-sandbox limits. Response `resources.allocations` and `resources.pending` count unreleased allocations and pending hosted Environments without an allocation. - An unconfigured deployment has an empty provider and no specification. Docker and microsandbox use `mode: nodes`; E2B uses `mode: direct`, without a synthetic node. - `generation` identifies the saved selection. `owner_epoch` fences the execution owner and node connections; it does not replace `expected_generation`. @@ -177,7 +177,7 @@ Node capacity is approved by the administrator, separately from the deployment s `GET /core/v1/sandbox/nodes` returns `{data: [...]}` with, for each node: `id`, `name`, `provider`, `online`, `last_seen_at`, `created_at`, `max_active`, `max_retained`, the counts `active`, `reserved`, `running`, `retained`, `snapshots` and `cleanup_pending`, `provider_ready`, `diagnostic`, the host measurements `cpu_count`, `available_memory_bytes` and `available_disk_bytes`, `rollout`, `enrollment_id` and `core_url`. `enrollment_id` is the handle of the command that registered the node, or null when Core has none. `core_url` is the installation public URL at enrollment; a node whose `core_url` differs from the current public URL receives no new placements. Work already placed on it finishes there, including a placed Environment that has no allocation yet, and its retained sandboxes can still resume while the old address reaches Core. Remove it and add it again. -A node without generation management reports its provider's readiness itself: `provider_ready`, and when it is unready one fixed `diagnostic` code. A node added with Web's command manages generations, so its readiness follows its serving generation and its fixed code for the target generation appears in `rollout.diagnostic`. The node classifies the first failed readiness check and sends only the code; Core stores any other value as `provider_unavailable` and never stores or returns probe text or host paths. The codes are `docker_unavailable`, `docker_limits_unsupported`, `runtime_download_failed`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient` and `provider_unavailable`. `runtime_download_failed` means the exact Runtime artifacts could not be transferred or verified; it never contains artifact URLs, credentials or transport output. [Readiness codes](../../docs/getting-started/nodes.md#readiness-codes) gives causes and operator actions. Core and nodes must come from the same distribution. +A node without generation management reports its provider's readiness itself: `provider_ready`, and when it is unready one fixed `diagnostic` code. A node added with Web's command manages generations, so its readiness follows its serving generation and its fixed code for the target generation appears in `rollout.diagnostic`. The node classifies the first failed readiness check and sends only its code; a frame with any other value is invalid, and Core never stores or returns probe text or host paths. The codes are Provider-neutral classes: `provider_unavailable`, `host_unsupported`, `artifacts_unavailable`, `runtime_download_failed`, `runtime_image_unavailable` and `capacity_insufficient`. `runtime_download_failed` means the exact Runtime artifacts could not be transferred or verified; it never contains artifact URLs, credentials or transport output. [Readiness codes](../../docs/getting-started/nodes.md#readiness-codes) gives causes and operator actions. Core and nodes must come from the same distribution. `PATCH /core/v1/sandbox/nodes/{node_id}` takes `{name, max_active, max_retained}`; lowering a limit stops no running sandbox. `DELETE /core/v1/sandbox/nodes/{node_id}` refuses with 409 `runtime_node_in_use` while the node holds allocations, snapshots, reservations or pending cleanup, including while it is offline. Removal deletes no compute and retires the node's identity; the host can come back only as a new node. There is no node drain. @@ -197,7 +197,6 @@ Some fields keep one name across providers but differ in meaning, or do not appl | Enrollment-token `max_active`, `max_retained` | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | `max_retained` always equals `max_active` | Both limits apply | | Node list and detail | Empty list; detail returns 404 | Enrolled nodes | Enrolled nodes | | Node `retained`, `snapshots`, `max_retained` | Not applicable | Docker never suspends: `retained` equals `active`, `snapshots` is 0 and `max_retained` equals `max_active` | Suspended sandboxes are `retained` minus `active` | -| Node `diagnostic` codes | Not applicable | `docker_unavailable`, `docker_limits_unsupported`, `runtime_download_failed`, `runtime_image_unavailable`, `capacity_insufficient` or `provider_unavailable` | `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `runtime_download_failed`, `capacity_insufficient` or `provider_unavailable` | | Node `host.available_disk_bytes` | Not applicable | Free space on the filesystem of the node state directory, not a container's disk | Free space on the filesystem of the node state directory; sandbox disks have their own quotas | | Allocation `compute_phase`, `compute_phase_changed_at` | Not applicable: no node allocations | Always `disabled`, counted as running until release; the time is the allocation's creation | Includes `suspended`; its time plus `suspension.retention_seconds` tells roughly when Core reclaims the snapshot | | Runtime observation `cpu`, `memory` | From E2B metrics: `cpu.utilization_ratio` and `capacity_cores`, memory usage and limit; no cumulative CPU time | From Docker stats: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | From the VM: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | @@ -218,12 +217,11 @@ Some fields keep one name across providers but differ in meaning, or do not appl | 409 | `sandbox_deployment_conflict` | Another state the change cannot apply to | | 409 | `runtime_node_in_use` | Node removal while it holds resources | | 503 | `execution_unavailable` | Provider preparation is unavailable | -| 503 | `credential_storage_unavailable` | Core has no credential encryption key | Storage and credential failures stay errors: an empty or failed read never proves cleanup. The [machine connection API](./machine-api.md#node-route-errors) lists the errors of the node routes. ## Canonical node specification -`sandbox/deployment_contract.go` owns the resource bounds, provider requirements, release patterns and canonical field order; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds and patterns in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or patterns. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. +`sandbox/deployment_contract.go` owns the resource bounds, release patterns and canonical field order, and each registered Provider's `sandbox.DeploymentPolicy` declares its requirements; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds, patterns and Provider declarations in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits, patterns or providers. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. The specification digest is the SHA-256 of compact UTF-8 JSON with `provider` first, then `resources`, then `runtime` when the provider requires it. Resource and Runtime fields follow the contract's declaration order; zero optional disk fields are omitted and required fields stay present. Release identities are lowercase ASCII, and the digest never depends on the incoming field order or whitespace. `services/core/internal/sandbox/testdata/deployment-contract.json` holds shared acceptance cases, exact canonical bytes and digests that both the Go and Python tests consume. diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 7615b7384..de744cf36 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -39,7 +39,7 @@ A Session stays usable after a Turn fails: new input starts a new Turn. Later re - **Cancellation.** A queued Turn is cancelled without a live Runtime. A running Turn is cancelled when the Runtime confirms it; completion can win that race. The Turn has stopped when it reads `cancelled`, not when the request returns. A cancellation on an idle Session with no pending input is accepted and has no effect; while an input reservation is pending, it returns 409. - **Function results.** `turn_id`, `call_id` and `success` are required; `output` and `error` are optional and nullable ([content rules](./message-content.md#function-results)). An identical repeated result returns 202 without another application or event. The result Item appears when the harness applies the result; a result that cancellation prevents from being applied stays stored but produces no Item. - **Queueing.** A queued Turn starts when a Runtime that supports the Session's harness and configuration is connected and one of Core's [`core.execution_concurrency`](../../docs/configuration.md#settings) work slots is free. A Session stays bound to the Runtime that first ran it. -- **Execution availability.** A service without execution returns 503 `execution_unavailable`, and a Worker that loses execution ownership returns 503. +- **Execution availability.** While Core shuts down, or after its Worker loses execution ownership, requests return 503 `execution_unavailable`. ### Sessions with an Environment diff --git a/contracts/agents-api/vaults.md b/contracts/agents-api/vaults.md index a05e76511..aaa3a9bac 100644 --- a/contracts/agents-api/vaults.md +++ b/contracts/agents-api/vaults.md @@ -139,10 +139,10 @@ Token endpoints must be HTTPS. Core resolves the host, rejects loopback, private Core seals every token, refresh token and client secret with AES-256-GCM under the installation's [`secrets/core/credential.key`](../../docs/configuration.md#compose-installations), bound to the Project, Vault, Credential, auth type and `mcp_server_url`. A wrong key, a modified row or a row moved to another binding fails to decrypt. Names are metadata outside the binding. The key and plaintext tokens exist in trusted service memory; encryption protects stored secrets and does not protect against a compromised service host. -Without a configured key, Credential creation and replacement return 503 `credential_storage_unavailable` before writing; reads, lists, deletion and Vault operations still work. An unreadable or malformed key file stops Core at startup. Losing or replacing the key makes every stored secret unusable; Core supports one key, with no rotation or re-encryption. +An unreadable or malformed key file stops Core at startup. Losing or replacing the key makes every stored secret unusable. Reads, lists, deletion, Vault operations, new Credentials and `static_bearer` token replacements still work; an `mcp_oauth` update returns 500 `internal_error`, and a dispatch that needs an old secret fails. A saved E2B key is lost too, so hosted Sessions return 503 `execution_unavailable` until you open **System** → **Manage sandbox configuration**, choose **Reset deployment** (with **Force — cancel remaining work now** if needed) and set up the backend again; sandboxes left behind expire under their E2B timeout. Core supports one key, with no rotation or re-encryption. ## Deletion -Deleting a Credential, or its Vault, removes the credential rows. It does not erase secrets from PostgreSQL pages, write-ahead logs, backups or native history. Afterwards its reads, updates and repeated deletion return 404, lists omit it, new Sessions cannot select it and new Credentials cannot be created in a deleted Vault (a missing storage key still answers 503 first). +Deleting a Credential, or its Vault, removes the credential rows. It does not erase secrets from PostgreSQL pages, write-ahead logs, backups or native history. Afterwards its reads, updates and repeated deletion return 404, lists omit it, new Sessions cannot select it and new Credentials cannot be created in a deleted Vault. Existing Sessions keep their frozen attachments and selections, and their history stays readable. The next dispatch that needs a deleted Credential fails; Core neither selects another Credential nor connects anonymously. Deletion does not cancel running work, withdraw a token already sent to a Runtime or revoke the grant at its provider: cancel the Session and revoke the grant yourself when needed. Revoked or invalid OAuth grants likewise fail until replaced. diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index 8ce891d43..886d98255 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 7eb295db6402db8dae91fcdd97f90a5900f740925caaee9d0172b9886544f6ec +source_hash: 8a781b20f0a359de77594ca570c4e1723332ac16a60d9a9be405ca5e2422e87d --- Core 管理 API(`/core/v1`)用于管理安装实例:Project 及其 API 密钥、Project 资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -134,9 +134,9 @@ Core 会在创建 Session 的同一事务中写入此记录。之后的 Agent | 字段 | 含义 | | --- | --- | | `object` | `core.installation` | -| `installation_id` | `OAC_INSTALLATION_ID_FILE` 中的安装 ID([Compose 安装](../../../docs/zh/configuration.md#compose-installations));Core 在不使用沙箱管理器运行时为 null | -| `public_url` | `public_url` 设置([设置](../../../docs/zh/configuration.md#settings)):应用程序、节点、沙箱和自托管执行器使用的源地址。未设置时为 null | -| `api_base_url` | 在 `public_url` 后附加 `/v1`,即 Project API 密钥使用的 `OPENAI_BASE_URL`。当 `public_url` 为 null 时为 null | +| `installation_id` | `OAC_INSTALLATION_ID_FILE` 中的安装 ID([Compose 安装](../../../docs/zh/configuration.md#compose-installations)) | +| `public_url` | `public_url` 设置([设置](../../../docs/zh/configuration.md#settings)):应用程序、节点、沙箱和自托管执行器使用的源地址 | +| `api_base_url` | 在 `public_url` 后附加 `/v1`,即 Project API 密钥使用的 `OPENAI_BASE_URL` | | `local_only` | 当 `public_url` 指向回环主机时为 True,该主机只能由 Core 主机访问 | | `source_commit` | Core 构建所依据的完整源代码提交;开发构建为 null | | `configuration` | Core 从环境加载的进程设置,位于 `settings` 中 | diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index 851754d2f..842ec7a32 100644 --- a/contracts/agents-api/zh/core-errors.md +++ b/contracts/agents-api/zh/core-errors.md @@ -1,7 +1,7 @@ --- title: "Core 管理错误" source: contracts/agents-api/core-errors.md -source_hash: 56fd21c7a7be2ddbc7a3c4163473d03fd6d72de05ba79f57d0389ae768f5d534 +source_hash: 9e089104160f06a17495c560f8a40c794046abd925de4f0aa487b58d91e481eb --- `/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。 @@ -92,12 +92,11 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 | 409 | `sandbox_deployment_conflict` | 沙箱部署在当前状态下无法更改 | | 409 | `sandbox_specification_mismatch` | 已保存的部署规格对其提供商不再有效 | | 409 | `runtime_node_in_use` | 节点仍有资源分配、快照、预留资源或待清理项 | -| 409 | `environment_unavailable` | Session 的环境已不可用,例如在不提供执行的 Core 上归档 | +| 409 | `environment_unavailable` | Session 的环境已不可用,例如托管环境创建失败 | | 409 | `runtime_history_unsupported` | 该 Session 不支持 Runtime 历史 | | 500 | `internal_error` | Core 未能完成操作 | | 503 | `runtime_node_unavailable` | 没有可用或有剩余容量的沙箱节点 | -| 503 | `credential_storage_unavailable` | Core 没有凭证加密密钥 | -| 503 | `execution_unavailable` | 此 Core 不提供执行功能 | +| 503 | `execution_unavailable` | 执行不可用,例如 Core 正在关闭 | | 503 | `runtime_history_unavailable` | 持久 Runtime 历史未配置或暂时不可用 | | 503 | `core_metrics_unavailable` | 无法读取 Core 指标 | | 503 | `file_transfer_unavailable` | 有界内容传输不可用 | diff --git a/contracts/agents-api/zh/environment-executor-credentials.md b/contracts/agents-api/zh/environment-executor-credentials.md index 06c5207cf..1516c7468 100644 --- a/contracts/agents-api/zh/environment-executor-credentials.md +++ b/contracts/agents-api/zh/environment-executor-credentials.md @@ -1,7 +1,7 @@ --- title: "Environment 执行器凭证" source: contracts/agents-api/environment-executor-credentials.md -source_hash: 64d83e57ce2dbf0913658220a039a630e9efcadeed9c5490b58c122c4f160728 +source_hash: 8f87d545cb1b9b944b29e0c2a2c1b5bcd160280771b3ec39a196da96dad93766 --- 执行器凭证允许 `oac-daemon` 为一个 `self_hosted` Environment 注册并连接。它只授权该 Environment 的私有 daemon 传输(`/api/v1/agent-daemon/*`),不授权 `/v1`、`/core/v1`、sandbox node 注册或 Project 资源。Project 的 principal 是其执行 principal。Core 只保存密钥摘要。 @@ -37,7 +37,7 @@ grant 绑定 Environment、Session 创建者的 principal 和 Core 构建版本 | `POST /api/v1/agent-daemon/installation` | Grant | 固定绑定:`version`、`protocol_version`、`environment_id`、`remote_url`、`workspace_directory`、`harness` | | `POST /api/v1/agent-daemon/installation/claim` | Grant | `{"executor_token":"SECRET"}`;204 | -无效或过期的 grant 返回 401 `installation_authorization_invalid`。没有匹配安装器时,grant 路由返回 503 `installation_unavailable`。Core 用安装的 [`secrets/core/credential.key`](../../../docs/zh/configuration.md#compose-installations) 签名每个 grant;未配置 key 时,上述 Session 响应、Core-key 查询和 grant 路由返回 503 `credential_storage_unavailable`。格式错误的密钥返回 400。产物路由不携带凭证,grant 只发送给 Core,不发送给产物主机。 +无效或过期的 grant 返回 401 `installation_authorization_invalid`。没有匹配安装器时,grant 路由返回 503 `installation_unavailable`。Core 用安装的 [`secrets/core/credential.key`](../../../docs/zh/configuration.md#compose-installations) 签名每个 grant。格式错误的密钥返回 400。产物路由不携带凭证,grant 只发送给 Core,不发送给产物主机。 ## Core-key 路由 {#core-key-routes} diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index 01c2d0508..173fd3262 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: 2f4cbc16107fe0aeb26f911c4e1275803e992de90d26b9a4295d39472b2c9d3f +source_hash: 4a59ada66fa31f92d3775175281316854b00255a68a4c25ac1959f7e05863825 --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 定义 Core 和 Runtime 共同应用的[提供商规则](#session-override),并声明[凭据网关](#credential-gateway)转发的内容,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -51,7 +51,7 @@ MiniMax Code 要求上下文限制和输出限制均为正数。Core 会在写 空的 Session 执行扩展无效。显式 null 提供商会请求继承;空的或不完整的提供商对象无效。已保存提供商配置中的未知字段、重复字段或只读输出字段均会被拒绝。没有 Harness 的已保存 Agent 可以保存有效配置包;其 Harness 兼容性会在 Session 准入时检查。仅更新提供商的 Agent 更新会保留已保存的 Harness,并在行锁保护下验证合并后的组合。Session 内联的 `agent.x_agents_core` 接受 `harness` 和 `harness_config`;提供商覆盖值必须放在请求顶层。 -Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式提供完整 Session 覆盖值时,无需解密已保存的配置包。Session 自身的加密快照会与 Session 及其 Environment 原子写入。现有 Session 绝不会再次查询 Agent:Agent 编辑、密钥替换、删除、暂停和重启均无法改变其模型、Harness 或提供商。加密密钥缺失或错误时会安全失败;重启前后应保持相同的 [credential key](../../../docs/zh/configuration.md#compose-installations)。不存在 Turn 级覆盖。 +Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式提供完整 Session 覆盖值时,无需解密已保存的配置包。Session 自身的加密快照会与 Session 及其 Environment 原子写入。现有 Session 绝不会再次查询 Agent:Agent 编辑、密钥替换、删除、暂停和重启均无法改变其模型、Harness 或提供商。加密密钥错误时会安全失败;重启前后应保持相同的 [credential key](../../../docs/zh/configuration.md#compose-installations)。不存在 Turn 级覆盖。 每个创建请求都会在解析已保存 Agent、模板、凭据或部署默认值之前记录调用方意图,因此更改或删除它们不会改变其重试标识。匹配的创建重试会在再次解析 Agent 或提供商之前恢复已提交的 Session,并且不会进一步加入输入。流式传输不参与重试标识的计算。[TypeScript client](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md#saved-agent-and-deployment-defaults) 展示了已保存 Agent 和部署默认值。 @@ -127,7 +127,7 @@ Core 会在写入 Session 前验证解析后的配置,并将其冻结在 Sessi | `PUT /core/v1/harnesses/{harness}/model-configuration` | 使用共享的提供商和原生参数校验器替换 `{model_provider, model, harness_config}` | | `DELETE /core/v1/harnesses/{harness}/model-configuration` | 移除配置;幂等,返回 204 | -PUT 要求提供 `model` 和完整的 `model_provider`;`harness_config` 默认为 `{}`。读取操作返回 `model`、`harness_config`、安全的 `model_provider` 视图(`protocol`、`base_url`、可选的 token 限制和 `api_key_configured`)、observations 和 `updated_at`,绝不返回密钥。该配置包使用自己的加密用途加密并绑定到 Harness。每次写入都会记录一条管理员审计条目(`resource_type: deployment_model_provider`,Harness 作为 `resource_id`,操作为 `set` 或 `delete`,`project_id` 为 null),且不包含密钥。加密密钥缺失或错误时会安全失败:写操作以及需要使用默认值的 Session 创建都会返回 503 `credential_storage_unavailable`。 +PUT 要求提供 `model` 和完整的 `model_provider`;`harness_config` 默认为 `{}`。读取操作返回 `model`、`harness_config`、安全的 `model_provider` 视图(`protocol`、`base_url`、可选的 token 限制和 `api_key_configured`)、observations 和 `updated_at`,绝不返回密钥。该配置包使用自己的加密用途加密并绑定到 Harness。每次写入都会记录一条管理员审计条目(`resource_type: deployment_model_provider`,Harness 作为 `resource_id`,操作为 `set` 或 `delete`,`project_id` 为 null),且不包含密钥。在其他加密密钥下密封的默认值会安全失败:读取该默认值以及需要它的 Session 创建都会返回 500 `internal_error`,直到重新设置默认值。 创建 Session 时,Core 会解密解析后 Harness 的默认值,并像处理其他配置包一样将其冻结在 Session 的加密快照中,因此更改或移除默认值绝不会影响现有 Session。execution-configuration 读取结果会显示冻结的安全视图,其来源为 `deployment`。提供商快照缺失或无效时会安全失败,并且不会回退到其他模型或提供商。 diff --git a/contracts/agents-api/zh/node-generation-protocol.md b/contracts/agents-api/zh/node-generation-protocol.md index 6aeb0802e..fb74ac11c 100644 --- a/contracts/agents-api/zh/node-generation-protocol.md +++ b/contracts/agents-api/zh/node-generation-protocol.md @@ -1,7 +1,7 @@ --- title: "沙箱节点协议" source: contracts/agents-api/node-generation-protocol.md -source_hash: 1562f69c8c7a5937a10b98c8b7dea2518bfbf1f875c2bd67ff0461467ed72cb9 +source_hash: e349ba887f9788e8f39990d33638182afcda553593e934788746fa0423cb4ee7 --- 沙箱节点在其主机上运行 Docker 或 microsandbox Provider,并通过一个 WebSocket 与 Core 相连。Core 通过该连接发送 Provider 操作;节点针对本地 Provider 执行这些操作,并报告就绪状态、主机测量值及其持有的部署代次。Core 始终是唯一的生命周期所有者:节点绝不重试变更操作或调度工作。帧和校验器位于 [`services/core/internal/sandbox/node`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/node)(`wire.go`、`generation_wire.go`);节点用于注册和读取配置的 HTTP 路由位于[机器连接 API](machine-api.md#node-routes)。 @@ -19,7 +19,7 @@ source_hash: 1562f69c8c7a5937a10b98c8b7dea2518bfbf1f875c2bd67ff0461467ed72cb9 只要节点在当前所有者 epoch 下保持连接,并且最近一次心跳距今不足 45 秒,Core 就会将该节点计为在线。心跳会确立 Provider 的就绪状态和最近的主机测量值,但绝不表示 Session 活动。 -健康报告包含 `provider_ready`、可选的固定 `diagnostic`、`observed_at`、最多为 32 的 `active_operations`,以及 [Runtime 遥测 API](runtime-observability-api.md#node-host-observations-and-history) 报告的主机测量值。未启用代次管理的节点每次报告时都会探测其 Provider;未就绪的 Provider 会报告一个固定诊断代码,该代码根据类型化探测错误进行分类;探测文本和主机路径保留在节点上。Core 会将未知代码存储为 `provider_unavailable`。[节点指南](../../../docs/zh/getting-started/nodes.md#readiness-codes) 列出了这些代码及其原因。支持代次管理的节点则按下文所述按代次报告就绪状态。 +健康报告包含 `provider_ready`、可选的固定 `diagnostic`、`observed_at`、最多为 32 的 `active_operations`,以及 [Runtime 遥测 API](runtime-observability-api.md#node-host-observations-and-history) 报告的主机测量值。未启用代次管理的节点每次报告时都会探测其 Provider;未就绪的 Provider 会报告其首个失败检查所属的、与 Provider 无关的就绪类别,该类别取自探测所包装的类别错误;探测文本、厂商细节和主机路径保留在节点上。节点或代次健康状态中的 `diagnostic` 若不是已声明的类别,该帧即无效。[节点指南](../../../docs/zh/getting-started/nodes.md#readiness-codes) 列出了这些类别及其原因。支持代次管理的节点则按下文所述按代次报告就绪状态。 ## Provider 请求 {#provider-requests} @@ -123,6 +123,6 @@ Runtime 字节缺失时,绝不将固定的放置实例迁移到当前 Runtime 下载中断后,只会修复原始路径中缺失的字节。如果在任何导入尝试之前执行回收,准备日志会证明该代次没有已导入的原生镜像。如果某代次的原生可执行文件缺失,且导入可能已经开始,该代次仍会保留:文件缺失永远不能证明原生制品不存在,而空的原生清单也永远不能抹除回执或存储历史。 -诊断代码编写于 `services/core/internal/sandbox/node_diagnostic.go`。共享的 `services/core/internal/sandbox/testdata/node-diagnostics.json` 测试夹具检查 Go 映射、OpenAPI 源注释和生成的枚举,以及 TypeScript 客户端声明。Web 使用客户端规范化器,并检查每个已声明代码的本地化消息。代码变更时要同步更新这些投影;未知代码会规范化为 `provider_unavailable`。 +就绪类别编写于 `services/core/internal/sandbox/node_diagnostic.go`,每个类别对应一个导出错误和一个代码。共享的 `services/core/internal/sandbox/testdata/node-diagnostics.json` 测试夹具检查 Go 映射、OpenAPI 源注释和生成的枚举,以及 TypeScript 客户端声明。Web 使用客户端规范化器,并检查每个已声明代码的本地化消息。代码变更时要同步更新这些投影;客户端将未知代码读作 `provider_unavailable`。 准备诊断使用固定的类型化原因。只有制品传输、校验和或版本来源验证失败才会报告 `runtime_download_failed`;私有准备器通过退出类别指示这一类失败,Core 和节点都不解析 stderr。Provider 故障、所有权故障、取消和未分类故障保留其类型化代码,或使用 `provider_unavailable`。协议中不会传输任何 Provider 原始文本。 diff --git a/contracts/agents-api/zh/runtime-observability.md b/contracts/agents-api/zh/runtime-observability.md index b4ef0f415..223c463c9 100644 --- a/contracts/agents-api/zh/runtime-observability.md +++ b/contracts/agents-api/zh/runtime-observability.md @@ -1,7 +1,7 @@ --- title: "运行时可观测性" source: contracts/agents-api/runtime-observability.md -source_hash: d18a476b06248dedfa5630ccf0f8a29dff59677a1e161d1847cc7d05e0c48de8 +source_hash: 31fa597224d654f347c7f438bea78d8e548e7588849d4346c599aac8a8f8054c --- 这是面向贡献者的契约,规定 Core 如何观测 Runtime 并保留其历史。路由和响应字段见 [Runtime telemetry API](runtime-observability-api.md)。代码位于 `services/core/internal/runtimeobs`(解析、源、采样器和导出)、`internal/runtimehistory`(历史查询和 PostgreSQL 存储)以及 `internal/runtimeobs/otlpexporter`。 @@ -85,7 +85,7 @@ CPU 静默状态、心跳时龄、连接状态和保活时间都不是空闲时 ### 周期采样 {#periodic-sampling} -周期采集仅随执行工作器运行而执行(Core 需使用 `OAC_PUBLIC_URL` 启动;见 [Core environment](../../../docs/zh/configuration.md#appendix-core-environment-without-the-installer)),并受该工作器的数据库租约保护。未运行该工作器的 Core 不存储历史记录,所有历史读取都返回 503;当前读取在两种情况下均可正常工作。 +周期采集在执行工作器中运行,并受其数据库租约保护。 采样器在启动时扫描一次,此后每次扫描结束后再经过一个采样间隔再次扫描。一次扫描按 Session ID 顺序,对未删除、状态为 `openai_hosted` 且没有已释放分配的 Session 执行 keyset 扫描。它通过与当前读取相同的解析器和源,以 32 个 Session 为一页进行读取,并发数为 8,每个源时限为 2 秒。采样器在每页之前以及扫描期间每 100 ms 检查租约;失去所有权时取消进行中的读取;将每条记录交给导出之前再次检查租约。失败的行不会停止扫描;未完成的扫描会在下一个间隔重复。 diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 2ab6371d9..bbb49a831 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: b1285a55dc2dc836f5a8b97f1a7c9283181dcd35d0344f03f9c1e79dccad095c +source_hash: 7900b9c51c42e8bd55c9b411f58b572bf2333e9d84177c313df612712737133f --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness;部署可以保持未配置状态,既无节点,也不接受托管准入。 @@ -88,7 +88,7 @@ E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建 GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url`(只读:安装公开 URL,即使配置前也存在)、`mode`、`generation`、`owner_epoch`、`reset`、`rollout`、`suspension`、`resources` 和 `credential_configured`。已配置的部署还会返回 `specification`、`specification_digest`、`configuration` 和 `metadata`:这是适配器对其选择器及其记录的观测结果所作的公开投影,绝不会包含原始存储值或机密。E2B 返回 `configuration.template`、`configuration.api_url`、`configuration.domain`,并在记录后返回 `metadata.template_build`。Docker 和 microsandbox 返回空的 `configuration` 和 `metadata` 对象以及 `credential_configured: false`;未配置的部署则不含这两个对象。 - `metadata.template_build` 为 `{status, resources: {cpus, memory_mib, root_disk_mib}}`:这是保存选择时 Core 通过固定版本 SDK 读取的构建。GET 绝不会调用 E2B,因此 E2B 中断期间该操作仍保持低成本。验证仅接受 CPU 数量和内存与所选值一致的 `ready` 构建;`root_disk_mib` 是构建的原生磁盘大小,Core 不会强制执行该值。未知值为 null,`metadata: {}` 表示未记录任何观测,在不提供凭据的情况下提交完全相同的 PUT 也不会刷新它。 -- `suspension` 对 microsandbox 而言为 `{idle_seconds, retention_seconds}`,microsandbox 是 Core 唯一会暂停的提供商(当前为 300 和 86400);Docker、E2B 和未配置的部署返回 null。 +- 所选 Provider 声明 checkpoint 支持时,`suspension` 为 `{idle_seconds, retention_seconds}`,即 Core 的 [suspension policy](../../../docs/zh/sandbox-provider.md#suspension);其他部署(包括未配置的部署)返回 null。 - 请求中的 `resources` 和响应中的 `specification.resources` 是每个沙箱的限制。响应中的 `resources.allocations` 和 `resources.pending` 分别计算尚未释放的分配,以及尚未分配沙箱的待处理托管 Environment。 - 未配置的部署具有空的 provider 且没有 specification。Docker 和 microsandbox 使用 `mode: nodes`;E2B 使用 `mode: direct`,且没有合成节点。 - `generation` 标识已保存的选择。`owner_epoch` 用于对执行所有者和节点连接进行栅栏隔离;它不能替代 `expected_generation`。 @@ -179,7 +179,7 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 `GET /core/v1/sandbox/nodes` 返回 `{data: [...]}`,其中每个节点包含 `id`、`name`、`provider`、`online`、`last_seen_at`、`created_at`、`max_active`、`max_retained`,计数项 `active`、`reserved`、`running`、`retained`、`snapshots` 和 `cleanup_pending`,以及 `provider_ready`、`diagnostic`、主机测量值 `cpu_count`、`available_memory_bytes` 和 `available_disk_bytes`、`rollout`、`enrollment_id` 和 `core_url`。`enrollment_id` 是注册该节点的命令的句柄;如果 Core 没有该句柄,则为 null。`core_url` 是注册时的安装公开 URL;`core_url` 与当前公开 URL 不同的节点不会收到新放置。已放置到该节点的工作会继续在那里完成,包括已放置但尚未获得分配的 Environment;只要旧地址仍能访问 Core,其保留沙箱仍可恢复。请移除该节点并重新添加。 -不具备代次管理的节点会自行报告其提供商的就绪状态:`provider_ready`,未就绪时则报告一个固定的 `diagnostic` 代码。通过 Web 的命令添加的节点会管理代次,因此其就绪状态取决于服务代次,而目标代次的固定代码会出现在 `rollout.diagnostic` 中。节点会对首次失败的就绪检查进行分类,并且只发送代码;Core 会将任何其他值存储为 `provider_unavailable`,且绝不存储或返回探测文本或主机路径。代码包括 `docker_unavailable`、`docker_limits_unsupported`、`runtime_download_failed`、`runtime_image_unavailable`、`kvm_unavailable`、`microsandbox_artifacts_unavailable`、`capacity_insufficient` 和 `provider_unavailable`。`runtime_download_failed` 表示无法传输或验证精确的 Runtime 制品;它绝不会包含制品 URL、凭据或传输输出。[就绪代码](../../../docs/zh/getting-started/nodes.md#readiness-codes)给出了原因和操作员应采取的措施。Core 和节点必须来自同一发行包。 +不具备代次管理的节点会自行报告其提供商的就绪状态:`provider_ready`,未就绪时则报告一个固定的 `diagnostic` 代码。通过 Web 的命令添加的节点会管理代次,因此其就绪状态取决于服务代次,而目标代次的固定代码会出现在 `rollout.diagnostic` 中。节点会对首次失败的就绪检查进行分类,并且只发送代码;携带任何其他值的帧均无效,且 Core 绝不存储或返回探测文本或主机路径。这些代码是与 Provider 无关的类别:`provider_unavailable`、`host_unsupported`、`artifacts_unavailable`、`runtime_download_failed`、`runtime_image_unavailable` 和 `capacity_insufficient`。`runtime_download_failed` 表示无法传输或验证精确的 Runtime 制品;它绝不会包含制品 URL、凭据或传输输出。[就绪代码](../../../docs/zh/getting-started/nodes.md#readiness-codes)给出了原因和操作员应采取的措施。Core 和节点必须来自同一发行包。 `PATCH /core/v1/sandbox/nodes/{node_id}` 接受 `{name, max_active, max_retained}`;降低限制不会停止任何正在运行的沙箱。当节点仍持有分配、快照、预留或待处理清理时,包括节点离线期间,`DELETE /core/v1/sandbox/nodes/{node_id}` 会拒绝操作并返回 409 `runtime_node_in_use`。移除操作不会删除任何计算资源,并且会停用该节点的身份;该主机只能作为新节点重新加入。不存在节点排空过程。 @@ -199,7 +199,6 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 | 注册令牌 `max_active`、`max_retained` | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | `max_retained` 始终等于 `max_active` | 两个限制均适用 | | 节点列表和详情 | 空列表;详情返回 404 | 已注册节点 | 已注册节点 | | 节点 `retained`、`snapshots`、`max_retained` | 不适用 | Docker 绝不暂停:`retained` 等于 `active`,`snapshots` 为 0,`max_retained` 等于 `max_active` | 已暂停沙箱数为 `retained` 减去 `active` | -| 节点 `diagnostic` 代码 | 不适用 | `docker_unavailable`、`docker_limits_unsupported`、`runtime_download_failed`、`runtime_image_unavailable`、`capacity_insufficient` 或 `provider_unavailable` | `kvm_unavailable`、`microsandbox_artifacts_unavailable`、`runtime_download_failed`、`capacity_insufficient` 或 `provider_unavailable` | | 节点 `host.available_disk_bytes` | 不适用 | 节点状态目录所在文件系统的可用空间,而不是容器的磁盘 | 节点状态目录所在文件系统的可用空间;沙箱磁盘有自己的配额 | | 分配 `compute_phase`、`compute_phase_changed_at` | 不适用:没有节点分配 | 始终为 `disabled`,在释放前计为运行中;该时间为分配创建时间 | 包含 `suspended`;该时间加上 `suspension.retention_seconds` 可大致确定 Core 回收快照的时间 | | Runtime 观测 `cpu`、`memory` | 来自 E2B 指标:`cpu.utilization_ratio` 和 `capacity_cores`、内存使用量和限制;无累计 CPU 时间 | 来自 Docker stats:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | 来自 VM:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | @@ -220,12 +219,11 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 | 409 | `sandbox_deployment_conflict` | 更改无法应用于另一种状态 | | 409 | `runtime_node_in_use` | 节点仍持有资源时移除节点 | | 503 | `execution_unavailable` | 无法准备提供商 | -| 503 | `credential_storage_unavailable` | Core 没有凭据加密密钥 | 存储和凭据故障始终作为错误处理:读取为空或读取失败绝不能证明清理完成。[机器连接 API](machine-api.md#node-route-errors)列出了节点路由的错误。 ## 规范的节点规格 {#canonical-node-specification} -`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界和模式,因此不存在第二套限制或模式。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 +`sandbox/deployment_contract.go`负责资源边界、发行版模式和规范字段顺序,每个已注册 Provider 的 `sandbox.DeploymentPolicy` 声明其要求;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界、模式和 Provider 声明,因此不存在第二套限制、模式或提供商列表。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 规范摘要是紧凑 UTF-8 JSON 的 SHA-256,其中 `provider` 位于首位,其次是 `resources`,然后在提供商需要时放置 `runtime`。资源和 Runtime 字段遵循契约的声明顺序;值为零的可选磁盘字段会被省略,必填字段则保持存在。发行版标识采用小写 ASCII,摘要绝不会受传入字段顺序或空白字符影响。`services/core/internal/sandbox/testdata/deployment-contract.json`保存共享验收用例、精确的规范字节和摘要,Go 与 Python 测试都会使用这些内容。 diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index cb7230f99..99688def4 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "会话、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: c6141811b426fb0dfb95105b27cc381af5f22e3a7923a171f113f228ae0a5b33 +source_hash: 93ace7d112cbc671039624ec9c999addce715bb08036a50246442a9e14549021 --- 本契约涵盖会话(Session)内部发生的事情:发送输入、实时事件流,以及读取轮次(Turn)、条目(Item)和使用量的持久化历史。会话资源本身(创建配置、重试标识、更新、列出和删除)见 [Core 线协议行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -41,7 +41,7 @@ Turn 失败后会话仍可使用:新输入会启动一个新 Turn。后来预 - **取消。** 排队的 Turn 无需活动 Runtime 即可取消。正在运行的 Turn 只有在 Runtime 确认后才会取消;完成操作可能赢得该竞争。读取到 `cancelled` 时才表示该 Turn 已停止,而不是请求返回时。在没有待处理输入的情况下,对空闲会话执行的取消会被接受且不产生任何效果;而在输入预留待处理期间,取消会返回 409。 - **函数结果。** `turn_id`、`call_id` 和 `success` 为必填项;`output` 和 `error` 为可选项且可为空([内容规则](message-content.md#function-results))。重复提交完全相同的结果会返回 202,不会再次应用或发出事件。harness 应用结果时才会出现结果 Item;如果取消操作导致结果无法应用,结果仍会存储,但不会产生 Item。 - **排队。** 当一个已连接且支持该会话 harness 和配置的 Runtime 接入,并且 Core 的 [`core.execution_concurrency`](../../../docs/zh/configuration.md#settings) 工作槽位有一个空闲时,排队的 Turn 才会启动。会话始终绑定到首次运行它的 Runtime。 -- **执行可用性。** 不具备执行能力的服务会返回 503 `execution_unavailable`,失去执行所有权的 Worker 会返回 503。 +- **执行可用性。** Core 关闭期间,或其 Worker 失去执行所有权后,请求返回 503 `execution_unavailable`。 ### 包含 Environment 的会话 {#sessions-with-an-environment} diff --git a/contracts/agents-api/zh/vaults.md b/contracts/agents-api/zh/vaults.md index 81434de67..fb5e55ee9 100644 --- a/contracts/agents-api/zh/vaults.md +++ b/contracts/agents-api/zh/vaults.md @@ -1,7 +1,7 @@ --- title: "Vault 与 Credential" source: contracts/agents-api/vaults.md -source_hash: 95626340ee36faee3418dd1155a0e50c378ead09c09c91e86f30e09bd8f409e0 +source_hash: fe706a5c3fd5f03ccfe25d0b075de3ad9360b8e0512b3303220fc9a707852181 --- Vault 是 Project 所有的 Credential 容器。Credential 保存一个 HTTPS MCP server 的秘密:`static_bearer` token 或 `mcp_oauth` grant。Session 在 `vault_ids` 中关联 Vault;Core 在创建 Session 时为每个 HTTP MCP server 选择一个 Credential,只在分派工作时将解密 token 交给 Runtime。秘密只能写入:任何读取都不返回 token、refresh token、client secret 或密文。 @@ -141,10 +141,10 @@ Token endpoint 必须为 HTTPS。Core 解析主机,拒绝回环、私有、链 Core 使用安装的 [`secrets/core/credential.key`](../../../docs/zh/configuration.md#compose-installations),以 AES-256-GCM 加密每个 token、refresh token 和 client secret,绑定 Project、Vault、Credential、auth type 和 `mcp_server_url`。错误密钥、修改的行或移动到其他绑定的行均无法解密。名称是不参与绑定的元数据。key 和明文 token 存在于可信服务内存中;加密保护存储的秘密,不保护已被攻破的服务主机。 -未配置 key 时,Credential 创建和替换在写入前返回 503 `credential_storage_unavailable`;读取、列表、删除和 Vault 操作仍可用。key 文件不可读或格式错误会使 Core 启动失败。丢失或替换 key 使全部已存储秘密无法使用;Core 只支持一个 key,不支持轮换或重新加密。 +key 文件不可读或格式错误会使 Core 启动失败。丢失或替换 key 使全部已存储秘密无法使用。读取、列表、删除、Vault 操作、新建 Credential 和替换 `static_bearer` token 仍可用;`mcp_oauth` 更新返回 500 `internal_error`,需要旧秘密的派发会失败。已保存的 E2B 密钥同样失效:托管 Session 返回 503 `execution_unavailable`,直到你打开 **System** → **Manage sandbox configuration**,选择 **Reset deployment**(必要时选择 **Force — cancel remaining work now**)并重新配置后端;遗留的沙箱按其 E2B 超时过期。Core 只支持一个 key,不支持轮换或重新加密。 ## 删除 {#deletion} -删除 Credential 或其 Vault 会移除凭证行,但不会擦除 PostgreSQL 页面、WAL、备份或原生历史中的秘密。此后查询、更新和重复删除返回 404;列表不再包含它;新 Session 不能选择它;不能在已删除 Vault 中创建新 Credential(缺失 storage key 时仍先返回 503)。 +删除 Credential 或其 Vault 会移除凭证行,但不会擦除 PostgreSQL 页面、WAL、备份或原生历史中的秘密。此后查询、更新和重复删除返回 404;列表不再包含它;新 Session 不能选择它;不能在已删除 Vault 中创建新 Credential。 现有 Session 保留固定的关联和选择,历史仍可读。下一次需要已删除 Credential 的分派失败;Core 不选择其他 Credential,也不匿名连接。删除不取消运行中的工作、不收回已发送 Runtime 的 token,也不在服务方撤销 grant;需要时自行取消 Session 并撤销 grant。已撤销或无效 OAuth grant 同样失败,直到被替换。 diff --git a/deploy/node/node_generations.py b/deploy/node/node_generations.py index 158eaf827..70e61fe0d 100644 --- a/deploy/node/node_generations.py +++ b/deploy/node/node_generations.py @@ -136,8 +136,8 @@ def root_runtime_files(root, value, others, installer): """Return only verified original Runtime files that no retained config uses.""" def paths(configuration): if configuration["provider"] == "docker": - return [Path(configuration["docker"]["seccomp_file"])] - return [Path(configuration["microsandbox"][key]) for key in ("helper_path", "runtime_path", "firmware_path")] + return [Path(configuration["native"]["seccomp_file"])] + return [Path(configuration["native"][key]) for key in ("helper_path", "runtime_path", "firmware_path")] names = ["runtime/seccomp.json", "images/runtime.tar.gz", "images/runtime.tar"] if value["provider"] == "microsandbox": names.extend(installer.MICRO) @@ -227,14 +227,14 @@ def validate_preparation_plan(root, plan, base, installer): if not re.fullmatch(r"[a-f0-9]{40}", source): raise installer.InstallError("Preparation release identity differs") if plan["provider"] == "docker": - policy = Path(plan["docker"]["seccomp_file"]) + policy = Path(plan["native"]["seccomp_file"]) if policy not in (root / "runtime/seccomp.json", root / "releases" / source / "runtime/seccomp.json"): raise installer.InstallError("Preparation policy is outside its immutable release") - if plan["docker"]["image"] not in (runtime["image_id"], runtime["image_manifest_digest"]): + if plan["native"]["image"] not in (runtime["image_id"], runtime["image_manifest_digest"]): raise installer.InstallError("Preparation image differs from the specification") installer.no_links(policy) else: - micro = plan["microsandbox"] + micro = plan["native"] paths = [Path(micro[key]) for key in ("helper_path", "runtime_path", "firmware_path")] if not any(paths == [release / name for name in installer.MICRO] for release in (root, root / "releases" / source)): raise installer.InstallError("Preparation artifacts are outside their immutable release") @@ -244,17 +244,17 @@ def validate_preparation_plan(root, plan, base, installer): for path in paths + [expected_home]: installer.no_links(path) configuration = dict(plan, core_url=plan["core_url"].removesuffix("/api/v1")) - installer.node_spec.verify_provider(plan, configuration, plan.get("docker", {}).get("image")) + installer.node_spec.verify_provider(plan, configuration, plan.get("native", {}).get("image")) def verify_plan_final(plan, final, installer): expected = copy.deepcopy(plan) if plan["provider"] == "docker": - image = final.get("docker", {}).get("image") + image = final.get("native", {}).get("image") runtime = plan["specification"]["runtime"] if image not in (runtime["image_id"], runtime["image_manifest_digest"]): raise installer.InstallError("Final Docker image differs from the preparation specification") - expected["docker"]["image"] = image + expected["native"]["image"] = image if expected != final: raise installer.InstallError("Final generation differs from its immutable preparation plan") @@ -274,9 +274,9 @@ def owned_root(args, installer): def generation_home(root, configuration, base, installer): runtime = configuration["specification"]["runtime"] - previous = base["microsandbox"] + previous = base["specification"]["runtime"] if (runtime["runtime_sha256"], runtime["firmware_sha256"]) == (previous["runtime_sha256"], previous["firmware_sha256"]): - return Path(previous["runtime_home"]) + return Path(base["native"]["runtime_home"]) material = ":".join((configuration["installation_id"], runtime["runtime_sha256"], runtime["firmware_sha256"])) home = Path.home() / ".oac/m" / hashlib.sha256(material.encode()).hexdigest()[:12] if len(os.fsencode(home)) > 48: @@ -287,21 +287,21 @@ def generation_home(root, configuration, base, installer): def image_available(value, installer): try: if value["provider"] == "docker": - seccomp = Path(value["docker"]["seccomp_file"]) + seccomp = Path(value["native"]["seccomp_file"]) installer.existing_file(seccomp) json.loads(seccomp.read_text()) - raw = installer.checked(list(installer.DOCKER) + ["image", "inspect", value["docker"]["image"], "--format", "{{.Id}} {{.Os}}/{{.Architecture}}"], "Cannot inspect pinned image") - return raw.strip() == value["docker"]["image"] + " linux/amd64" - micro = value["microsandbox"] + raw = installer.checked(list(installer.DOCKER) + ["image", "inspect", value["native"]["image"], "--format", "{{.Id}} {{.Os}}/{{.Architecture}}"], "Cannot inspect pinned image") + return raw.strip() == value["native"]["image"] + " linux/amd64" + micro, runtime = value["native"], value["specification"]["runtime"] for key in ("helper_path", "runtime_path", "firmware_path"): if not installer.existing_file(Path(micro[key])): return False - if (installer.file_digest(Path(micro["runtime_path"])) != micro["runtime_sha256"] - or installer.file_digest(Path(micro["firmware_path"])) != micro["firmware_sha256"]): + if (installer.file_digest(Path(micro["runtime_path"])) != runtime["runtime_sha256"] + or installer.file_digest(Path(micro["firmware_path"])) != runtime["firmware_sha256"]): return False env = dict(os.environ, MSB_BACKEND="local", MSB_HOME=micro["runtime_home"], MSB_PATH=micro["runtime_path"], MSB_LIBKRUNFW_PATH=micro["firmware_path"]) - image = json.loads(installer.checked([micro["runtime_path"], "image", "inspect", micro["image"], "--format", "json"], "Cannot inspect pinned image", env=env)) - return image.get("digest") == micro["image"].split("@", 1)[1] and image.get("architecture") == "amd64" and image.get("os") == "linux" + image = json.loads(installer.checked([micro["runtime_path"], "image", "inspect", runtime["microsandbox_ref"], "--format", "json"], "Cannot inspect pinned image", env=env)) + return image.get("digest") == runtime["microsandbox_ref"].split("@", 1)[1] and image.get("architecture") == "amd64" and image.get("os") == "linux" except (installer.InstallError, OSError, ValueError): return False @@ -312,13 +312,13 @@ def runtime_files(root, value, args, manifest, sums, installer): release = root / "releases" / source if value is not None: if args.provider == "microsandbox": - release = Path(value["microsandbox"]["helper_path"]).parents[2] - if any(Path(value["microsandbox"][key]) != release / name for key, name in zip( + release = Path(value["native"]["helper_path"]).parents[2] + if any(Path(value["native"][key]) != release / name for key, name in zip( ("helper_path", "runtime_path", "firmware_path"), installer.MICRO)): raise installer.InstallError("Retained Runtime artifact paths differ") else: - release = Path(value["docker"]["seccomp_file"]).parents[1] - if Path(value["docker"]["seccomp_file"]) != release / "runtime/seccomp.json": + release = Path(value["native"]["seccomp_file"]).parents[1] + if Path(value["native"]["seccomp_file"]) != release / "runtime/seccomp.json": raise installer.InstallError("Retained Runtime seccomp path differs") if release not in (root, root / "releases" / source): raise installer.InstallError("Retained Runtime artifacts are outside this installation") @@ -377,7 +377,7 @@ def prepare(args, installer): value = configurations.get(args.generation) finalized = target.exists() or base["generation"] == args.generation if value is not None: - installer.node_spec.verify_provider(value, args.configuration, value.get("docker", {}).get("image")) + installer.node_spec.verify_provider(value, args.configuration, value.get("native", {}).get("image")) else: for candidate in configurations.values(): # Unpublished plans must never be used as ready reuse candidates. @@ -387,8 +387,6 @@ def prepare(args, installer): value = copy.deepcopy(candidate) value["generation"] = args.generation value["specification"] = args.configuration["specification"] - if args.provider == "microsandbox": - value["microsandbox"].update(value["specification"]["resources"]) break if not finalized or value is None or not image_available(value, installer): settings = installer.private_json(root / "preparation.json") @@ -400,9 +398,9 @@ def prepare(args, installer): except installer.node_spec.SpecificationError as error: raise installer.RuntimeDownloadError("Runtime release provenance differs") from error if args.provider == "microsandbox": - args.runtime_home = Path(value["microsandbox"]["runtime_home"]) if value else generation_home(root, args.configuration, base, installer) + args.runtime_home = Path(value["native"]["runtime_home"]) if value else generation_home(root, args.configuration, base, installer) if value is None: - value = installer.provider_config(root / "releases" / runtime["source_commit"], args, manifest, runtime["image_id"]) + value = installer.provider_config(root / "releases" / runtime["source_commit"], args, runtime["image_id"]) if preparation is None: preparation = dict(marker_identity(args), import_started=False, configuration=copy.deepcopy(value)) atomic_json(directory / (str(args.generation) + ".preparing"), preparation) @@ -422,7 +420,7 @@ def prepare(args, installer): if runtime_image not in (runtime["image_id"], runtime["image_manifest_digest"]): raise installer.InstallError("Resolved Docker image is outside the authorized specification") value = copy.deepcopy(value) - value["docker"]["image"] = runtime_image + value["native"]["image"] = runtime_image if preparation and preparation.get("configuration"): verify_plan_final(preparation["configuration"], value, installer) if installer.existing_file(target): @@ -560,26 +558,26 @@ def collect(args, installer): def collect_image(args, value, others, installer): if value["provider"] == "microsandbox": - micro = value["microsandbox"] - shared = [item for item in others if item["microsandbox"]["runtime_home"] == micro["runtime_home"]] + micro, image = value["native"], value["specification"]["runtime"]["microsandbox_ref"] + shared = [item for item in others if item["native"]["runtime_home"] == micro["runtime_home"]] home = Path(micro["runtime_home"]) installer.no_links(home) if not home.is_dir() or installer.private_json(home / "oac-installation.json") != {"installation_id": args.installation_id}: raise installer.InstallError("Microsandbox store ownership differs") runtime_path = Path(micro["runtime_path"]) installer.no_links(runtime_path) - if not installer.existing_file(runtime_path) or installer.file_digest(runtime_path) != micro["runtime_sha256"]: + if not installer.existing_file(runtime_path) or installer.file_digest(runtime_path) != value["specification"]["runtime"]["runtime_sha256"]: raise installer.InstallError("Cannot verify retained microsandbox executable") env = dict(os.environ, MSB_BACKEND="local", MSB_HOME=micro["runtime_home"], MSB_PATH=micro["runtime_path"], MSB_LIBKRUNFW_PATH=micro["firmware_path"]) - if not any(item["microsandbox"]["image"] == micro["image"] for item in shared): + if not any(item["specification"]["runtime"]["microsandbox_ref"] == image for item in shared): # A failed inspect/remove is not proof of absence. A successful full # inventory must contain only understood immutable references. raw = installer.checked([micro["runtime_path"], "image", "list", "--quiet"], "Cannot verify microsandbox image inventory", env=env) references = raw.splitlines() if any(not re.fullmatch(r"[^\s@]+@sha256:[a-f0-9]{64}", item) for item in references): raise installer.InstallError("Cannot verify microsandbox image inventory") - if any(item.split("@", 1)[1] == micro["image"].split("@", 1)[1] for item in references): - installer.checked([micro["runtime_path"], "image", "remove", micro["image"], "--quiet"], "Runtime image is still in use", env=env) + if any(item.split("@", 1)[1] == image.split("@", 1)[1] for item in references): + installer.checked([micro["runtime_path"], "image", "remove", image, "--quiet"], "Runtime image is still in use", env=env) if not shared: raw = installer.checked([micro["runtime_path"], "sandbox", "list", "--format", "json"], "Cannot verify empty microsandbox store", env=env) if json.loads(raw) != []: diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py index 7944db376..d563a29cf 100644 --- a/deploy/node/node_install.py +++ b/deploy/node/node_install.py @@ -279,11 +279,11 @@ def micro_home(installation_id): return directory -def provider_config(root, args, manifest, runtime_image): +def provider_config(root, args, runtime_image): result = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url + "/api/v1", "specification": args.configuration["specification"], "generation": args.configuration["generation"]} if args.provider == "docker": - result["docker"] = {"host": "unix:///var/run/docker.sock", "image": runtime_image, + result["native"] = {"host": "unix:///var/run/docker.sock", "image": runtime_image, "network": "oac-node-" + args.installation_id, "seccomp_file": str(root / "runtime/seccomp.json"), "nested_sandbox": True} else: @@ -291,11 +291,9 @@ def provider_config(root, args, manifest, runtime_image): port = endpoint.port or (443 if endpoint.scheme == "https" else 80) addresses = sorted({entry[4][0] for entry in socket.getaddrinfo(endpoint.hostname, port, type=socket.SOCK_STREAM)}) core_rules = [{"action": "allow", "direction": "egress", "destination": address, "protocol": "tcp", "port": str(port)} for address in addresses] - result["microsandbox"] = { + result["native"] = { "helper_path": str(root / MICRO[0]), "runtime_path": str(root / MICRO[1]), "firmware_path": str(root / MICRO[2]), - "runtime_sha256": manifest["microsandbox"]["runtime_sha256"], "firmware_sha256": manifest["microsandbox"]["firmware_sha256"], - "runtime_home": str(getattr(args, "runtime_home", micro_home(args.installation_id))), "image": manifest["runtime_ref"], - **args.configuration["specification"]["resources"], + "runtime_home": str(getattr(args, "runtime_home", micro_home(args.installation_id))), "network": {"default_egress": "deny", "default_ingress": "deny", "rules": core_rules + [ {"action": "allow", "direction": "egress", "destination": "public"}, {"action": "allow", "direction": "egress", "destination": "host", "protocol": "udp", "port": "53"}, @@ -417,7 +415,7 @@ def register_node(root, args, token, helper_archive=None, *, secret_path): runtime_image = prepare_runtime(root, args, manifest) # Retain the original network policy when recovering a partial installation. if not existing_file(root / "provider.json"): - write_once(root / "provider.json", json_text(provider_config(root, args, manifest, runtime_image))) + write_once(root / "provider.json", json_text(provider_config(root, args, runtime_image))) else: node_spec.verify_provider(json.loads((root / "provider.json").read_text()), args.configuration, runtime_image) marker = root / "registered.json" @@ -1092,7 +1090,7 @@ def remove_node_files(root, installation_id): Runs as the node's own user, so a link it planted can never reach another user's files.""" no_links(root) provider = private_json(root / "provider.json") or {} - image = (provider.get("docker") or {}).get("image") + image = (provider.get("native") or {}).get("image") runtime_home = micro_home(installation_id) if root.exists(): shutil.rmtree(root) diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index a74b79cfe..ba28d64c4 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -27,7 +27,7 @@ def release(manifest): # BEGIN GENERATED DEPLOYMENT CONTRACT # Generated from sandbox/deployment_contract.go; do not edit. -_CONTRACT = json.loads("{\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"disk\":false,\"runtime\":true},\"e2b\":{\"disk\":false,\"runtime\":false},\"microsandbox\":{\"disk\":true,\"runtime\":true}},\"minimum_disk\":1024}") +_CONTRACT = json.loads("{\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"runtime\":false,\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"disk\":true,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}") # END GENERATED DEPLOYMENT CONTRACT @@ -158,14 +158,5 @@ def verify_provider(stored, configuration, runtime_image): or stored.get("generation") != configuration["generation"] or stored.get("core_url") != configuration["core_url"] + "/api/v1"): raise SpecificationError("Retained node configuration differs from Core; preserve its state") - if provider == "docker": - if stored.get("docker", {}).get("image") != runtime_image: - raise SpecificationError("Retained Docker image differs; preserve the node and inspect its configuration") - else: - micro = stored.get("microsandbox", {}) - if any(key in micro for key in ("max_active", "max_retained", "idle_seconds", "retention_seconds")): - raise SpecificationError("Node capacity and lifecycle policy belong to Core; regenerate the stale provider file") - expected = dict(spec["resources"], image=spec["runtime"]["microsandbox_ref"], - runtime_sha256=spec["runtime"]["runtime_sha256"], firmware_sha256=spec["runtime"]["firmware_sha256"]) - if any(micro.get(key) != value for key, value in expected.items()): - raise SpecificationError("Retained microsandbox configuration differs from Core; preserve its state") + if provider == "docker" and stored.get("native", {}).get("image") != runtime_image: + raise SpecificationError("Retained Docker image differs; preserve the node and inspect its configuration") diff --git a/deploy/node/test_generation_review_regressions.py b/deploy/node/test_generation_review_regressions.py index 548e47a9e..f2f730ff1 100644 --- a/deploy/node/test_generation_review_regressions.py +++ b/deploy/node/test_generation_review_regressions.py @@ -46,7 +46,7 @@ def interrupted(path, value): with mock.patch.object(node_generations, 'atomic_json', side_effect=interrupted): with self.assertRaises(OSError): node_generations.prepare(case.args, installer) - self.assertEqual(installer.private_json(path)['docker']['image'], case.manifest['image_manifest_digests']['runtime']) + self.assertEqual(installer.private_json(path)['native']['image'], case.manifest['image_manifest_digests']['runtime']) self.assertTrue(installer.private_json(path.with_suffix('.preparing'))['import_started']) node_generations.prepare(case.args, installer) saved = path.read_bytes() @@ -217,7 +217,7 @@ def test_unpublished_plan_refuses_path_or_installation_drift(self): changed=json.loads(json.dumps(original)); changed['configuration'][field]=value node_generations.atomic_json(path,changed) with self.assertRaises(installer.InstallError): node_generations.retained_configs(case.root,installer) - changed=json.loads(json.dumps(original)); changed['configuration']['docker']['seccomp_file']=str(case.home/'foreign') + changed=json.loads(json.dumps(original)); changed['configuration']['native']['seccomp_file']=str(case.home/'foreign') node_generations.atomic_json(path,changed) with self.assertRaises(installer.InstallError): node_generations.collect(case.args,installer) self.assertFalse((case.root/'state/node/generations/2.dropped').exists()) @@ -259,7 +259,7 @@ def available(value,installer): with mock.patch.object(installer.node_spec,'fetch',return_value=cfg),mock.patch.object(node_generations,'image_available',side_effect=available): node_generations.prepare(case.args,installer) final=installer.private_json(case.root/'state/node/generations/2.json') - self.assertNotEqual(final['microsandbox']['helper_path'],original['microsandbox']['helper_path']) + self.assertNotEqual(final['native']['helper_path'],original['native']['helper_path']) self.assertIn(1,node_generations.retained_configs(case.root,installer)) def test_operator_update_refuses_before_download_or_update(self): diff --git a/deploy/node/test_node_generations.py b/deploy/node/test_node_generations.py index ca64cb5a7..c83ff6f0d 100644 --- a/deploy/node/test_node_generations.py +++ b/deploy/node/test_node_generations.py @@ -21,11 +21,11 @@ def setUp(self): self.root = Path(temporary.name) self.directory = self.root / "state/node/generations" self.directory.mkdir(parents=True, mode=0o700) - self.value = {"installation_id": "test-installation", "generation": 1, "provider": "docker", "docker": {"image": "sha256:" + "a" * 64}, + self.value = {"installation_id": "test-installation", "generation": 1, "provider": "docker", "native": {"image": "sha256:" + "a" * 64}, "specification": {"resources": {"cpus": 1, "memory_mib": 1024}, "runtime": {"source_commit": "b" * 40, "image_id": "sha256:" + "a" * 64, "image_manifest_digest": "sha256:" + "c" * 64, "microsandbox_ref": "oac-runtime@sha256:" + "d" * 64, "runtime_sha256": "e" * 64, "firmware_sha256": "f" * 64}}} self.args = SimpleNamespace(installation_id="test-installation", generation=1, specification_digest=node_spec.digest("docker", self.value["specification"])) self.release = self.root / "releases" / ("b" * 40) - self.value["docker"]["seccomp_file"] = str(self.release / "runtime/seccomp.json") + self.value["native"]["seccomp_file"] = str(self.release / "runtime/seccomp.json") self.release.mkdir(parents=True) (self.release / "artifact").write_bytes(b"immutable bytes") node_generations.atomic_json(self.root / "provider.json", self.value) @@ -166,7 +166,6 @@ def test_never_imported_generation_can_collect_missing_executable(self): def micro_fixture(self): self.value["provider"] = "microsandbox" - del self.value["docker"] home = self.root / "micro-store" home.mkdir(mode=0o700) node_generations.atomic_json(home / "oac-installation.json", {"installation_id": self.args.installation_id}) @@ -174,7 +173,8 @@ def micro_fixture(self): runtime.write_bytes(b"verified native executable") runtime.chmod(0o700) image = self.value["specification"]["runtime"]["microsandbox_ref"] - self.value["microsandbox"] = {"helper_path": str(self.release / "helper"), "runtime_home": str(home), "runtime_path": str(runtime), "firmware_path": str(self.release / "firmware"), "runtime_sha256": hashlib.sha256(runtime.read_bytes()).hexdigest(), "image": image} + self.value["specification"]["runtime"]["runtime_sha256"] = hashlib.sha256(runtime.read_bytes()).hexdigest() + self.value["native"] = {"helper_path": str(self.release / "helper"), "runtime_home": str(home), "runtime_path": str(runtime), "firmware_path": str(self.release / "firmware")} self.args.specification_digest = node_spec.digest("microsandbox", self.value["specification"]) node_generations.atomic_json(self.root / "provider.json", self.value) # This fixture changes provider before any helper exists. @@ -217,14 +217,14 @@ def test_docker_collection_preserves_another_installations_idle_serving_image(se other = dict(self.value, installation_id="second-installation") node_generations.atomic_json(second / "provider.json", other) before = (second / "provider.json").read_bytes() - host_images = {self.value["docker"]["image"]} + host_images = {self.value["native"]["image"]} def docker(command, *_args, **_kwargs): if "rm" in command or "prune" in command: host_images.clear() raise AssertionError("generation GC must not manage shared Docker images") installer.checked.side_effect = docker node_generations.collect(self.args, installer) installer.checked.assert_not_called() - self.assertEqual(host_images, {other["docker"]["image"]}) + self.assertEqual(host_images, {other["native"]["image"]}) self.assertEqual((second / "provider.json").read_bytes(), before) self.assertFalse(self.release.exists()) self.assertEqual(node_generations.retained_configs(self.root, installer), {}) diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py index b17d6fd80..203db53bf 100644 --- a/deploy/node/test_node_install.py +++ b/deploy/node/test_node_install.py @@ -315,7 +315,7 @@ def test_shared_original_firmware_survives_until_its_last_generation(self): self.args.provider = "microsandbox" self.install() successor = self.prepare_successor_runtime() - successor["microsandbox"]["firmware_path"] = str(self.root / installer.MICRO[2]) + successor["native"]["firmware_path"] = str(self.root / installer.MICRO[2]) installer.node_generations.atomic_json(self.root / "state/node/generations/2.json", successor) original = installer.private_json(self.root / "provider.json") self.args.generation = 1 @@ -377,9 +377,9 @@ def interrupted(manifest, name, path): self.assertFalse((directory / "2.json").exists()) self.assertEqual(saved["specification"], config["specification"]) self.assertFalse(json.loads((directory / "2.preparing").read_text())["import_started"]) - helper = Path(saved["microsandbox"]["helper_path"]) + helper = Path(saved["native"]["helper_path"]) inode = helper.stat().st_ino - self.assertFalse(Path(saved["microsandbox"]["runtime_path"]).exists()) + self.assertFalse(Path(saved["native"]["runtime_path"]).exists()) installer.node_generations.prepare(self.args, installer) self.assertEqual(helper.stat().st_ino, inode) self.assertEqual(json.loads((directory / "2.json").read_text()), saved) @@ -436,7 +436,7 @@ def test_docker_installs_matched_payload_registers_and_starts_persistent_service system = self.sudo_host() installer.install_system(self.args, "synthetic-once-token") config = json.loads((self.root / "provider.json").read_text()) - self.assertEqual(config["docker"]["image"], self.manifest["images"]["runtime"]) + self.assertEqual(config["native"]["image"], self.manifest["images"]["runtime"]) self.assertEqual(config["core_url"], self.args.core_url + "/api/v1") self.assertEqual(config["installation_id"], self.args.installation_id) self.assertFalse((self.root / installer.MICRO[0]).exists()) @@ -455,7 +455,7 @@ def test_containerd_node_persists_actual_id_and_warm_retry_avoids_archive(self): self.containerd = True self.install() expected = self.manifest['image_manifest_digests']['runtime'] - self.assertEqual(json.loads((self.root / 'provider.json').read_text())['docker']['image'], expected) + self.assertEqual(json.loads((self.root / 'provider.json').read_text())['native']['image'], expected) before = (self.root / 'provider.json').read_bytes() with mock.patch.object(installer.distribution, 'runtime_archive', side_effect=AssertionError('warm Runtime download')): self.install() @@ -464,7 +464,7 @@ def test_containerd_node_persists_actual_id_and_warm_retry_avoids_archive(self): def test_retained_provider_image_cannot_bypass_verified_selection(self): self.install() config = json.loads((self.root / 'provider.json').read_text()) - config['docker']['image'] = 'sha256:' + 'f' * 64 + config['native']['image'] = 'sha256:' + 'f' * 64 (self.root / 'provider.json').write_text(json.dumps(config)) self.calls.clear() with self.assertRaisesRegex(node_spec.SpecificationError, 'Retained Docker image differs'): @@ -483,14 +483,9 @@ def test_wrong_loaded_runtime_cannot_register_or_write_provider_config(self): def test_microsandbox_imports_image_and_allows_only_explicit_private_core_endpoint(self): self.args.provider = "microsandbox" self.install() - config = json.loads((self.root / "provider.json").read_text())["microsandbox"] - self.assertEqual(config["runtime_sha256"], self.manifest["microsandbox"]["runtime_sha256"]) - self.assertEqual(config["cpus"], 3) - self.assertEqual(config["memory_mib"], 6144) - self.assertEqual(config["root_disk_mib"], 10240) - self.assertEqual(config["environment_disk_mib"], 12288) - for field in ("idle_seconds", "retention_seconds", "max_active", "max_retained"): - self.assertNotIn(field, config) + config = json.loads((self.root / "provider.json").read_text())["native"] + # Resources, the image and artifact hashes are read from the specification. + self.assertEqual(set(config), {"helper_path", "runtime_path", "firmware_path", "runtime_home", "network"}) rules = config["network"]["rules"] self.assertIn({"action": "allow", "direction": "egress", "destination": "172.29.144.1", "protocol": "tcp", "port": "24443"}, rules) self.assertNotIn("private", [rule["destination"] for rule in rules]) @@ -963,7 +958,7 @@ def forge(): def test_uninstall_prints_only_an_image_id_from_the_service_home(self): root = self.home / "node" root.mkdir() - (root / "provider.json").write_text(json.dumps({"docker": {"image": "x\nFinish with: sudo sh"}})) + (root / "provider.json").write_text(json.dumps({"native": {"image": "x\nFinish with: sudo sh"}})) output = io.StringIO() with mock.patch.object(installer.sys, "stdout", output): installer.remove_node_files(root, self.args.installation_id) @@ -1083,20 +1078,6 @@ def test_offline_bundle_uses_same_bootstrap_and_verified_artifacts(self): self.install() self.assertEqual(json.loads((self.root / "provider.json").read_text())["specification"], self.args.configuration["specification"]) - def test_changed_local_micro_resources_cannot_reconnect(self): - self.args.provider = "microsandbox" - self.install() - target = self.root / "provider.json" - stored = json.loads(target.read_text()) - stored["microsandbox"]["cpus"] += 1 - target.write_text(json.dumps(stored)) - before = target.read_bytes() - self.calls.clear() - with self.assertRaisesRegex(node_spec.SpecificationError, "microsandbox configuration differs"): - self.install() - self.assertEqual(target.read_bytes(), before) - self.assertFalse(any("register" in call or "enable" in call for call, _ in self.calls)) - def test_origin_rejects_other_schemes_credentials_paths_and_redirects(self): for value in ("ftp://core.example", "https://user@core.example", "https://@core.example", "https://core.example/v1", "https://core.example?", "https://core.example#", "https://core.example\\path", "https://core.example:bad", ""): with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): diff --git a/docs/configuration.md b/docs/configuration.md index 0951a30ef..48baf73da 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,7 +45,7 @@ Model providers are not process settings; see [Default models](#default-models). | Variable | Default | Meaning | | --- | --- | --- | -| `OAC_PUBLIC_URL` | `http://localhost:8080`, set by `compose.yaml`. Core started without it runs no Runtime gateway and executes no Sessions | Origin applications, nodes, sandboxes and self-hosted executors use. See [changing the public URL](#changing-the-public-url) | +| `OAC_PUBLIC_URL` | Required; `compose.yaml` sets `http://localhost:8080` | Origin applications, nodes, sandboxes and self-hosted executors use. See [changing the public URL](#changing-the-public-url) | | `OAC_HOST` | `127.0.0.1` | Web bind address published by `compose.yaml`. The installer sets `0.0.0.0` | | `OAC_WEB_PORT` | `8080` | Host port of Web | | `OAC_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` | @@ -81,7 +81,7 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa | Setting | Where in Web | Core API | Notes | | --- | --- | --- | --- | | Sandbox backend: Docker, microsandbox or E2B | **System** → **Manage sandbox configuration**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation, chosen after the first sign-in. Another backend needs **Reset deployment** first; see [change the sandbox configuration](./getting-started/nodes.md#change-the-sandbox-configuration) | -| Sandbox size, Runtime release, E2B key and template build | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the sizes in [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json). Existing sandboxes keep their size and release. The E2B key is write-only and encrypted | +| Sandbox size, Runtime release, E2B key and template build | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the [default size the Provider declares](./sandbox-provider.md#register-the-provider-kind). Existing sandboxes keep their size and release. The E2B key is write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**; **Edit node** and **Remove node** on a node's page | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and the [nodes guide](./getting-started/nodes.md) | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | | Default model per harness | **System** → **Default model configuration**: **Set** | `/core/v1/harnesses/{harness}/model-configuration` | See [Default models](#default-models) | @@ -122,11 +122,12 @@ The named Docker volume `_data` contains these paths. Docker manages Li ## Docker node configuration -The node installer writes Docker’s provider configuration into the node’s configuration file. Deployment resources, Runtime images and capacity remain in [Core’s database](#runtime-settings-web). +The node installer writes Docker’s host settings into the `native` object of the node’s configuration file, which only the Docker adapter reads. Deployment resources, the Runtime release and capacity remain in [Core’s database](#runtime-settings-web). | Field | Installer value | Meaning | | --- | --- | --- | | `host` | `unix:///var/run/docker.sock` | Explicit Docker Engine socket | +| `image` | The Runtime image’s local ID after loading | The release’s `image_id` or `image_manifest_digest`. The host’s image store decides which digest names the loaded image, so the value is node-local; the adapter accepts only these two | | `network` | `oac-node-` | Runtime container network | | `seccomp_file` | `/runtime/seccomp.json` | Matched distribution’s seccomp profile | | `nested_sandbox` | `true` | Enables the Docker adapter’s init process and proc-mask configuration | @@ -181,14 +182,14 @@ Core reads its process environment. Compose interpolates `.env` into it and moun | Variable | Set from | | --- | --- | -| `OAC_PUBLIC_URL` | The [public URL](#settings). Core validates it once and derives the Agents API base, the daemon WebSocket URL, the [sandbox Link URL](#changing-the-public-url), the self-hosted `remote_url`, the installer downloads, the hosted sandbox address and the deployment's read-only `core_url` from it, never from request headers | +| `OAC_PUBLIC_URL` | Required. The [public URL](#settings). Core validates it once and derives the Agents API base, the daemon WebSocket URL, the [sandbox Link URL](#changing-the-public-url), the self-hosted `remote_url`, the installer downloads, the hosted sandbox address and the deployment's read-only `core_url` from it, never from request headers | | `OAC_ADDR` | The image sets `:8091`. Independently started Core defaults to `127.0.0.1:8091` when unset or empty | | `OAC_DATABASE_URL` | Required. PostgreSQL without a password | | `OAC_DATABASE_PASSWORD_FILE` | `/run/database/password`. The URL must then carry no password | -| `OAC_CREDENTIAL_KEY_FILE` | `/run/oac/credential.key` | +| `OAC_CREDENTIAL_KEY_FILE` | Required. `/run/oac/credential.key`: a base64-encoded random 32-byte key. Core seals stored credentials with it | | `OAC_CORE_KEY_DIGESTS_FILE` | Required. `/run/oac/core-key-digests.json`: a JSON array with the SHA-256 of the Core key | -| `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`: the installation ID, a canonical UUID. It enables the sandbox deployment and node routes and requires `OAC_PUBLIC_URL`. Core refuses an ID other than the one its database recorded | -| `OAC_AGENT_HOST_IDENTITY_FILE` | `/run/agent-host/identity.json`: the [agent host's identity](#agent-host-container), whose `runtime_id` is a canonical UUID. Required with `OAC_PUBLIC_URL`, and only with it. When Core starts it registers the agent host with that ID and credential; a new credential fences the Links the old one authenticated, and a revoked agent host stays revoked | +| `OAC_INSTALLATION_ID_FILE` | Required. `/run/oac/installation.id`: the installation ID, a canonical UUID. Core refuses an ID other than the one its database recorded | +| `OAC_AGENT_HOST_IDENTITY_FILE` | Required. `/run/agent-host/identity.json`: the [agent host's identity](#agent-host-container), whose `runtime_id` is a canonical UUID. When Core starts it registers the agent host with that ID and credential; a new credential fences the Links the old one authenticated, and a revoked agent host stays revoked | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS`, `OAC_HISTORY_SETTINGS_FILE`, `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | The matching [process settings](#settings). Web reads the three log settings too | | `OAC_PROVIDER_ROOT` | Absolute adapter artifact root. The Core image sets `/opt/oac`. Each adapter owns its helper paths beneath this root. Core serves self-hosted daemon installers from its `native-installers/` directory when that holds a `catalog.json`, after checking the catalog against its own release. Adapter state lives at `/state`, the data volume's [`state/`](#compose-installations) | diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index 107d92160..28aecf694 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -66,7 +66,7 @@ Several installations can share a machine when they use distinct installation di ## Sandbox backend -The installer saves no sandbox backend. After signing in, open **System** → **Manage sandbox configuration** and choose Docker, microsandbox or E2B; Web proposes the Standard size in [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json). The choice is stored in Core's database. To change it later, [reset the deployment](./nodes.md#change-the-sandbox-configuration). Docker shares each node's kernel with its sandboxes, and its node service account is [root-equivalent](./nodes.md#what-the-installer-sets-up). Every backend needs a public HTTPS URL that is not loopback, because sandboxes call Core from outside its host. Prepare an E2B template with the [E2B guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md). +The installer saves no sandbox backend. After signing in, open **System** → **Manage sandbox configuration** and choose Docker, microsandbox or E2B; Web proposes the [default size the Provider declares](../sandbox-provider.md#register-the-provider-kind) as Standard. The choice is stored in Core's database. To change it later, [reset the deployment](./nodes.md#change-the-sandbox-configuration). Docker shares each node's kernel with its sandboxes, and its node service account is [root-equivalent](./nodes.md#what-the-installer-sets-up). Every backend needs a public HTTPS URL that is not loopback, because sandboxes call Core from outside its host. Prepare an E2B template with the [E2B guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md). ## Listeners and access diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index fd99451cd..0aa74c4a8 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -123,9 +123,9 @@ Use manual registration when you manage the node's files and service yourself in 1. Take `oac-node` from the same release as Core. 2. Get an enrollment token: the token in a command from **Add node**, or `POST /core/v1/sandbox/enrollment-tokens` with the Core key. It is single-use and carries the node's approved capacity; the response's `expires_at` says when it expires. Save it in a `0600` file on the host. 3. Read the node configuration with the token, which does not consume it: `GET /api/v1/sandbox-node/configuration` with `Authorization: Bearer `. -4. Write a private provider file. Copy `provider`, `installation_id`, `core_url`, `generation` and `specification` from the response, and add one adapter object for the host: - - `docker`: `host` (an explicit Unix socket), `image` (the locally imported Runtime image of the approved release), `network`, `extra_hosts`, an absolute `seccomp_file` and `nested_sandbox`. - - `microsandbox`: absolute `helper_path`, `runtime_path` and `firmware_path` with their `runtime_sha256` and `firmware_sha256`, `image`, the sandbox `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib`, a `network` policy, and `runtime_home`: a private directory, which the helper creates with mode `0700` when it is missing. microsandbox places Unix sockets under it, so keep its path within 48 bytes; the installer refuses a longer one for its own nodes. +4. Write a private provider file. Copy `provider`, `installation_id`, `core_url`, `generation` and `specification` from the response, and add a `native` object with the host settings of that provider. The adapter reads sandbox size, the Runtime image and artifact hashes from `specification`: + - Docker: the [Docker node configuration](../configuration.md#docker-node-configuration) fields, with `host` an explicit Unix socket, `image` the local ID of the imported Runtime image and `seccomp_file` absolute. + - microsandbox: absolute `helper_path`, `runtime_path` and `firmware_path`, a `network` policy, and `runtime_home`: a private directory, which the helper creates with mode `0700` when it is missing. microsandbox places Unix sockets under it, so keep its path within 48 bytes; the installer refuses a longer one for its own nodes. 5. Register, then run the node under the host's service supervisor, with real absolute paths: ```sh @@ -157,20 +157,18 @@ When a node is online but its sandbox provider is not ready, **Nodes** and **Ove - **Nodes added with Web's command** report a failed check of the current sandbox configuration as **Preparation failed** in the node's target status, with the reason in a help tip (`rollout.diagnostic` in `GET /core/v1/sandbox/nodes`). The tip beside **Provider not ready** only says *Sandbox provider unavailable*. - **Manually registered nodes** show the reason in the help tip beside **Provider not ready** (`diagnostic`). -The node's log has the local error behind the code. +Each code is a Provider-neutral class; the local error behind it stays on the node. A manually registered node logs it. For a node added with Web's command, rerun the command on the host: the installer checks the host requirements first and names what to fix ([Installer messages](#installer-messages)). A node reports only its first failed check, in this order: the Docker daemon or KVM, Docker's limit support, host capacity, then the installed Runtime files. An unreachable Docker daemon therefore hides a missing image. The next heartbeat, about ten seconds after a fix, clears or replaces the code. An offline node keeps its last code, which Web hides until the node reconnects. | Code | Help tip | Cause | Fix | | --- | --- | --- | --- | -| `docker_unavailable` | Docker unavailable | The Docker socket is unreachable or not accessible, or Docker fails its info or image request | Start Docker and give the node's user access to `/var/run/docker.sock` | -| `docker_limits_unsupported` | Docker limits unsupported | Docker reports no CPU quota or memory limit support | Use a host whose cgroups enforce CPU and memory limits (cgroup v2) | +| `provider_unavailable` | Sandbox provider unavailable | The provider's service is unreachable or fails a request, such as a stopped Docker daemon, or a failure without a class | Rerun the node's command, or read a manually registered node's log | +| `host_unsupported` | Host unsupported | The host lacks a capability the provider requires, such as Docker CPU and memory limits (cgroup v2) or read-write access to `/dev/kvm` | Rerun the node's command, or read a manually registered node's log | | `capacity_insufficient` | Host too small | The host has fewer CPUs or less memory than one sandbox | Use a larger host, or change the sandbox size | -| `runtime_image_unavailable` | Runtime image missing | Docker does not have the pinned Runtime image | A node added with Web's command downloads it again by itself; otherwise load the image from the matching release | -| `kvm_unavailable` | KVM unavailable | The node can't open `/dev/kvm` for reading and writing | Enable hardware virtualization and give the node's user KVM access, through the `kvm` group | -| `microsandbox_artifacts_unavailable` | microsandbox components missing | The Runtime or firmware is missing or fails its SHA-256 check, or the helper is missing | A node added with Web's command downloads the missing files by itself; otherwise restore them from the matching release | +| `runtime_image_unavailable` | Runtime image missing | The provider does not have the pinned Runtime image | A node added with Web's command downloads it again by itself; otherwise load the image from the matching release | +| `artifacts_unavailable` | Provider files missing | A pinned provider file, such as the microsandbox Runtime, firmware or helper, is missing or fails its SHA-256 check | A node added with Web's command downloads the missing files by itself; otherwise restore them from the matching release | | `runtime_download_failed` | Runtime download failed | While preparing a new configuration, the node could not download or verify the Runtime files | Check the node's HTTPS access to the console and the release. The node retries with growing delays, up to 30 minutes apart | -| `provider_unavailable` | Sandbox provider unavailable | Any other failure | Read the node's log | A new group membership applies only to a new process. Restart the node service: `sudo systemctl restart oac-node-.service`. A node that is registered but never connects usually can't reach Core at the public URL, or its `/api/v1` WebSocket doesn't pass the reverse proxy. diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 45d88391a..b095c897e 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -16,7 +16,7 @@ Core owns durable Environment, allocation, placement and cleanup state; the Prov ## Steps 1. **Read the contract.** Implement every method, support the required operations and declare a decision for each of the others in [Implement the interface](#implement-the-interface). -2. **Write the adapter package** under `services/core/internal/sandbox/`: native SDK calls, ownership checks, identity translation and private configuration. Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`. An out-of-process helper lives in `services/core/tools/-provider`. +2. **Write the adapter package** under `services/core/internal/sandbox/`: native SDK calls, ownership checks, identity translation, private configuration and, for a node adapter, its node-local construction. Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`. An out-of-process helper lives in `services/core/tools/-provider`. 3. **Register the kind** once, following [Register the provider kind](#register-the-provider-kind). Registration is explicit construction, not an init-time plugin registry. 4. **Label owned resources** with the provider ownership labels in the [Runtime names](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#openagentcore-runtime-names) table, and never accept older label names as a fallback. 5. **Run the contract suite** with `make check-sandbox-provider-contract`; see [Validate the integration](#validate-the-integration). @@ -102,28 +102,29 @@ Hosted and self-hosted Environments use the same Runtime preparation; a provider ## Register the provider kind -`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), suspension defaults, the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. +`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), its `sandbox.DeploymentPolicy` (disk limits, the Runtime input and the default size setup proposes), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. A new provider takes these steps: 1. Implement the operation contracts in the adapter package, with native contract tests. 2. Add its specification and resource validators. -3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. -4. Register its constructor, policies, configuration adapter, operation declaration and defaults in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The installer's projection combines the registered policies with the shared field bounds in `sandbox/deployment_contract.go`; regenerate it with `go run ./services/core/cmd/specification-contract -write`. -5. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. +3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential is required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. +4. For a node adapter, export from its package the `BuildLocal` constructor, the typed `native` object it decodes and the native files it adds to the shared node artifacts. Node-local settings, such as host paths, live only in that object; resources and the Runtime release are read from the node configuration's `specification`. +5. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode and deployment policy with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads them from `deploy/node/node_spec.py`, and the TypeScript client and Web from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. +6. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. -**Known design gap:** Web's setup views carry provider-specific options, such as E2B's views. Exposing another provider through that surface currently requires a shared Web edit. This coupling does not meet [Complexity stays in the adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. +**Known design gap:** Web still names providers in the setup wizard's backend choice, the Docker confirmation and E2B's configuration fields wherever Web shows or parses them (the setup step with its service presets, the deployment summary and the client's deployment projection), because the protocol declares no configuration fields yet. Exposing another provider through that surface currently requires a shared Web edit. This coupling does not meet [Complexity stays in the adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. -`providers.Build` passes persisted node configuration and ephemeral `LocalOptions` to `BuildLocal`. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. +A node configuration, `sandbox.NodeConfig`, holds `provider`, `generation`, `installation_id`, `core_url`, `specification` and the adapter's opaque `native` object. `providers.Build` validates `provider`, `generation`, `installation_id` and `specification`, and passes the configuration with ephemeral `sandbox.LocalOptions` to `BuildLocal`, which decodes `native` strictly. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. ### Registration validation -`providers.ValidateRegistration` is the single wiring check. Lookup, constructor binding and the installer projection run it before any configuration callback or constructor. An unknown provider name stays invalid input; a malformed registration returns a safe `providercontract.ErrContract` that includes no submitted configuration or native diagnostics. +`providers.ValidateRegistration` is the single wiring check. Lookup, constructor binding and the generated projections run it before any configuration callback or constructor. An unknown provider name stays invalid input; a malformed registration returns a safe `providercontract.ErrContract` that includes no submitted configuration or native diagnostics. - A `nodes` registration has only `BuildLocal`, and a `direct` registration only `BuildDirect`; missing, mixed or unknown modes are rejected. -- The specification and resource validators, the configuration adapter and a complete operation declaration are mandatory, so an incomplete registration cannot publish a partial installer projection. +- The specification and resource validators, the configuration adapter and a complete operation declaration are mandatory, so an incomplete registration cannot publish a partial projection. A declared default size must pass the adapter's resource validator. - The Runtime input policy either accepts the pinned Runtime or gives the adapter's fixed reason for rejecting it, never both. -- Checkpoint is admitted only for a `nodes` registration, because the common lifecycle suspends only node allocations; registration rejects a `direct` Provider that declares it. Checkpoint support also requires positive idle and retention defaults that fit Runtime durations, and a provider without checkpoint support configures no suspension defaults. +- Checkpoint is admitted only for a `nodes` registration, because the common lifecycle suspends only node allocations; registration rejects a `direct` Provider that declares it. A registration carries no suspension values: Core applies its one [suspension policy](#suspension) to every Provider that declares checkpoint support. The configuration adapter must be non-nil, including its concrete value. Every `ConfigurationRequirements` field needs an explicit valid decision: `Credential` is `Required` or `NotRequired`, and `Discovery`, `SelectionDiscovery` and `CredentialVerification` use the shared supported or unsupported declaration with a safe reason. A new requirement field needs an explicit validation update and never inherits an existing decision. Requiring a credential does not promise the `VerifyCredential` operation. These checks establish complete registration, not correct native SDK behavior; constructor and adapter contract tests still apply. @@ -133,13 +134,13 @@ Preview and persistence use `providers.Normalize` and `providers.Describe`. `pro A direct adapter with a credential verifies all retained generations and allocation references before a key is replaced. The common `sandbox.CallFence` excludes native calls and waits for helper completion, including calls whose callers timed out; execution invokes the prepared verification and fencing callbacks without branching on a vendor. -Vendor deployment validation and SDK setup stay at the construction boundary, and construction never creates an Environment. For node-local adapters `providers.Built` returns the provider, probe, installation identity, backend fingerprint and specification digest, and a `Quiescent` check when a helper can outlive its caller; generation collection waits for it. The factory also returns its close function. `execution.RuntimeProvider` binds the adapter to its kind, installation ID, backend fingerprint, generation, mode and node ownership; the database owns the selection, and the in-memory copy is never another authority. Docker and microsandbox run on nodes, and E2B is constructed directly. The node proxy exposes checkpoint operations only for a backend whose registered declaration supports them, and common lifecycle code admits suspension through the checkpoint declaration, never through a provider name. +Vendor deployment validation and SDK setup stay at the construction boundary, and construction never creates an Environment. For node-local adapters `sandbox.Built` returns the provider, probe, installation identity, backend fingerprint and specification digest, and a `Quiescent` check when a helper can outlive its caller; generation collection waits for it. The factory also returns its close function. `execution.RuntimeProvider` binds the adapter to its kind, installation ID, backend fingerprint, generation, mode and node ownership; the database owns the selection, and the in-memory copy is never another authority. Docker and microsandbox run on nodes, and E2B is constructed directly. The node proxy exposes checkpoint operations only for a backend whose registered declaration supports them, and common lifecycle code admits suspension through the checkpoint declaration, never through a provider name. The backend fingerprint identifies a native resource namespace, not capacity. Core keeps deployment generations so that owned allocations keep resolving to their original backend; never repoint retained allocations at a replacement backend. ### Distribution artifacts and process paths -Each node adapter's registration owns its typed `NodeArtifacts` declaration: logical distribution path, release filename suffix and installation role (`node`, `runtime`, `policy` or `image`). Registration rejects missing declarations, unsafe paths and unknown roles. `go run ./services/core/cmd/provider-artifacts -write` generates the shared Web catalog and Python projection. Run the command without `-write` to check freshness. Distribution packaging, Web availability and node installation read this projection; adding a provider's payload does not add a provider-name branch to those consumers. +Each node adapter's registration owns its typed `NodeArtifacts` declaration, the shared node artifacts plus the native files its package exports: logical distribution path, release filename suffix and installation role (`node`, `runtime`, `policy` or `image`). Registration rejects missing declarations, unsafe paths and unknown roles. `go run ./services/core/cmd/provider-artifacts -write` generates the shared Web catalog and Python projection. Run the command without `-write` to check freshness. Distribution packaging, Web availability and node installation read this projection; adding a provider's payload does not add a provider-name branch to those consumers. The launcher supplies `sandbox.ProcessPaths` from the [derived process environment](./configuration.md). Core reads these paths once and passes them to direct construction and setup operations. They are fixed distribution properties, not deployment settings or user-selectable helper paths. Each adapter resolves its own relative helper and state locations; E2B uses `e2b/oac-e2b-provider` and `e2b/`. Missing or nonabsolute roots fail before helper execution. Provider construction and discovery never read process environment variables. @@ -185,7 +186,7 @@ The deployment's CPU, memory and disk settings, `max_active`, `max_retained` and ### Suspension -A provider with checkpoint support can suspend idle work; the deployment's [`suspension`](../contracts/agents-api/sandbox-deployment.md#safe-response) policy sets the idle time and snapshot retention. Core suspends only after at least one Turn is terminal, when no root or Subagent Turn is queued, in progress or waiting, no input, file operation or initialization is pending, and real activity has been idle for the configured interval. For node allocations Core records the first root or child terminal transition with the database clock in the same transaction. Candidate filtering and the Session-locked recheck compare elapsed database time with the idle duration, and the initial snapshot retention deadline is anchored to the same database observation, so Core and database host clocks need not agree. Native completion timestamps stay unchanged in public history but never drive idle admission, and heartbeats never reset activity. Before acknowledging a planned suspension, the daemon closes admission and drains native cleanup, output receipts and file work. +Core suspends the idle work of every provider that declares checkpoint support, with one fixed policy: it suspends work idle for 5 minutes (300 seconds) and keeps the snapshot for 24 hours (86400 seconds). The deployment's [`suspension`](../contracts/agents-api/sandbox-deployment.md#safe-response) reports these values. Core suspends only after at least one Turn is terminal, when no root or Subagent Turn is queued, in progress or waiting, no input, file operation or initialization is pending, and real activity has been idle for that time. For node allocations Core records the first root or child terminal transition with the database clock in the same transaction. Candidate filtering and the Session-locked recheck compare elapsed database time with the idle duration, and the initial snapshot retention deadline is anchored to the same database observation, so Core and database host clocks need not agree. Native completion timestamps stay unchanged in public history but never drive idle admission, and heartbeats never reset activity. Before acknowledging a planned suspension, the daemon closes admission and drains native cleanup, output receipts and file work. The Worker lease, the Session lock and the per-node gates own suspension for every provider. New Turn claims, file-write intents and capture admission serialize under the Session lock and share one compute-phase check; new pending work cancels a capture and wakes the same source. Normal preparation waits for the compute phase to be running, after the authenticated resume handshake, and pending input stays pending when its promotion conflicts with a lifecycle transition. Compute phases and revision-checked receipts live on the allocation. Core persists quiesce, capture and restore intent before the effect, only a fresh receipt performs a capture or restore, and recovery observes the exact attempt without retrying an unknown creation, capture or restore. A consumed snapshot never rolls a running generation back. Deletion, revocation and retention expiry win over wake, up to the final database compare-and-swap, and unknown cleanup identities are kept until owned resources are confirmed absent. Consumed artifacts and old compute are deleted, so suspension cycles never build a chain of writable disks. diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 6e2e99e3c..17e74d289 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -86,7 +86,7 @@ Summary figures are cumulative per Session and are not billing records. Sessions | Operation | Route | Console use | | --- | --- | --- | | List harnesses | `GET /core/v1/harnesses` | System's Default model cards: each harness's read-only `enabled` and `default`, its model configuration without the key, and Usage details from the configuration's `last_used_at`, `last_error_code` and `last_error_at`; the Overview's Getting started (a default model on the default harness, or on any enabled harness when none is default) | -| Set or replace | `PUT /core/v1/harnesses/{harness}/model-configuration` | **Set** or **Replace**: the complete model configuration with its write-only provider key, never prefilled and never retried; a 400 shows Core's message in the form, and a 503 `credential_storage_unavailable` says Core has no credential encryption key; then the list is read again | +| Set or replace | `PUT /core/v1/harnesses/{harness}/model-configuration` | **Set** or **Replace**: the complete model configuration with its write-only provider key, never prefilled and never retried; a 400 shows Core's message in the form; then the list is read again | | Clear | `DELETE /core/v1/harnesses/{harness}/model-configuration` | **Clear**, confirmed, then the list is read again | The list carries each harness's configuration, so the console does not read `GET /core/v1/harnesses/{harness}/model-configuration`. @@ -98,7 +98,7 @@ The list carries each harness's configuration, so the console does not read `GET | Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (any provider while `public_url` is loopback or not https) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in System leading to System, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows on System, the Sandbox configuration summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | | E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the templates the entered E2B key can see, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | | Reset | `POST`, `DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources, or cancel the remaining clear at the observed generation; show Core's remaining and offline projection | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Add node** follows only the node whose `enrollment_id` equals its command's | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (a [readiness code](../getting-started/nodes.md#readiness-codes); any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Add node** follows only the node whose `enrollment_id` equals its command's | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | | Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. Under microsandbox, a node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase and, while suspended, about when Core reclaims it (that time plus the deployment's `suspension.retention_seconds`); a null time shows a dash | | Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; root runs it directly. No ordinary-user installation or removal entry is exposed, and the log hint always names the system service. The command downloads the installer from the installation's `public_url`. No token is requested until the installation is read, when it cannot be read, when it is `local_only` (or its `public_url` is not an HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider. The dialog reads both again on opening and when the window regains focus | diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 1abf64824..16c9317b9 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 48e9776a3ac7b42b4b651304b8bf003d26c07745b239c1567ce5e0d7fb8f0540 +source_hash: 45dfb918d391bad141bed29775184c9eaaca35d73167605689772511a5b01de5 --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -49,7 +49,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | Variable | Default | Meaning | | --- | --- | --- | -| `OAC_PUBLIC_URL` | `http://localhost:8080`,由 `compose.yaml` 设置。未设置时启动的 Core 不运行 Runtime 网关,也不执行任何 Session | 应用、节点、沙箱和自托管执行器使用的源地址。参阅[更改公共 URL](#changing-the-public-url) | +| `OAC_PUBLIC_URL` | 必填;`compose.yaml` 设为 `http://localhost:8080` | 应用、节点、沙箱和自托管执行器使用的源地址。参阅[更改公共 URL](#changing-the-public-url) | | `OAC_HOST` | `127.0.0.1` | `compose.yaml` 发布的 Web 绑定地址。安装器设置为 `0.0.0.0` | | `OAC_WEB_PORT` | `8080` | Host port of Web | | `OAC_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` | @@ -85,7 +85,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | 设置 | Web 中的位置 | Core API | 注意事项 | | --- | --- | --- | --- | | 沙箱后端:Docker、microsandbox 或 E2B | **System** → **Manage sandbox configuration**:设置向导,最后点击 **Save configuration** | `/core/v1/sandbox/deployment` | 每个安装只能使用一个后端,在首次登录后选择。要改用其他后端,必须先执行 **Reset deployment**;请参阅[更改沙箱配置](getting-started/nodes.md#change-the-sandbox-configuration) | -| 沙箱大小、Runtime 发行版、E2B 密钥和模板构建 | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web 会在 [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json) 中推荐可用大小。现有沙箱会保留其大小和发行版。E2B 密钥仅可写入,并且已加密 | +| 沙箱大小、Runtime 发行版、E2B 密钥和模板构建 | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web 会推荐 [Provider 声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小和发行版。E2B 密钥仅可写入,并且已加密 | | 节点及其容量 | **Nodes**:**Add node**;在节点页面上使用 **Edit node** 和 **Remove node** | `/core/v1/sandbox/enrollment-tokens`、`/core/v1/sandbox/nodes` | 请参阅[节点容量](#node-capacity)和[节点指南](getting-started/nodes.md) | | 项目和 API 密钥 | **Projects and keys**:**Create project**、**Rename**、**Issue key**、**Revoke**、**Archive** | `/core/v1/projects` | 密钥只显示一次;Core 存储其摘要 | | 每个 Harness 的默认模型 | **System** → **Default model configuration**:**Set** | `/core/v1/harnesses/{harness}/model-configuration` | 请参阅[默认模型](#default-models) | @@ -126,11 +126,12 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 ## Docker 节点配置 {#docker-node-configuration} -节点安装程序会将 Docker 的提供商配置写入节点的配置文件。部署资源、Runtime 镜像和容量仍存储在 [Core 的数据库](#runtime-settings-web)中。 +节点安装程序会将 Docker 的主机设置写入节点配置文件的 `native` 对象,只有 Docker 适配器读取它。部署资源、Runtime 发行版本和容量仍存储在 [Core 的数据库](#runtime-settings-web)中。 | 字段 | 安装程序设置的值 | 含义 | | --- | --- | --- | | `host` | `unix:///var/run/docker.sock` | 显式 Docker Engine 套接字 | +| `image` | 加载后 Runtime 镜像的本地 ID | 发行版本的 `image_id` 或 `image_manifest_digest`。主机的镜像存储决定由哪个 digest 指代已加载的镜像,因此该值属于节点本地;适配器只接受这两个值 | | `network` | `oac-node-` | Runtime 容器网络 | | `seccomp_file` | `/runtime/seccomp.json` | 所匹配发行版的 seccomp 配置文件 | | `nested_sandbox` | `true` | 启用 Docker 适配器的 init 进程和 proc-mask 配置 | @@ -185,14 +186,14 @@ Core 读取进程环境。Compose 将 `.env` 插值到环境中,并把机密 | 变量 | 设置来源 | | --- | --- | -| `OAC_PUBLIC_URL` | [公共 URL](#settings)。Core 只校验一次,并从中派生 Agents API 基地址、守护进程 WebSocket URL、[沙箱 Link URL](#changing-the-public-url)、自托管 `remote_url`、安装程序下载地址、托管沙箱地址和部署的只读 `core_url`,绝不从请求标头派生 | +| `OAC_PUBLIC_URL` | 必填。[公共 URL](#settings)。Core 只校验一次,并从中派生 Agents API 基地址、守护进程 WebSocket URL、[沙箱 Link URL](#changing-the-public-url)、自托管 `remote_url`、安装程序下载地址、托管沙箱地址和部署的只读 `core_url`,绝不从请求标头派生 | | `OAC_ADDR` | 安装程序在容器中设置为 `:8091`。独立启动的 Core 在未设置或为空时,默认使用 `127.0.0.1:8091` | | `OAC_DATABASE_URL` | 必填。不含密码的 PostgreSQL URL | | `OAC_DATABASE_PASSWORD_FILE` | `/run/database/password`。此时 URL 不得包含密码 | -| `OAC_CREDENTIAL_KEY_FILE` | `/run/oac/credential.key` | +| `OAC_CREDENTIAL_KEY_FILE` | 必填。`/run/oac/credential.key`:Base64 编码的 32 字节随机密钥。Core 用它加密存储的凭据 | | `OAC_CORE_KEY_DIGESTS_FILE` | 必填。`/run/oac/core-key-digests.json`:一个包含 Core 密钥 SHA-256 的 JSON 数组 | -| `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`:安装 ID,采用规范 UUID 格式。它会启用沙箱部署和节点路由,并要求设置 `OAC_PUBLIC_URL`。如果 ID 与数据库记录的 ID 不一致,Core 会拒绝它 | -| `OAC_AGENT_HOST_IDENTITY_FILE` | `/run/agent-host/identity.json`:[agent host 的身份](#agent-host-container),其 `runtime_id` 为规范 UUID。设置 `OAC_PUBLIC_URL` 时必须设置,且只能与它一同设置。Core 启动时用该 ID 和凭据注册 agent host;新凭据会隔离旧凭据认证过的 Link,已吊销的 agent host 保持吊销 | +| `OAC_INSTALLATION_ID_FILE` | 必填。`/run/oac/installation.id`:安装 ID,采用规范 UUID 格式。如果 ID 与数据库记录的 ID 不一致,Core 会拒绝它 | +| `OAC_AGENT_HOST_IDENTITY_FILE` | 必填。`/run/agent-host/identity.json`:[agent host 的身份](#agent-host-container),其 `runtime_id` 为规范 UUID。Core 启动时用该 ID 和凭据注册 agent host;新凭据会隔离旧凭据认证过的 Link,已吊销的 agent host 保持吊销 | | `OAC_EXECUTION_CONCURRENCY`、`OAC_DEFAULT_HARNESS`、`OAC_HARNESSES`、`OAC_WRITE_AUDIT_RETENTION`、`OAC_OAUTH_TRUSTED_ORIGINS`、`OAC_HISTORY_SETTINGS_FILE`、`OAC_LOG_LEVEL`、`OAC_LOG_FORMAT`、`OAC_LOG_ADD_SOURCE` | 对应的[进程设置](#settings)。Web 也读取三个日志设置 | | `OAC_PROVIDER_ROOT` | 适配器构件的绝对根目录。Core 镜像设置为 `/opt/oac`。每个适配器都拥有此根目录下的辅助路径。当其中的 `native-installers/` 目录包含 `catalog.json` 时,Core 在核对该目录清单与自身发行版后提供自托管守护进程安装程序。适配器状态位于 `/state`,即数据卷的 [`state/`](#compose-installations) | diff --git a/docs/zh/getting-started/install-options.md b/docs/zh/getting-started/install-options.md index c4f9979ca..5999b779b 100644 --- a/docs/zh/getting-started/install-options.md +++ b/docs/zh/getting-started/install-options.md @@ -1,7 +1,7 @@ --- title: "安装选项" source: docs/getting-started/install-options.md -source_hash: acf47c13134900271924d6d26b62488998cd331df7ad1196f4ce940c0bf7457c +source_hash: a6920271d21280686ae8df889f8de499da4dc2572bc1a80d4a567e3861a1c520 --- [默认安装](install.md)无需任何选项。本页介绍安装选项、Compose 部署和反向代理配置。 @@ -68,7 +68,7 @@ docker compose exec web oac-web core-key ## 沙箱后端 {#sandbox-backend} -安装程序不保存沙箱后端。登录后,打开 **System** → **Manage sandbox configuration**,选择 Docker、microsandbox 或 E2B;Web 会按 [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/web/src/features/sandbox/standard-sizes.json) 推荐 Standard 尺寸。该选择保存在 Core 的数据库中。以后要更改,请[重置部署](nodes.md#change-the-sandbox-configuration)。Docker 沙箱与每个节点共用该节点的内核,其节点服务账户[等效于 root](nodes.md#what-the-installer-sets-up)。每种后端都需要一个非回环的公共 HTTPS URL,因为沙箱会从 Core 主机之外调用 Core。按照 [E2B 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md)准备模板。 +安装程序不保存沙箱后端。登录后,打开 **System** → **Manage sandbox configuration**,选择 Docker、microsandbox 或 E2B;Web 会把 [Provider 声明的默认大小](../sandbox-provider.md#register-the-provider-kind)推荐为 Standard 尺寸。该选择保存在 Core 的数据库中。以后要更改,请[重置部署](nodes.md#change-the-sandbox-configuration)。Docker 沙箱与每个节点共用该节点的内核,其节点服务账户[等效于 root](nodes.md#what-the-installer-sets-up)。每种后端都需要一个非回环的公共 HTTPS URL,因为沙箱会从 Core 主机之外调用 Core。按照 [E2B 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md)准备模板。 ## 监听器与访问 {#listeners-and-access} diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index ec1e69d0b..d0c39328b 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,7 +1,7 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: f5bec50bf81ebf1d08faaa54432da6a9c6e3ddbf88d93e33244276546c74aaab +source_hash: cd12954864bf9c15cf2d700fc3d9ebaf6126c0ab4297784850cfdd32b86ae656 --- 节点是一台 Linux 主机,在沙箱后端为 Docker 或 microsandbox 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 @@ -125,9 +125,9 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 1. 使用与 Core 同一发行版本的 `oac-node`。 2. 获取注册令牌:使用 **Add node** 命令中的令牌,或通过 Core 密钥调用 `POST /core/v1/sandbox/enrollment-tokens`。令牌一次性使用,包含批准的节点容量;响应的 `expires_at` 给出过期时间。在主机上存入权限为 `0600` 的文件。 3. 使用令牌读取节点配置,不会消耗令牌:`GET /api/v1/sandbox-node/configuration`,带 `Authorization: Bearer `。 -4. 写入私有提供商文件。从响应复制 `provider`、`installation_id`、`core_url`、`generation` 和 `specification`,并为主机添加一个适配器对象: - - `docker`:`host`(显式 Unix 套接字)、`image`(本地导入的批准发行版 Runtime 镜像)、`network`、`extra_hosts`、绝对路径 `seccomp_file` 和 `nested_sandbox`。 - - `microsandbox`:绝对路径 `helper_path`、`runtime_path` 和 `firmware_path`,以及对应的 `runtime_sha256` 和 `firmware_sha256`;`image`;沙箱的 `cpus`、`memory_mib`、`root_disk_mib` 和 `environment_disk_mib`;`network` 策略;以及 `runtime_home` 私有目录。目录缺失时辅助程序以 `0700` 创建。microsandbox 在其中放置 Unix 套接字,因此路径不要超过 48 字节;安装程序对自管节点拒绝更长路径。 +4. 写入私有提供商文件。从响应复制 `provider`、`installation_id`、`core_url`、`generation` 和 `specification`,并添加 `native` 对象,写入该提供商的主机设置。适配器从 `specification` 读取沙箱规格、Runtime 镜像和产物哈希: + - Docker:[Docker 节点配置](../configuration.md#docker-node-configuration)中的字段,其中 `host` 是显式 Unix 套接字,`image` 是导入的 Runtime 镜像的本地 ID,`seccomp_file` 是绝对路径。 + - microsandbox:绝对路径 `helper_path`、`runtime_path` 和 `firmware_path`;`network` 策略;以及 `runtime_home` 私有目录。目录缺失时辅助程序以 `0700` 创建。microsandbox 在其中放置 Unix 套接字,因此路径不要超过 48 字节;安装程序对自管节点拒绝更长路径。 5. 使用真实绝对路径注册,然后通过主机服务管理器运行节点: ```sh @@ -159,20 +159,18 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 - **使用 Web 命令添加的节点**:当前沙箱配置检查失败时,在目标状态显示 **Preparation failed**,帮助提示中给出原因(`GET /core/v1/sandbox/nodes` 的 `rollout.diagnostic`)。**Provider not ready** 旁的提示仅显示 *Sandbox provider unavailable*。 - **手动注册的节点**:在 **Provider not ready** 旁的帮助提示展示原因(`diagnostic`)。 -节点日志包含状态码背后的本地错误。 +每个状态码都是与 Provider 无关的类别;其背后的本地错误留在节点上。手动注册的节点会把它写入日志。对于使用 Web 命令添加的节点,请在主机上重新运行该命令:安装程序会先检查主机要求,并指出需要修复的问题([安装程序消息](#installer-messages))。 节点按如下顺序仅报告首个失败检查:Docker 守护进程或 KVM、Docker 限制支持、主机容量、已安装的 Runtime 文件。因此无法访问 Docker 守护进程时,会隐藏镜像缺失问题。修复后约十秒的下一次心跳会清除或替换状态码。离线节点保留最后状态码,Web 在节点重连前隐藏它。 | 状态码 | 帮助提示 | 原因 | 解决方法 | | --- | --- | --- | --- | -| `docker_unavailable` | Docker unavailable | Docker 套接字不可达、无权访问,或 Docker info/镜像请求失败 | 启动 Docker 并赋予节点用户访问 `/var/run/docker.sock` 的权限 | -| `docker_limits_unsupported` | Docker limits unsupported | Docker 报告不支持 CPU 配额或内存限制 | 使用 cgroups 强制执行 CPU 与内存限制的主机(cgroup v2) | +| `provider_unavailable` | Sandbox provider unavailable | 提供商的服务不可达或请求失败(例如 Docker 守护进程已停止),或失败没有类别 | 重新运行节点的命令,或阅读手动注册节点的日志 | +| `host_unsupported` | Host unsupported | 主机缺少提供商所需的能力,例如 Docker 的 CPU 与内存限制(cgroup v2)或对 `/dev/kvm` 的读写权限 | 重新运行节点的命令,或阅读手动注册节点的日志 | | `capacity_insufficient` | Host too small | 主机 CPU 或内存不足以运行一个沙箱 | 使用更大主机或修改沙箱规格 | -| `runtime_image_unavailable` | Runtime image missing | Docker 中没有固定版本的 Runtime 镜像 | Web 命令添加的节点自动重新下载;其他节点加载匹配发行版镜像 | -| `kvm_unavailable` | KVM unavailable | 节点无法读写 `/dev/kvm` | 启用硬件虚拟化,通过 `kvm` 组赋予节点用户 KVM 访问权限 | -| `microsandbox_artifacts_unavailable` | microsandbox components missing | Runtime 或固件缺失、SHA-256 检查失败,或辅助程序缺失 | Web 命令添加的节点自动下载缺失文件;其他节点从匹配发行版恢复 | +| `runtime_image_unavailable` | Runtime image missing | 提供商中没有固定版本的 Runtime 镜像 | Web 命令添加的节点自动重新下载;其他节点加载匹配发行版镜像 | +| `artifacts_unavailable` | Provider files missing | 固定版本的提供商文件(例如 microsandbox 的 Runtime、固件或辅助程序)缺失或 SHA-256 检查失败 | Web 命令添加的节点自动下载缺失文件;其他节点从匹配发行版恢复 | | `runtime_download_failed` | Runtime download failed | 准备新配置时无法下载或验证 Runtime 文件 | 检查节点到控制台和发行下载地址的 HTTPS 访问。节点以递增间隔重试,最长间隔 30 分钟 | -| `provider_unavailable` | Sandbox provider unavailable | 其他失败 | 阅读节点日志 | 新用户组成员关系仅对新进程生效。重启节点服务:`sudo systemctl restart oac-node-.service`。已注册但从未连接的节点通常无法通过公开 URL 访问 Core,或 `/api/v1` WebSocket 无法通过反向代理。 diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 1dfaad5dc..231b42e58 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "添加 Sandbox Provider" source: docs/sandbox-provider.md -source_hash: 33b301d5eff40dcd9e830c854b5dcd5221e632203161d8dd381eabe3005f32fb +source_hash: b6c2a9956d4b4060be3745c1340ece507d03a2b401daa52926555aec84ac9e65 --- **Sandbox Provider** 为 Core 管理的 Environment 提供 Runtime daemon 运行所需的外层计算资源,以及启动 daemon 的有界引导流程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -18,7 +18,7 @@ Core 拥有持久 Environment、allocation、placement 和 cleanup 状态;Prov ## 步骤 {#steps} 1. **阅读契约。** 实现每个方法,支持必需操作,并按[实现接口](#implement-the-interface)对其余每项操作声明决定。 -2. **编写 adapter 包**,放在 `services/core/internal/sandbox/`:包括原生 SDK 调用、所有权检查、身份转换和私有配置。声明 `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`。进程外 helper 放在 `services/core/tools/-provider`。 +2. **编写 adapter 包**,放在 `services/core/internal/sandbox/`:包括原生 SDK 调用、所有权检查、身份转换、私有配置,以及 node adapter 的 node-local 构造。声明 `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`。进程外 helper 放在 `services/core/tools/-provider`。 3. **注册 kind** 一次,遵循[注册 provider kind](#register-the-provider-kind)。注册是明确构造,不是 init 时 plugin registry。 4. **标记所属资源**,使用 [Runtime 名称](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#openagentcore-runtime-names)表中的 provider ownership label,不接受旧 label 名称作为回退。 5. **运行契约套件** `make check-sandbox-provider-contract`;参见[验证集成](#validate-the-integration)。 @@ -104,28 +104,29 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` ## 注册 provider kind {#register-the-provider-kind} -`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、suspension 默认值、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 +`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、`sandbox.DeploymentPolicy`(磁盘限制、Runtime 输入,以及 setup 推荐的默认大小)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 新 provider 执行以下步骤: 1. 在 adapter 包中实现 operation 契约,并编写原生契约测试。 2. 添加 specification 和 resource validator。 -3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node provider 仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 -4. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter、operation 声明和默认值。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。installer 投影组合已注册 policy 与 `sandbox/deployment_contract.go` 中的共享 field bound;通过 `go run ./services/core/cmd/specification-contract -write` 重新生成。 -5. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。 +3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node provider 仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 +4. node adapter 从自己的包中导出 `BuildLocal` constructor、它解码的类型化 `native` 对象,以及它在共享 node artifact 之外添加的原生文件。node-local 设置(如主机路径)只存放在该对象中;resources 和 Runtime release 从 node 配置的 `specification` 读取。 +5. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式和 deployment policy 与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取,因此 Web 读取这些声明,而不比较 provider kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 +6. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。 -**已知设计缺口:** Web 的 setup view 携带 provider 专有选项,如 E2B 的 view。通过该界面提供另一 provider 目前需要修改共享的 Web。此耦合不符合[复杂性留在 adapter 内](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter);新集成必须通过协议表达配置,把厂商专有行为留在 adapter。不得添加 Session 或 Turn 调度路径、厂商专有 column 或 API field,或 store 中的厂商 switch。 +**已知设计缺口:** Web 仍在 setup 向导的后端选择、Docker 确认,以及所有显示或解析 E2B 配置字段的地方(带服务预设的 setup 步骤、部署摘要和客户端的部署投影)中指名 provider,因为协议尚未声明配置字段。通过该界面提供另一 provider 目前需要修改共享的 Web。此耦合不符合[复杂性留在 adapter 内](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter);新集成必须通过协议表达配置,把厂商专有行为留在 adapter。不得添加 Session 或 Turn 调度路径、厂商专有 column 或 API field,或 store 中的厂商 switch。 -`providers.Build` 将持久化 node 配置与临时 `LocalOptions` 传给 `BuildLocal`。调用方通过 `Standalone` 明确选择独立注册或单 provider 执行,或通过 `GenerationStateDirectory` 中的规范绝对 node state directory 选择 generation 拥有的执行。context 缺失或混合时拒绝。adapter 负责 generation 特有的原生 preparation 和 readiness 检查。Microsandbox 将 helper lease 绑定到安装实例、generation 和 specification digest,然后在平台、容量和 artifact readiness 后检查固定 image。 +node 配置 `sandbox.NodeConfig` 包含 `provider`、`generation`、`installation_id`、`core_url`、`specification`,以及 adapter 的不透明 `native` 对象。`providers.Build` 验证 `provider`、`generation`、`installation_id` 和 `specification`,并将配置与临时 `sandbox.LocalOptions` 传给 `BuildLocal`,由它严格解码 `native`。调用方通过 `Standalone` 明确选择独立注册或单 provider 执行,或通过 `GenerationStateDirectory` 中的规范绝对 node state directory 选择 generation 拥有的执行。context 缺失或混合时拒绝。adapter 负责 generation 特有的原生 preparation 和 readiness 检查。Microsandbox 将 helper lease 绑定到安装实例、generation 和 specification digest,然后在平台、容量和 artifact readiness 后检查固定 image。 ### 注册验证 {#registration-validation} -`providers.ValidateRegistration` 是唯一 wiring 检查。lookup、constructor binding 和 installer projection 在任何 configuration callback 或 constructor 前运行它。未知 provider name 保持为无效输入;格式错误的注册返回安全 `providercontract.ErrContract`,不包含提交的配置或原生诊断。 +`providers.ValidateRegistration` 是唯一 wiring 检查。lookup、constructor binding 和生成的投影在任何 configuration callback 或 constructor 前运行它。未知 provider name 保持为无效输入;格式错误的注册返回安全 `providercontract.ErrContract`,不包含提交的配置或原生诊断。 - `nodes` 注册仅有 `BuildLocal`,`direct` 注册仅有 `BuildDirect`;缺失、混合或未知 mode 被拒绝。 -- specification 和 resource validator、configuration adapter 与完整 operation 声明都是必需项,因此不完整注册不能发布部分 installer projection。 +- specification 和 resource validator、configuration adapter 与完整 operation 声明都是必需项,因此不完整注册不能发布部分投影。声明的默认大小必须通过 adapter 的 resource validator。 - Runtime input policy 要么接受固定 Runtime,要么给出 adapter 拒绝它的固定原因,不能两者兼有。 -- Checkpoint 仅准入 `nodes` 注册,因为公共 lifecycle 只暂停 node allocation;声明 checkpoint 的 `direct` Provider 会被注册拒绝。Checkpoint 支持还要求适合 Runtime duration 的正 idle、retention 默认值,不支持 checkpoint 的 provider 不配置 suspension 默认值。 +- Checkpoint 仅准入 `nodes` 注册,因为公共 lifecycle 只暂停 node allocation;声明 checkpoint 的 `direct` Provider 会被注册拒绝。注册不携带 suspension 数值:Core 对每个声明 checkpoint 支持的 Provider 应用同一个 [suspension policy](#suspension)。 configuration adapter 必须非 nil,包括其具体值。每个 `ConfigurationRequirements` 字段都需要明确有效的决定:`Credential` 为 `Required` 或 `NotRequired`,`Discovery`、`SelectionDiscovery` 和 `CredentialVerification` 使用共享 supported 或 unsupported 声明并携带安全 reason。新增 requirement field 需要明确更新验证,不继承已有决定。要求凭据不承诺支持 `VerifyCredential` 操作。这些检查证明注册完整,不证明原生 SDK 行为正确;constructor 和 adapter 契约测试仍然适用。 @@ -135,13 +136,13 @@ configuration adapter 必须非 nil,包括其具体值。每个 `Configuration 具有凭据的 direct adapter 在替换 key 前验证全部保留 generation 与 allocation reference。公共 `sandbox.CallFence` 排除原生调用并等待 helper 完成,包括调用方已超时的调用;execution 调用已准备的 verification 和 fencing callback,不按厂商分支。 -厂商部署验证和 SDK setup 留在构造边界,构造不创建 Environment。node-local adapter 的 `providers.Built` 返回 provider、probe、installation identity、backend fingerprint 和 specification digest;helper 可能比调用方存活更久时,还返回 `Quiescent` 检查,generation 回收会等待它。factory 还返回 close 函数。`execution.RuntimeProvider` 将 adapter 绑定到 kind、installation ID、backend fingerprint、generation、mode 和 node ownership;选择由数据库负责,内存副本不构成另一权限来源。Docker 与 microsandbox 在 node 上运行,E2B 直接构造。node proxy 仅对注册声明支持 checkpoint 的 backend 暴露 checkpoint 操作,公共 lifecycle 通过 checkpoint 声明准入 suspension,不通过 provider name。 +厂商部署验证和 SDK setup 留在构造边界,构造不创建 Environment。node-local adapter 的 `sandbox.Built` 返回 provider、probe、installation identity、backend fingerprint 和 specification digest;helper 可能比调用方存活更久时,还返回 `Quiescent` 检查,generation 回收会等待它。factory 还返回 close 函数。`execution.RuntimeProvider` 将 adapter 绑定到 kind、installation ID、backend fingerprint、generation、mode 和 node ownership;选择由数据库负责,内存副本不构成另一权限来源。Docker 与 microsandbox 在 node 上运行,E2B 直接构造。node proxy 仅对注册声明支持 checkpoint 的 backend 暴露 checkpoint 操作,公共 lifecycle 通过 checkpoint 声明准入 suspension,不通过 provider name。 backend fingerprint 标识原生资源命名空间,不表示容量。Core 保留部署 generation,使所属 allocation 继续解析到原 backend;不要将保留 allocation 重新指向替代 backend。 ### 发行产物与进程路径 {#distribution-artifacts-and-process-paths} -每个 node adapter 注册负责其类型化 `NodeArtifacts` 声明:逻辑发行路径、release filename suffix 和安装角色(`node`、`runtime`、`policy` 或 `image`)。注册拒绝缺失声明、不安全路径和未知角色。`go run ./services/core/cmd/provider-artifacts -write` 生成共享 Web catalog 和 Python projection。不带 `-write` 运行可检查是否最新。发行打包、Web availability 和 node 安装读取此投影;添加 provider payload 不在这些消费者中增加 provider-name 分支。 +每个 node adapter 注册负责其类型化 `NodeArtifacts` 声明,即共享 node artifact 加上其包导出的原生文件:逻辑发行路径、release filename suffix 和安装角色(`node`、`runtime`、`policy` 或 `image`)。注册拒绝缺失声明、不安全路径和未知角色。`go run ./services/core/cmd/provider-artifacts -write` 生成共享 Web catalog 和 Python projection。不带 `-write` 运行可检查是否最新。发行打包、Web availability 和 node 安装读取此投影;添加 provider payload 不在这些消费者中增加 provider-name 分支。 launcher 从[派生进程环境](configuration.md)提供 `sandbox.ProcessPaths`。Core 读取这些路径一次,并传给 direct construction 和 setup 操作。它们是固定发行属性,不是部署设置或用户可选 helper 路径。每个 adapter 解析自己的相对 helper 和 state 位置;E2B 使用 `e2b/oac-e2b-provider` 和 `e2b/`。root 缺失或不是绝对路径时,在执行 helper 前失败。Provider 构造与发现不读取进程环境变量。 @@ -187,7 +188,7 @@ placement 自动完成:environment-to-node placement 与 Session 创建及其 ### 暂停 {#suspension} -支持 checkpoint 的 provider 可以暂停空闲工作;部署 [`suspension`](../../contracts/agents-api/zh/sandbox-deployment.md#safe-response) policy 设置 idle time 和 snapshot retention。Core 仅在至少一个 Turn 已终结、没有 root 或 Subagent Turn 排队、进行中或等待、没有 pending input、file operation 或 initialization,且真实 activity 已空闲达到配置间隔后暂停。对于 node allocation,Core 在同一事务中用数据库时钟记录首个 root 或 child terminal transition。candidate filter 和 Session-locked recheck 比较数据库已过时间与 idle duration,初始 snapshot retention deadline 也锚定同一数据库观测,因此 Core 与数据库主机时钟无需一致。原生 completion timestamp 在公开历史中保持不变,但不驱动 idle admission,heartbeat 不重置 activity。确认计划暂停前,daemon 关闭 admission 并排空 native cleanup、output receipt 和 file work。 +Core 用同一个固定 policy 暂停每个声明 checkpoint 支持的 provider 的空闲工作:工作空闲 5 分钟(300 秒)后暂停,snapshot 保留 24 小时(86400 秒)。部署的 [`suspension`](../../contracts/agents-api/zh/sandbox-deployment.md#safe-response) 报告这两个值。Core 仅在至少一个 Turn 已终结、没有 root 或 Subagent Turn 排队、进行中或等待、没有 pending input、file operation 或 initialization,且真实 activity 已空闲达到该时间后暂停。对于 node allocation,Core 在同一事务中用数据库时钟记录首个 root 或 child terminal transition。candidate filter 和 Session-locked recheck 比较数据库已过时间与 idle duration,初始 snapshot retention deadline 也锚定同一数据库观测,因此 Core 与数据库主机时钟无需一致。原生 completion timestamp 在公开历史中保持不变,但不驱动 idle admission,heartbeat 不重置 activity。确认计划暂停前,daemon 关闭 admission 并排空 native cleanup、output receipt 和 file work。 Worker lease、Session lock 与 per-node gate 对每个 provider 负责 suspension。新 Turn claim、file-write intent 和 capture admission 在 Session lock 下串行化,共享一个 compute-phase 检查;新 pending work 取消 capture 并唤醒同一 source。正常 preparation 在经过认证的 resume handshake 后等待 compute phase 为 running;pending input 的 promotion 与 lifecycle transition 冲突时保持 pending。compute phase 和 revision-checked receipt 位于 allocation。Core 在 effect 前持久化 quiesce、capture 和 restore intent,仅新 receipt 执行 capture 或 restore,恢复观察精确 attempt,不重试未知 creation、capture 或 restore。已消费 snapshot 不让 running generation 回滚。删除、撤销和 retention expiry 优先于 wake,一直持续到最终数据库 compare-and-swap;未知 cleanup identity 保留,直到确认所属资源不存在。已消费 artifact 和旧 compute 被删除,因此暂停循环不累积可写磁盘链。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index fb9912ddd..fcf17a36c 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 40d24367c1c88fe0e6deba2408dc6d1bd9bbd2a320e20bf3156946f91dee7c3d +source_hash: cb3dcd402e9c0b9e9f7ac5cd6d7f7311191f4d4d0b30e41ce6197ce8d5d82996 --- 本页列出各控制台页面读取和写入的 Core 路由,以及控制台如何限定读取范围。[administrator API contract](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API namespaces and credentials](../api/index.md) 定义了本文使用的术语。 @@ -88,7 +88,7 @@ source_hash: 40d24367c1c88fe0e6deba2408dc6d1bd9bbd2a320e20bf3156946f91dee7c3d | 操作 | 路由 | 控制台用途 | | --- | --- | --- | | 列出 Harnesses | `GET /core/v1/harnesses` | System 的 Default model 卡片:每个 Harness 的只读 `enabled` 和 `default`、不含密钥的模型配置,以及来自配置中 `last_used_at`、`last_error_code` 和 `last_error_at` 的 Usage details;Overview 的 Getting started(默认 Harness 上的默认模型;如果没有默认模型,则为任意已启用 Harness 上的默认模型) | -| 设置或替换 | `PUT /core/v1/harnesses/{harness}/model-configuration` | **Set** 或 **Replace**:提交包含只写提供商密钥的完整模型配置;该密钥绝不预填,写入也绝不重试;400 会在表单中显示 Core 的消息,503 `credential_storage_unavailable` 表示 Core 没有凭据加密密钥;随后再次读取列表 | +| 设置或替换 | `PUT /core/v1/harnesses/{harness}/model-configuration` | **Set** 或 **Replace**:提交包含只写提供商密钥的完整模型配置;该密钥绝不预填,写入也绝不重试;400 会在表单中显示 Core 的消息;随后再次读取列表 | | 清除 | `DELETE /core/v1/harnesses/{harness}/model-configuration` | **Clear**,需确认,随后再次读取列表 | 列表会返回每个 Harness 的配置,因此控制台不会读取 `GET /core/v1/harnesses/{harness}/model-configuration`。 @@ -100,7 +100,7 @@ source_hash: 40d24367c1c88fe0e6deba2408dc6d1bd9bbd2a320e20bf3156946f91dee7c3d | 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(`public_url` 为回环地址或不是 https 时的任何提供商)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 Managed in System 前往 System,且无需确认;使用 `resources` 以及 Docker 或 microsandbox 的 `runtime` release 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。E2B 的 `metadata.template_build`(状态、CPU、内存、磁盘)会显示在 System、Sandbox 配置摘要和 Sandbox metrics 中;当缺少 `specification.resources` 时,它还会确定每个 Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 System 和 Nodes 摘要中 | | E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导先列出输入的 E2B 密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | | 重置 | `POST`、`DELETE /core/v1/sandbox/deployment/reset` | 显式清除托管资源,或在观测到的 generation 处取消剩余清除;显示 Core 的剩余资源和离线预测 | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes 页面;Overview 上的机群;Sandbox metrics 中的节点容量。在线节点的 `diagnostic`(`docker_unavailable`、`docker_limits_unsupported`、`runtime_image_unavailable`、`kvm_unavailable`、`microsandbox_artifacts_unavailable`、`capacity_insufficient`、`provider_unavailable`;任何其他值均读取为 `provider_unavailable`)会将其标记为降级,并在上述每个页面及节点页面中,紧邻状态的帮助提示里说明原因和修复方法。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,Nodes 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 Old address,而不是健康状态。**Add node** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes 页面;Overview 上的机群;Sandbox metrics 中的节点容量。在线节点的 `diagnostic`(一个[就绪状态码](../getting-started/nodes.md#readiness-codes);任何其他值均读取为 `provider_unavailable`)会将其标记为降级,并在上述每个页面及节点页面中,紧邻状态的帮助提示里说明原因和修复方法。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,Nodes 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 Old address,而不是健康状态。**Add node** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | | 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics 节点对话框:主机自最近一次心跳以来的 CPU 忙碌占比和内存使用量,以及页面所选范围内二者的历史记录。**Edit node** 读取 `host.effective_cpu_cores` 和 `host.total_memory_bytes`,用于在每个 Sandbox 大小旁显示主机容量,以及最多可容纳多少个该大小的 Sandbox | | 分配 | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes 页面;Sandbox metrics。在 microsandbox 下,节点页面根据 `compute_phase_changed_at` 显示每个分配处于计算阶段的时间,并在分配暂停时估算 Core 回收它的时间(该时间加上部署的 `suspension.retention_seconds`);时间为 null 时显示短横线 | | 注册 | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**:管理员先设置节点的 Sandbox 限制(`max_active`;`max_retained` 仅适用于 microsandbox,在 Docker 下等于 `max_active`),然后 Core 才会把一次性令牌放入命令中;该命令会验证安装程序校验和,并包含命令的 `enrollment_id`,节点注册时会报告此 ID。命令使用 sudo 运行安装程序(作为系统服务),并通过标准输入传递令牌;以 root 运行时则直接执行。界面不提供普通用户安装或移除入口,日志提示始终指明系统服务。命令从安装的 `public_url` 下载安装程序。只有成功读取安装信息后才会请求令牌;如果安装信息无法读取、安装为 `local_only`(或其 `public_url` 不是 HTTPS 来源),或者 `/console/config` 列出的 `node_artifacts` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取这两项信息 | diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index eae192ecb..a5b93df83 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -331,7 +331,7 @@ describe("AdminClient installation", () => { }; it("reads installation facts before any deployment and rejects inconsistent snapshots", async () => { expect(await clientWith(installation).client.retrieveInstallation()).toEqual(installation); - expect(await clientWith({ ...installation, installation_id: null, public_url: null, api_base_url: null, source_commit: null }).client.retrieveInstallation()).toMatchObject({ public_url: null }); + expect(await clientWith({ ...installation, source_commit: null }).client.retrieveInstallation()).toMatchObject({ source_commit: null }); const configuration = (settings: unknown[]) => ({ ...installation, configuration: { settings } }); for (const invalid of [ configuration([port, { ...headers, value: { authorization: "leak" } }]), @@ -340,6 +340,8 @@ describe("AdminClient installation", () => { { ...installation, address_bindings: { ...installation.address_bindings, nodes_on_other_address: 3 } }, { ...installation, token: "leak" }, { ...installation, configuration: null }, + { ...installation, installation_id: null }, + { ...installation, public_url: null, api_base_url: null }, configuration([{ ...port, configured: true }]), ]) { await expect(clientWith(invalid).client.retrieveInstallation()).rejects.toMatchObject({ code: "invalid_admin_response" }); diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 206502a28..28051cae1 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -269,8 +269,8 @@ function projectInstallationSetting(value: unknown): CoreInstallationSetting { export function projectInstallation(value: unknown): CoreInstallation { const installation = record(value, ["object", "installation_id", "public_url", "api_base_url", "local_only", "source_commit", "configuration", "address_bindings"]); const origin = installation.public_url; - if (installation.object !== "core.installation" || (installation.installation_id !== null && canonicalUuid(installation.installation_id) === null) || - (origin !== null && typeof origin !== "string") || installation.api_base_url !== (typeof origin === "string" ? `${origin}/v1` : null) || + if (installation.object !== "core.installation" || canonicalUuid(installation.installation_id) === null || + typeof origin !== "string" || installation.api_base_url !== `${origin}/v1` || typeof installation.local_only !== "boolean" || (installation.source_commit !== null && (typeof installation.source_commit !== "string" || !/^[0-9a-f]{40}$/.test(installation.source_commit)))) return invalidAdminResponse(); const bindings = record(installation.address_bindings, ["nodes", "nodes_on_other_address", "hosted_sandboxes", "self_hosted_executors"]); diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 6949f9c4c..815592d83 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -194,11 +194,11 @@ export interface CoreInstallationConfiguration { /** `GET /core/v1/installation`: available before any sandbox deployment exists. */ export interface CoreInstallation { object: "core.installation"; - installation_id: string | null; - /** The origin applications, nodes, sandboxes and self-hosted executors use; null when Core runs without one. */ - public_url: string | null; + installation_id: string; + /** The origin applications, nodes, sandboxes and self-hosted executors use. */ + public_url: string; /** `public_url` followed by `/v1`; the base URL for application API keys. */ - api_base_url: string | null; + api_base_url: string; /** True when `public_url` is a loopback origin that only the Core host reaches. */ local_only: boolean; /** Full source commit Core was built from; null for development builds. */ diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 77ccf9d18..1106413a9 100644 --- a/packages/agents-client/src/deployment-contract.ts +++ b/packages/agents-client/src/deployment-contract.ts @@ -1,3 +1,3 @@ // Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT. -export const deploymentContract = {"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"minimum_disk":1024} as const; +export const deploymentContract = {"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"providers":{"docker":{"mode":"nodes","disk":false,"runtime":true,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"runtime":false,"default_resources":null},"microsandbox":{"mode":"nodes","disk":true,"runtime":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}}},"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 52fca2bf7..5070a2c95 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -18,7 +18,7 @@ const node = { }; /** Never heard from, enrolled before Core recorded enrollment IDs, with a fixed readiness code. */ const unready = { - ...node, rollout: { state: "unknown", ready_generation: 1 }, id: "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", online: false, provider_ready: false, diagnostic: "kvm_unavailable", + ...node, rollout: { state: "unknown", ready_generation: 1 }, id: "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", online: false, provider_ready: false, diagnostic: "host_unsupported", cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, last_seen_at: null, active: 0, retained: 0, enrollment_id: null, }; const detail = { @@ -156,8 +156,8 @@ describe("Core sandbox credential boundaries", () => { it("returns the node's fixed readiness diagnostic unchanged and reads an unknown code as provider_unavailable", async () => { const fetch = vi.fn().mockResolvedValue(response({ data: [unready, { ...unready, diagnostic: "future_code" }, node] })); const { data } = await new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", fetch }).listNodes(); - expect(data.map((entry) => entry.diagnostic)).toEqual(["kvm_unavailable", "provider_unavailable", undefined]); - expectTypeOf().toEqualTypeOf(); + expect(data.map((entry) => entry.diagnostic)).toEqual(["host_unsupported", "provider_unavailable", undefined]); + expectTypeOf().toEqualTypeOf(); }); it("requires each node's enrollment ID: a string, or null for nodes enrolled before Core recorded it", async () => { const fetch = vi.fn().mockResolvedValue(response({ data: [node, unready] })); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 962fa54ac..3b63b6cb8 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -8,12 +8,10 @@ export type SandboxDiagnostic = "" | "node_unavailable" | "resource_missing" | " /** Checked against Core's shared node-diagnostics.json fixture. */ export const sandboxNodeDiagnostics = [ "provider_unavailable", - "docker_unavailable", - "docker_limits_unsupported", + "host_unsupported", + "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", - "kvm_unavailable", - "microsandbox_artifacts_unavailable", "capacity_insufficient", ] as const; /** Fixed reason a node's provider is not ready. Core omits the field while the provider is ready, so read it as falsy (undefined) then. The client reads an unknown future value as provider_unavailable. */ @@ -25,7 +23,8 @@ export function normalizeSandboxNodeDiagnostic(value: string): Exclude : "provider_unavailable"; } -export type SandboxProvider = "docker" | "microsandbox" | "e2b"; +/** A registered Provider kind; deploymentContract.providers holds each one's declaration. */ +export type SandboxProvider = keyof typeof deploymentContract.providers; /** Client-generated, never a Core code: a deployment write whose rejection could echo the key and is withheld. */ export const sandboxConfigurationUnconfirmed = "sandbox_configuration_unconfirmed"; const sandboxConfigurationParams = new Set(["runtime", ...deploymentContract.resources.map(({ name }) => `resources.${name}`)]); @@ -88,7 +87,7 @@ export interface SandboxDeployment { configuration?: { template?: string; api_url?: string; domain?: string }; metadata?: { template_build?: SandboxE2BTemplateBuild }; credential_configured: boolean; - /** Idle suspension policy; microsandbox only, otherwise null. */ + /** Idle suspension policy; null unless the Provider declares checkpoint support. */ suspension: { idle_seconds: number; retention_seconds: number } | null; } /** The fixed E2B build as Core read it when the selection was saved; unknown values are null. */ @@ -198,7 +197,6 @@ const measure = (value: unknown) => typeof value === "number" && Number.isFinite const nullable = (test: (value: unknown) => boolean) => (value: unknown) => value === null || test(value); const strings = (value: Record, fields: readonly string[]) => fields.every((field) => typeof value[field] === "string"); -const providers = new Set(["", "docker", "microsandbox", "e2b"]); const modes = new Set(["", "nodes", "direct"]); const releaseFields = ["source_commit", "image_id", "image_manifest_digest", "microsandbox_ref", "runtime_sha256", "firmware_sha256"]; function projectSpecification(value: unknown): SandboxSpecification { @@ -264,7 +262,7 @@ function projectDeployment(value: unknown): SandboxDeployment { const resources = members(deployment.resources, ["allocations", "pending"]); const suspension = deployment.suspension === null ? null : members(deployment.suspension, ["idle_seconds", "retention_seconds"]); const configured = hasOwn(deployment, "specification"); - valid(strings(deployment, ["installation_id", "core_url"]) && providers.has(deployment.provider as string) && modes.has(deployment.mode as string) && + valid(strings(deployment, ["installation_id", "core_url"]) && (deployment.provider === "" || (typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider))) && modes.has(deployment.mode as string) && [deployment.owner_epoch, deployment.generation, resources.allocations, resources.pending].every(isNonnegativeInteger) && (suspension === null || [suspension.idle_seconds, suspension.retention_seconds].every(isNonnegativeInteger)) && configured === hasOwn(deployment, "specification_digest") && (!configured || (typeof deployment.specification_digest === "string" && deployment.specification_digest !== ""))); diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index ea78315ab..5cf6c8771 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -6,15 +6,15 @@ These are the code-level rules of `services/core` that no contract states. Contr The domain packages own their vocabulary, pure rules and use cases: the domain owners listed below, `items`, `adminaudit`, `writeaudit`, and the shared vocabulary packages `environmentconfig`, `metadata` and `jsonobject`. No `internal` package except `persistence` and `db` imports an `internal/persistence`, `internal/db` or pgx package: they reach storage only through interfaces that the PostgreSQL adapters under `internal/persistence/postgres` implement and the commands under `cmd/` wire in. `TestLayering` in `cmd/server` checks these imports. -`api.NewHandler` takes one `api.Dependencies` value, built only in `cmd/server`. Each application area is one field typed as an interface declared in `api` beside its handlers, listing exactly the methods they call. Every field is required and `NewHandler` rejects a missing one, except the optional groups whose comments say what nil means: `Execution` is nil without an execution Worker, `Sandboxes` is nil without a managed sandbox installation and requires `Execution`, and `Execution.NativeInstaller` is nil for a build without a source revision. Handlers never discover a capability by type assertion or fall back to another implementation. API tests use one strict fake per area, `fake`, which fails the test on any call the test did not set. +`api.NewHandler` takes one `api.Dependencies` value, built only in `cmd/server`. Each application area is one field typed as an interface declared in `api` beside its handlers, listing exactly the methods they call. Every field is required and `NewHandler` rejects a missing one, except `Execution.NativeInstaller`, which is nil for a build without a source revision. Handlers never discover a capability by type assertion or fall back to another implementation. API tests use one strict fake per area, `fake`, which fails the test on any call the test did not set. `internal/persistence/postgres/pgunit` owns the PostgreSQL mechanics that adapters share: pooled read-write and snapshot transactions; pool-bound queries, used only for a single-statement read that needs no transaction, such as the per-request key lookup; the execution lease (its dedicated connection and gate, the ownership check, the cancellation fence, close, and the execution deadline); identifier parsing (`ParseID`, `PathID`, `LookupCursor`); and detection of text PostgreSQL cannot store (`IsUnstorableText`). Persistence code runs every transaction through it. Outside `persistence`, only the commands under `cmd/`, which build the adapters' pool, and test fixtures import it; `cmd/server` also acquires the lease. `internal/persistence/postgres/pgtest` is test support: it opens the dedicated test database under the `oac_*_tests` guard, applies the migrations, and creates isolated databases for database-wide state such as the execution lease. Only test files import it. `internal/persistence/postgres/auditpg` is the one adapter that other adapters call directly. Audit rows are written inside the business transaction, so each adapter calls `RecordWriteAudit`, `RecordAdminMutation` or `RecordDeploymentMutation` with its own transaction's queries; the audit provenance travels in the context. `writeaudit` and `adminaudit` own the sources, their validation, `ErrInvalidSource` and the read models, and `auditpg.Store` serves the audit reads. -The adapter that stores a secret seals and opens it with the credential key `cmd/server` builds it with: domain storage interfaces carry plaintext, domain service constructors never take the key, a missing key is `credentialcrypto.ErrUnavailable`, and a ciphertext that fails to open or authenticate is an internal error, never a missing key, value or row. +The adapter that stores a secret seals and opens it with the credential key `cmd/server` builds it with: domain storage interfaces carry plaintext, domain service constructors never take the key, and a ciphertext that fails to open or authenticate is an internal error, never a missing key, value or row. -Two errors are shared across domains, each with one `api` helper: `textvalue.ErrUnstorable` (400, `writeTextValueError`) for text PostgreSQL cannot store, and `credentialcrypto.ErrUnavailable` (503, `writeCredentialUnavailableError`) for a missing credential key. The audit `ErrInvalidSource` errors pass through adapters unchanged, and `writeAuditSourceError` maps both to 400. Each handler maps its operation's errors through that domain's mapper; Session operations that also meet the sandbox deployment, namely creation, input admission, archive and Runtime observation, map through `writeOperationError`, which tries `writeSandboxError` and then `writeSessionsError`. +One error is shared across domains, with one `api` helper: `textvalue.ErrUnstorable` (400, `writeTextValueError`) for text PostgreSQL cannot store. The audit `ErrInvalidSource` errors pass through adapters unchanged, and `writeAuditSourceError` maps both to 400. Each handler maps its operation's errors through that domain's mapper; Session operations that also meet the sandbox deployment, namely creation, input admission, archive and Runtime observation, map through `writeOperationError`, which tries `writeSandboxError` and then `writeSessionsError`. Shared vocabulary has one owner each, and domains use it rather than copy it. `internal/environmentconfig` owns Environment setup, Skills, Plugins and initial files with their validation and public metadata; `Setup.Validate` checks requested configuration, where a Skill may be an unresolved reference, and `Setup.ValidateInstalled` checks frozen, installable configuration. `internal/skills` owns `ParseVersion`, the canonical positive decimal Skill version. `internal/metadata` owns the metadata rules: `Validate` for the pair, key and value limits and U+0000, `ValidateStorable` for U+0000 alone, and `Encode` with its 64 KiB bound. `internal/jsonobject` owns `Normalize`, the stable encoding of stored JSON objects that snapshots and retry identities compare. @@ -133,7 +133,7 @@ Provider input validation uses the adapter rules in `internal/harnessconfig`: on [Vaults and credentials](../../contracts/agents-api/vaults.md) describes the resources, selection rules, refresh and deletion. `vaults` implements them, with `vaultpg` as its storage, under these rules: - Credentials are children of tenant-owned Vaults. Creation admits the owner in the same SQL statement as the insert; retrieval joins the owning Vault; listing enforces Project and Vault ownership on the parent, cursor and row query. Metadata queries never select ciphertext and need no encryption key. -- `vaultpg` seals secret values before they reach SQL, with Core's separately configured random 32-byte key and the standard library's random-nonce AES-GCM. The versioned authenticated binding covers tenant, Vault, Credential, authentication purpose and exact destination. Never reuse daemon transport encryption for this storage. A missing key disables credential writes with `credentialcrypto.ErrUnavailable`; a malformed configured key fails startup. +- `vaultpg` seals secret values before they reach SQL, with Core's separately configured random 32-byte key and the standard library's random-nonce AES-GCM. The versioned authenticated binding covers tenant, Vault, Credential, authentication purpose and exact destination. Never reuse daemon transport encryption for this storage. A missing, unreadable or malformed key fails startup. - A static replacement is one SQL mutation scoped by tenant, Vault, Credential, auth type and destination, reusing the safe metadata for the immutable binding; it never decrypts the previous token, and a failed write keeps the old row. - OAuth refresh and replacement serialize on the Credential row lock, authenticate the stored grant metadata against its encrypted copy before using an endpoint, and persist the refreshed grant before returning an access token, so a stale refresh cannot undo a deletion or Vault cascade. - Credential deletion is one mutation checked by tenant, Vault and ID; Vault deletion removes the parent and its Credentials through the foreign-key cascade in one SQL statement, without decrypting, needing the key or calling providers. @@ -183,7 +183,7 @@ Item merging never mutates the incoming observation or the previous snapshot: pu ## Worker ownership -Enabling the daemon gateway with `OAC_PUBLIC_URL` also starts the execution Worker. One Worker owns an execution database through a `pgunit.Lease`: the PostgreSQL advisory lock held by one dedicated connection. A second Core on the same database cannot acquire the lease and starts no Worker. The Worker's execution writer runs every Session transaction on that connection: binding, claim and reconciliation, journal, Items and usage, function callbacks and receipts, and terminal state. The lease gate serializes these short transactions and the ownership pings, and `pgunit.ExecutionTimeout` (five seconds) bounds each one, including its gate and Session-lock waits. Cancelling an in-flight pgx operation can close the connection that owns the lock, so coordinator-owned work is cancelled only through the lease's cancellation fence, between leased operations. Never hold a transaction across daemon or model work, reconnect the writer or fall back to the pool after losing the lease. Public admission and device maintenance use pooled connections, and pooled reads grant no write authority. Transactions state their isolation: read committed for writes, read-only repeatable read for snapshots. +Core always starts the execution Worker. One Worker owns an execution database through a `pgunit.Lease`: the PostgreSQL advisory lock held by one dedicated connection. A second Core on the same database cannot acquire the lease and fails to start with `pgunit.ErrLeaseHeld`. The Worker's execution writer runs every Session transaction on that connection: binding, claim and reconciliation, journal, Items and usage, function callbacks and receipts, and terminal state. The lease gate serializes these short transactions and the ownership pings, and `pgunit.ExecutionTimeout` (five seconds) bounds each one, including its gate and Session-lock waits. Cancelling an in-flight pgx operation can close the connection that owns the lock, so coordinator-owned work is cancelled only through the lease's cancellation fence, between leased operations. Never hold a transaction across daemon or model work, reconnect the writer or fall back to the pool after losing the lease. Public admission and device maintenance use pooled connections, and pooled reads grant no write authority. Transactions state their isolation: read committed for writes, read-only repeatable read for snapshots. Sandbox reset snapshots bind the deployment relation explicitly to its single row before joining resources, so that even on a fresh database without statistics an inflated join estimate cannot trigger JIT compilation inside the lease deadline. diff --git a/services/core/README.md b/services/core/README.md index 02a1551b7..486030ff7 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -22,20 +22,24 @@ Core uses its own PostgreSQL database and account and shares no tables with an a 1. Create a development database. Core applies the migrations when it starts. -2. Create a Core key of at least 32 characters and a digest file holding its SHA-256, which Core uses to authenticate `/core/v1`, and the agent-host identity Core registers: +2. Create a Core key of at least 32 characters and a digest file holding its SHA-256, which Core uses to authenticate `/core/v1`, the credential key that seals stored credentials, the installation ID, and the agent-host identity Core registers. Keep the credential key and the ID with the database: ```sh umask 077; mkdir -p ~/.oac/dev openssl rand -hex 32 > ~/.oac/dev/core.key printf '["%s"]\n' "$(tr -d '\n' < ~/.oac/dev/core.key | sha256sum | cut -d' ' -f1)" > ~/.oac/dev/core-key-digests.json + openssl rand -base64 32 > ~/.oac/dev/credential.key + uuidgen | tr '[:upper:]' '[:lower:]' > ~/.oac/dev/installation.id python3 -c 'import json, secrets, uuid; print(json.dumps({"runtime_id": str(uuid.uuid4()), "credential": secrets.token_urlsafe(32)}))' > ~/.oac/dev/agent-host.json ``` -3. Start Core. `OAC_PUBLIC_URL` enables the Runtime gateway and the Worker, and requires the agent-host identity; without it Core executes nothing. The [Core environment table](../../docs/configuration.md#appendix-core-environment-without-the-installer) lists every variable. +3. Start Core. The [Core environment table](../../docs/configuration.md#appendix-core-environment-without-the-installer) lists every variable. ```sh OAC_DATABASE_URL='postgres://oac:…@127.0.0.1:5432/oac_dev' \ OAC_CORE_KEY_DIGESTS_FILE="$HOME/.oac/dev/core-key-digests.json" \ + OAC_CREDENTIAL_KEY_FILE="$HOME/.oac/dev/credential.key" \ + OAC_INSTALLATION_ID_FILE="$HOME/.oac/dev/installation.id" \ OAC_PUBLIC_URL=http://127.0.0.1:8091 \ OAC_AGENT_HOST_IDENTITY_FILE="$HOME/.oac/dev/agent-host.json" \ go run ./services/core/cmd/server diff --git a/services/core/cmd/sandbox-node/generations.go b/services/core/cmd/sandbox-node/generations.go index 78f41af37..e6b88a62c 100644 --- a/services/core/cmd/sandbox-node/generations.go +++ b/services/core/cmd/sandbox-node/generations.go @@ -47,7 +47,7 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf values := map[uint64]node.GenerationProvider{} recovery := []sandbox.GenerationReference{} collection := []sandbox.GenerationReference{} - seen := map[uint64]providerconfig.Config{} + seen := map[uint64]sandbox.NodeConfig{} closeValues := func() { for _, v := range values { if v.Close != nil { @@ -56,7 +56,7 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf } } for _, path := range paths { - var config providerconfig.Config + var config sandbox.NodeConfig if strings.HasSuffix(path, ".preparing") { journal, readErr := readGenerationJournal(path) err = readErr @@ -166,8 +166,8 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: stateDir, Identity: stored.Identity, Credential: stored.Credential, Generations: manager}) } -func buildGeneration(registry *providerconfig.Registry, config providerconfig.Config, stateDir string) (node.GenerationProvider, error) { - built, closeProvider, err := registry.Build(config, providerconfig.LocalOptions{GenerationStateDirectory: stateDir}) +func buildGeneration(registry *providerconfig.Registry, config sandbox.NodeConfig, stateDir string) (node.GenerationProvider, error) { + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: stateDir}) if err != nil { return node.GenerationProvider{}, err } @@ -175,12 +175,12 @@ func buildGeneration(registry *providerconfig.Registry, config providerconfig.Co } type generationJournal struct { - InstallationID string `json:"installation_id"` - Generation uint64 `json:"generation"` - SpecificationDigest string `json:"specification_digest"` - NativeComplete json.RawMessage `json:"native_complete,omitempty"` - ImportStarted json.RawMessage `json:"import_started,omitempty"` - Configuration *providerconfig.Config `json:"configuration,omitempty"` + InstallationID string `json:"installation_id"` + Generation uint64 `json:"generation"` + SpecificationDigest string `json:"specification_digest"` + NativeComplete json.RawMessage `json:"native_complete,omitempty"` + ImportStarted json.RawMessage `json:"import_started,omitempty"` + Configuration *sandbox.NodeConfig `json:"configuration,omitempty"` } func readGenerationJournal(path string) (generationJournal, error) { @@ -211,23 +211,17 @@ func readGenerationJournal(path string) (generationJournal, error) { } // The preparation configuration is an immutable plan, never a usable provider. -// Only Docker's two specification-proven local IDs can differ at publication. -func sameGenerationPlan(final, plan providerconfig.Config) bool { - if plan.Docker != nil && final.Docker != nil { - runtime := plan.Specification.Runtime - if final.Docker.Image != runtime.ImageID && final.Docker.Image != runtime.ImageManifestDigest { - return false - } - copyDocker := *plan.Docker - copyDocker.Image = final.Docker.Image - plan.Docker = ©Docker - } +// A generation's Core-visible envelope is fixed; native is the adapter's +// node-local state, which preparation may resolve and the adapter validates +// when it builds. +func sameGenerationPlan(final, plan sandbox.NodeConfig) bool { + final.Native, plan.Native = nil, nil return reflect.DeepEqual(final, plan) } // Pending-only entries stay recovery/collection-only. No journal is readiness // or authority to collect without a fresh current-connection Core grant. -func generationLocalState(config providerconfig.Config, stateDir string) (string, error) { +func generationLocalState(config sandbox.NodeConfig, stateDir string) (string, error) { directory := filepath.Join(stateDir, "generations") info, err := os.Lstat(directory) if os.IsNotExist(err) { diff --git a/services/core/cmd/sandbox-node/generations_test.go b/services/core/cmd/sandbox-node/generations_test.go index 56a015270..ae08f7ad0 100644 --- a/services/core/cmd/sandbox-node/generations_test.go +++ b/services/core/cmd/sandbox-node/generations_test.go @@ -7,15 +7,15 @@ import ( "os" "os/exec" "path/filepath" + "strings" "syscall" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - providerconfig "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" ) func TestGenerationJournalRestartIdentity(t *testing.T) { - config := providerconfig.Config{InstallationID: "installation", Generation: 9, Provider: "docker"} + config := sandbox.NodeConfig{InstallationID: "installation", Generation: 9, Provider: "docker"} stateDir := t.TempDir() directory := filepath.Join(stateDir, "generations") if err := os.Mkdir(directory, 0700); err != nil { @@ -118,7 +118,7 @@ func TestGenerationHelperUsesOnlyTypedExit(t *testing.T) { } func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { - config := providerconfig.Config{InstallationID: "installation", Generation: 2, Provider: "docker"} + config := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "docker"} stateDir := t.TempDir() directory := filepath.Join(stateDir, "generations") if err := os.Mkdir(directory, 0700); err != nil { @@ -144,6 +144,22 @@ func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { } } +// Preparation may resolve native state, such as the digest a containerd image +// store names the loaded Runtime image by; the envelope stays fixed. +func TestGenerationPlanFixesOnlyTheEnvelope(t *testing.T) { + release := sandbox.RuntimeRelease{ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64)} + plan := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "docker", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &release}, Native: json.RawMessage(`{"image":"` + release.ImageID + `"}`)} + final := plan + final.Native = json.RawMessage(`{"image":"` + release.ImageManifestDigest + `"}`) + if !sameGenerationPlan(final, plan) { + t.Fatal("refused a resolved native image") + } + final.Specification.Resources.CPUs = 4 + if sameGenerationPlan(final, plan) { + t.Fatal("accepted a different specification") + } +} + func TestGenerationStateDirectoryOwnership(t *testing.T) { for _, mode := range []string{"private", "public", "symlink", "symlink_parent"} { t.Run(mode, func(t *testing.T) { @@ -170,7 +186,7 @@ func TestGenerationStateDirectoryOwnership(t *testing.T) { } stateDir = link } - _, err := generationLocalState(providerconfig.Config{Generation: 1}, stateDir) + _, err := generationLocalState(sandbox.NodeConfig{Generation: 1}, stateDir) if mode == "private" && err != nil || mode != "private" && !errors.Is(err, sandbox.ErrOwnership) { t.Fatalf("directory ownership: %v", err) } diff --git a/services/core/cmd/sandbox-node/main.go b/services/core/cmd/sandbox-node/main.go index 11657f156..1cef71944 100644 --- a/services/core/cmd/sandbox-node/main.go +++ b/services/core/cmd/sandbox-node/main.go @@ -81,7 +81,7 @@ func run(ctx context.Context, args []string) error { if err != nil { return err } - built, closeProvider, err := registry.Build(config, providerconfig.LocalOptions{Standalone: true}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{Standalone: true}) if err != nil { return err } diff --git a/services/core/cmd/server/core_metrics.go b/services/core/cmd/server/core_metrics.go index 52f81462a..74b92a0cb 100644 --- a/services/core/cmd/server/core_metrics.go +++ b/services/core/cmd/server/core_metrics.go @@ -26,16 +26,11 @@ type coreMetricsSource struct { func metricPtr[T any](value T) *T { return &value } func (s *coreMetricsSource) Live() coremetrics.Live { stat := s.pool.Stat() - live := coremetrics.Live{Pool: coremetrics.Pool{InUse: metricPtr(int64(stat.AcquiredConns())), Idle: metricPtr(int64(stat.IdleConns())), Max: metricPtr(int64(stat.MaxConns()))}, Scheduler: coremetrics.Job{ID: "scheduler", Status: "stopped"}, ExecutionOwner: metricPtr(false), SlotsTotal: metricPtr(int64(0)), SlotsInUse: metricPtr(int64(0))} - if s.registry != nil { - live.ConnectedDaemons = metricPtr(int64(len(s.registry.Devices()))) - } - if s.worker != nil { - w := s.worker.MetricsSnapshot() - live.SlotsInUse, live.SlotsTotal, live.ExecutionOwner = w.SlotsInUse, w.SlotsTotal, w.ExecutionOwner - live.Scheduler = coremetrics.Job{ID: "scheduler", Status: w.Scheduler.Status, LastRunAt: w.Scheduler.LastRunAt, Processed: w.Scheduler.Processed, Failed: w.Scheduler.Failed} - } - return live + w := s.worker.MetricsSnapshot() + return coremetrics.Live{Pool: coremetrics.Pool{InUse: metricPtr(int64(stat.AcquiredConns())), Idle: metricPtr(int64(stat.IdleConns())), Max: metricPtr(int64(stat.MaxConns()))}, + Scheduler: coremetrics.Job{ID: "scheduler", Status: w.Scheduler.Status, LastRunAt: w.Scheduler.LastRunAt, Processed: w.Scheduler.Processed, Failed: w.Scheduler.Failed}, + ExecutionOwner: w.ExecutionOwner, SlotsTotal: w.SlotsTotal, SlotsInUse: w.SlotsInUse, + ConnectedDaemons: metricPtr(int64(len(s.registry.Devices())))} } func (s *coreMetricsSource) Sample(ctx context.Context) coremetrics.Sample { sample := coremetrics.Sample{Healthy: true, PoolInUse: metricPtr(int64(s.pool.Stat().AcquiredConns()))} @@ -48,19 +43,12 @@ func (s *coreMetricsSource) Sample(ctx context.Context) coremetrics.Sample { } else { sample.Healthy = false } - devices := []string{} - if s.registry != nil { - devices = s.registry.Devices() - } - counts, err := s.store.ReadExecutionSnapshot(ctx, time.Now(), devices) + counts, err := s.store.ReadExecutionSnapshot(ctx, time.Now(), s.registry.Devices()) if err != nil { sample.Healthy = false } else { sample.Queued, sample.InProgress = metricPtr(counts.QueuedTurns), metricPtr(counts.InProgressTurns) - sample.OldestQueuedSeconds = counts.OldestQueuedSeconds - if s.registry != nil { - sample.WaitingForDaemon = metricPtr(counts.WaitingForDaemon) - } + sample.OldestQueuedSeconds, sample.WaitingForDaemon = counts.OldestQueuedSeconds, metricPtr(counts.WaitingForDaemon) } size, err := s.store.ReadDatabaseSize(ctx) if err != nil { diff --git a/services/core/cmd/server/executor_connections.go b/services/core/cmd/server/executor_connections.go index bd38a0dcc..45e480e7d 100644 --- a/services/core/cmd/server/executor_connections.go +++ b/services/core/cmd/server/executor_connections.go @@ -9,7 +9,6 @@ import ( ) // executorConnections observes enrolled executors through the Runtime gateway. -// registry is nil when this Core has no gateway; no executor is then connected. type executorConnections struct { sessions sessions.Reader registry *runtimegateway.Registry diff --git a/services/core/cmd/server/http_routes.go b/services/core/cmd/server/http_routes.go index 536bdd549..3154facff 100644 --- a/services/core/cmd/server/http_routes.go +++ b/services/core/cmd/server/http_routes.go @@ -19,9 +19,6 @@ type daemonRoutes struct { // the exact daemon prefix /api/v1/agent-daemon to /api/v1/agent-daemon/. The API // handler canonicalizes again when served alone; the operation is idempotent. func serverHandler(apiHandler http.Handler, daemon *daemonRoutes) http.Handler { - if daemon == nil { - return apiHandler - } mux := http.NewServeMux() mux.Handle("/api/v1/agent-daemon/", daemon.gateway) mux.Handle("/api/v1/agent-daemon/enroll", daemon.enrollment) @@ -29,9 +26,7 @@ func serverHandler(apiHandler http.Handler, daemon *daemonRoutes) http.Handler { mux.Handle("/api/v1/agent-daemon/install/", apiHandler) mux.Handle("/api/v1/agent-daemon/installation", apiHandler) mux.Handle("/api/v1/agent-daemon/installation/", apiHandler) - if daemon.nodeConnect != nil { - mux.Handle("/api/v1/sandbox-node/connect", daemon.nodeConnect) - } + mux.Handle("/api/v1/sandbox-node/connect", daemon.nodeConnect) mux.Handle("/", apiHandler) return api.CanonicalPaths(mux) } diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index f6375996e..1b52b8e5e 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -108,7 +108,7 @@ func daemonComposition(t testing.TB) http.Handler { SessionAdmin: struct{ api.SessionAdmin }{}, Environments: struct{ api.Environments }{}, EnvironmentsReader: struct{ api.EnvironmentsReader }{}, ExecutorConnections: struct{ api.ExecutorConnections }{}, Admin: struct{ api.Admin }{}, AdminAudit: struct{ api.AdminAudit }{}, WriteAudit: struct{ api.WriteAudit }{}, Metrics: struct{ api.Metrics }{}, RuntimeObservations: struct{ api.RuntimeObservations }{}, RuntimeHistory: struct{ api.RuntimeHistory }{}, - Execution: &api.Execution{ + Execution: api.Execution{ ExecutorURL: "wss://core.example/api/v1/agent-daemon/ws", SessionAdmission: struct{ api.SessionAdmission }{}, InputAdmission: struct{ api.InputAdmission }{}, @@ -116,7 +116,7 @@ func daemonComposition(t testing.TB) http.Handler { Workspaces: struct{ api.EnvironmentWorkspaces }{}, Links: struct{ http.Handler }{}, }, - Sandboxes: &api.Sandboxes{Deployment: struct{ api.Deployment }{}, NodeAllocations: unusedNodeAllocations{}, DeploymentChanges: struct{ api.DeploymentChanges }{}, + Sandboxes: api.Sandboxes{Deployment: struct{ api.Deployment }{}, NodeAllocations: unusedNodeAllocations{}, DeploymentChanges: struct{ api.DeploymentChanges }{}, DeploymentReset: struct{ api.DeploymentReset }{}, ConfigurationDiscovery: struct{ api.ConfigurationDiscovery }{}}, }) if err != nil { diff --git a/services/core/cmd/server/installation.go b/services/core/cmd/server/installation.go index 11097db7f..1bab2cae5 100644 --- a/services/core/cmd/server/installation.go +++ b/services/core/cmd/server/installation.go @@ -13,14 +13,9 @@ var sourceCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) // installationFacts reports what GET /core/v1/installation serves: Core's own // environment and build, plus the process settings it loaded. func installationFacts(config processconfig.Config) api.Installation { - facts := api.Installation{Configuration: api.InstallationConfiguration{Settings: config.Settings()}} - if config.InstallationID != "" { - facts.InstallationID = &config.InstallationID - } - if origin := config.PublicOrigin; origin != nil { - public, base := origin.String(), origin.API() - facts.PublicURL, facts.APIBaseURL, facts.LocalOnly = &public, &base, placement.LoopbackOrigin(public) - } + public := config.PublicOrigin.String() + facts := api.Installation{InstallationID: config.InstallationID, PublicURL: public, APIBaseURL: config.PublicOrigin.API(), LocalOnly: placement.LoopbackOrigin(public), + Configuration: api.InstallationConfiguration{Settings: config.Settings()}} if sourceCommit.MatchString(buildRevision) { revision := buildRevision facts.SourceCommit = &revision diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index de6439df0..db2bfe025 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -151,13 +151,9 @@ func run(config processconfig.Config) error { return err } sandboxProviders := providers.Builtin() - var public string - if config.PublicOrigin != nil { - public = config.PublicOrigin.String() - } // The placement rules are built once: the provider declarations and the // public URL never change while Core runs. - placementRules, err := placement.NewRules(sandboxProviders, public) + placementRules, err := placement.NewRules(sandboxProviders, config.PublicOrigin.String()) if err != nil { return err } @@ -171,26 +167,18 @@ func run(config processconfig.Config) error { if err != nil { return err } - var workerDone chan error + // Node callbacks run only once the HTTP server serves, after the Worker starts. var worker *execution.Worker managedNodes := configureManagedNodes(deploymentService, deploymentStore, sandboxProviders, config, func(ctx context.Context) error { - if worker == nil { - return errors.New("sandbox execution owner is unavailable") - } return worker.CheckOwnership(ctx) }) - defer managedNodes.close() - var managed *execution.RuntimeProvider - var observationSource func(context.Context) (runtimeobs.Source, string, error) - if managedNodes != nil { - managed = managedNodes.runtime - observationSource = managedNodes.setup.observationSource - } + defer managedNodes.hub.Close() + observationSource := managedNodes.setup.observationSource observationResolver, err := deployment.NewObservationResolver(sessionStore, deploymentStore) if err != nil { return err } - history, err := runtimeHistory(ctx, units, config.RuntimeHistory, config.PublicOrigin != nil) + history, err := runtimeHistory(ctx, units, config.RuntimeHistory) if err != nil { return err } @@ -218,98 +206,86 @@ func run(config processconfig.Config) error { if err != nil { return err } - var daemonHandler http.Handler - var registry *runtimegateway.Registry - var linkRelay *relay.Relay - var executorURL string - var nativeInstaller *api.NativeInstaller - if origin := config.PublicOrigin; origin != nil { - if err := sessionStore.RegisterAgentHost(ctx, config.AgentHostID, config.AgentHostCredentialHash); err != nil { - return fmt.Errorf("agent host registration failed: %w", err) - } - executorURL = origin.DaemonWebSocket() - links := runtimegateway.NewLinkAuthority(sessionStore) - daemonHandler, registry, err = runtime.NewGateway(sessionStore, sessionService, sessionStore, links, executorURL) - if err != nil { - return err - } - defer runtime.CloseConnections(registry) - linkRelay = relay.New(links) - defer linkRelay.Close() - var catalog *nativeinstaller.Catalog - if config.NativeInstallers != "" { - catalog, err = nativeinstaller.Load(config.NativeInstallers, buildRevision) - if err != nil { - return err - } - } - if buildRevision != "" { - nativeInstaller = &api.NativeInstaller{Version: buildRevision, Base: origin.InstallerBase(), Catalog: catalog} - } + if err := sessionStore.RegisterAgentHost(ctx, config.AgentHostID, config.AgentHostCredentialHash); err != nil { + return fmt.Errorf("agent host registration failed: %w", err) + } + executorURL := config.PublicOrigin.DaemonWebSocket() + links := runtimegateway.NewLinkAuthority(sessionStore) + daemonHandler, registry, err := runtime.NewGateway(sessionStore, sessionService, sessionStore, links, executorURL) + if err != nil { + return err } - var deploymentExecution *deployment.ExecutionOperations - if registry != nil { - dispatcher := &execution.Dispatcher{Registry: registry, - Credentials: vaultService, Observer: modelConfigurationStore, Deployment: deploymentService, DeploymentReader: deploymentStore, - Sessions: sessionService, - SessionsReader: sessionStore, - Links: linkRelay, - ManagedRuntimes: managed, MaxConcurrentExecutions: config.ExecutionConcurrency} - lease, err := pgunit.AcquireLease(ctx, pool) + defer runtime.CloseConnections(registry) + linkRelay := relay.New(links) + defer linkRelay.Close() + var catalog *nativeinstaller.Catalog + if config.NativeInstallers != "" { + catalog, err = nativeinstaller.Load(config.NativeInstallers, buildRevision) if err != nil { return err } - deploymentExecution, err = deployment.NewExecutionOperations(deploymentService, deploymentpg.NewExecution(lease, credentialKey)) - if err != nil { - return errors.Join(err, lease.Close(ctx)) - } - sessionExecution, err := sessions.NewExecutionOperations(sessionpg.NewExecution(lease)) - if err != nil { - return errors.Join(err, lease.Close(ctx)) - } - // From this call on the Worker closes the lease, even when it fails to start. - worker, err = execution.StartWorker(ctx, dispatcher, execution.Owner{ - Lease: lease, - Deployment: deploymentExecution, - Sessions: sessionExecution, - }) - if err != nil { - return err + } + var nativeInstaller *api.NativeInstaller + if buildRevision != "" { + nativeInstaller = &api.NativeInstaller{Version: buildRevision, Base: config.PublicOrigin.InstallerBase(), Catalog: catalog} + } + dispatcher := &execution.Dispatcher{Registry: registry, + Credentials: vaultService, Observer: modelConfigurationStore, Deployment: deploymentService, DeploymentReader: deploymentStore, + Sessions: sessionService, + SessionsReader: sessionStore, + Links: linkRelay, + ManagedRuntimes: managedNodes.runtime, MaxConcurrentExecutions: config.ExecutionConcurrency} + lease, err := pgunit.AcquireLease(ctx, pool) + if err != nil { + return err + } + deploymentExecution, err := deployment.NewExecutionOperations(deploymentService, deploymentpg.NewExecution(lease, credentialKey)) + if err != nil { + return errors.Join(err, lease.Close(ctx)) + } + sessionExecution, err := sessions.NewExecutionOperations(sessionpg.NewExecution(lease)) + if err != nil { + return errors.Join(err, lease.Close(ctx)) + } + // From this call on the Worker closes the lease, even when it fails to start. + worker, err = execution.StartWorker(ctx, dispatcher, execution.Owner{ + Lease: lease, + Deployment: deploymentExecution, + Sessions: sessionExecution, + }) + if err != nil { + return err + } + workerDone := make(chan error, 1) + go func() { workerDone <- worker.Run(ctx) }() + defer func() { + stop() + if workerDone != nil { + <-workerDone } - workerDone = make(chan error, 1) - go func() { workerDone <- worker.Run(ctx) }() - defer func() { - stop() - if workerDone != nil { - <-workerDone - } - }() - } - // Sampling runs only with the Worker, which owns every sweep. - sampling := coremetrics.Periodic{ID: "runtime_sampler", Every: history.SampleInterval} - if worker != nil { - sampler, err := runtimeobs.NewSampler(observationResolver, observationService, worker, runtimeobs.SamplerOptions{}) - if err != nil { - return err + }() + // The Worker owns every sampling sweep. + sampler, err := runtimeobs.NewSampler(observationResolver, observationService, worker, runtimeobs.SamplerOptions{}) + if err != nil { + return err + } + sampling := coremetrics.Periodic{ID: "runtime_sampler", Every: history.SampleInterval, Run: func(ctx context.Context) (*int64, int64, error) { + result := sampler.Sweep(ctx) + sampleCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + err := worker.CheckOwnership(sampleCtx) + if err == nil { + _, err = deploymentStore.SampleHostHistory(sampleCtx) } - sampling.Run = func(ctx context.Context) (*int64, int64, error) { - result := sampler.Sweep(ctx) - sampleCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - err := worker.CheckOwnership(sampleCtx) - if err == nil { - _, err = deploymentStore.SampleHostHistory(sampleCtx) - } - cancel() - fields := []any{"listed", result.Listed, "observed", result.Observed, "failed", result.Failed, "complete", result.Complete} - if !result.Complete { - log.Bg().Warn("Runtime history sampling sweep incomplete", fields...) - err = errors.New("incomplete Runtime sampling sweep") - } else { - log.Bg().Debug("Runtime history sampling sweep complete", fields...) - } - return metricPtr(int64(result.Observed)), int64(result.Failed), err + cancel() + fields := []any{"listed", result.Listed, "observed", result.Observed, "failed", result.Failed, "complete", result.Complete} + if !result.Complete { + log.Bg().Warn("Runtime history sampling sweep incomplete", fields...) + err = errors.New("incomplete Runtime sampling sweep") + } else { + log.Bg().Debug("Runtime history sampling sweep complete", fields...) } - } + return metricPtr(int64(result.Observed)), int64(result.Failed), err + }} metricsSource := &coreMetricsSource{store: coremetricspg.New(units), pool: pool, worker: worker, registry: registry} metrics, err := coremetrics.New(processStartedAt, buildRevision, metricsSource, sampling, prune("history_cleanup", 2*time.Second, history.Prune), @@ -351,9 +327,7 @@ func run(config processconfig.Config) error { Environments: sessionService, EnvironmentsReader: sessionStore, ExecutorConnections: executorConnections{sessions: sessionStore, registry: registry}, Admin: sessionStore, AdminAudit: auditStore, WriteAudit: auditStore, Metrics: metrics, RuntimeObservations: observationService, RuntimeHistory: historyService, - } - if worker != nil { - deps.Execution = &api.Execution{ + Execution: api.Execution{ ExecutorURL: executorURL, SessionAdmission: worker, InputAdmission: worker, @@ -361,30 +335,23 @@ func run(config processconfig.Config) error { Workspaces: worker, Links: linkRelay, NativeInstaller: nativeInstaller, - } - } - if managedNodes != nil { - deps.Sandboxes = &api.Sandboxes{ + }, + Sandboxes: api.Sandboxes{ Deployment: deploymentService, NodeAllocations: deploymentStore, DeploymentChanges: worker, DeploymentReset: worker, ConfigurationDiscovery: managedNodes.setup, - } + }, } - handler, err := api.NewHandler(deps) + apiHandler, err := api.NewHandler(deps) if err != nil { return err } - if daemonHandler != nil { - routes := daemonRoutes{gateway: daemonHandler, - enrollment: runtimeenrollment.EnrollmentHandler(sessionService), - connection: runtimeenrollment.ConnectionHandler(sessionStore, registry)} - if managedNodes != nil { - routes.nodeConnect = managedNodes.hub - } - handler = serverHandler(handler, &routes) - } + handler := serverHandler(apiHandler, &daemonRoutes{gateway: daemonHandler, + enrollment: runtimeenrollment.EnrollmentHandler(sessionService), + connection: runtimeenrollment.ConnectionHandler(sessionStore, registry), + nodeConnect: managedNodes.hub}) server := &http.Server{Addr: config.Addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} done := make(chan error, 1) go func() { done <- server.ListenAndServe() }() diff --git a/services/core/cmd/server/managed_nodes.go b/services/core/cmd/server/managed_nodes.go index acc1d8f69..f5d2974d1 100644 --- a/services/core/cmd/server/managed_nodes.go +++ b/services/core/cmd/server/managed_nodes.go @@ -22,12 +22,8 @@ type managedNodes struct { // configureManagedNodes serves the nodes of the Web-managed deployment. Node // presence and health and the generation of each allocation go through the // deployment service; the owner epoch that fences connections and the -// allocations each generation retains are read from the deployment reader. It -// returns nil without an installation ID. +// allocations each generation retains are read from the deployment reader. func configureManagedNodes(nodes *deployment.Service, reader deployment.Reader, registry *providers.Registry, config processconfig.Config, owner func(context.Context) error) *managedNodes { - if config.InstallationID == "" { - return nil - } result := &managedNodes{} result.hub = node.NewHub(node.HubOptions{ Generations: func(ctx context.Context, n node.Identity, connection string, epoch uint64, health node.Health) error { @@ -74,8 +70,7 @@ func configureManagedNodes(nodes *deployment.Service, reader deployment.Reader, return nodes.Heartbeat(ctx, n.NodeID, connection, epoch, nodeHealthRecord(health)) }, }) - // Load requires OAC_PUBLIC_URL with an installation ID. An origin without a - // sandbox Link admits no hosted sandbox. + // An origin without a sandbox Link admits no hosted sandbox. link, _ := config.PublicOrigin.SandboxLink() result.setup = &managedSetup{processPaths: config.ProviderPaths, registry: registry, deployment: nodes, allocations: reader, hub: result.hub, installationID: config.InstallationID, runtimeAPI: config.PublicOrigin.RuntimeAPI(), sandboxLink: link} result.runtime = execution.NewDeferredRuntimeProvider(config.InstallationID, result.setup.load, result.setup.prepare) @@ -83,12 +78,6 @@ func configureManagedNodes(nodes *deployment.Service, reader deployment.Reader, return result } -func (m *managedNodes) close() { - if m != nil { - m.hub.Close() - } -} - func nodeHealthRecord(health node.Health) deployment.NodeHealth { return deployment.NodeHealth{Host: &deployment.NodeHost{EffectiveCPUCores: health.EffectiveCPUCores, CPUUtilization: health.CPUUtilization, TotalMemoryBytes: health.TotalMemoryBytes, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes, ObservedAt: &health.ObservedAt}, ProviderReady: health.ProviderReady, Diagnostic: health.Diagnostic, CPUCount: health.CPUCount, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes} } diff --git a/services/core/cmd/server/managed_setup.go b/services/core/cmd/server/managed_setup.go index e6284718b..dba227b56 100644 --- a/services/core/cmd/server/managed_setup.go +++ b/services/core/cmd/server/managed_setup.go @@ -87,6 +87,11 @@ func (s *managedSetup) publishUnconfigured(generation uint64) { s.publishSelecti func (s *managedSetup) load(ctx context.Context) (*execution.RuntimeProvider, error) { setup, err := s.deployment.Setup(ctx) + if errors.Is(err, deployment.ErrCredentialUnreadable) { + // A replaced credential key blocks hosted execution, not Core. + log.Warn(ctx, "Hosted provider credential is unreadable; administrator recovery remains available", "error", err) + return nil, fmt.Errorf("%w: %w", execution.ErrExecutionUnavailable, err) + } if err != nil { return nil, err } diff --git a/services/core/cmd/server/managed_setup_test.go b/services/core/cmd/server/managed_setup_test.go index 259ed35cb..f4a022aa8 100644 --- a/services/core/cmd/server/managed_setup_test.go +++ b/services/core/cmd/server/managed_setup_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/deploymentpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/processconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" @@ -24,15 +25,12 @@ import ( ) func TestWebSetupCreatesManagerWithoutLocalProvider(t *testing.T) { - if configureManagedNodes(nil, nil, providers.Builtin(), processconfig.Config{}, nil) != nil { - t.Fatal("sandbox manager started without an installation ID") - } origin, err := deployment.NewPublicOrigin("https://core.example") if err != nil { t.Fatal(err) } - m := configureManagedNodes(nil, nil, providers.Builtin(), processconfig.Config{InstallationID: uuid.NewString(), PublicOrigin: &origin}, func(context.Context) error { return nil }) - defer m.close() + m := configureManagedNodes(nil, nil, providers.Builtin(), processconfig.Config{InstallationID: uuid.NewString(), PublicOrigin: origin}, func(context.Context) error { return nil }) + defer m.hub.Close() if m.setup == nil || m.hub == nil || m.runtime == nil || m.runtime.Provider != nil || m.setup.runtimeAPI != "https://core.example/api/v1" { t.Fatal("zero-node setup unexpectedly instantiated local compute or omitted management") } @@ -113,7 +111,7 @@ func credentialService(t *testing.T) func(owner, candidate deployment.Setup) (de if err != nil { t.Fatal(err) } - service, err := deployment.NewService(deploymentpg.New(nil, nil), deploymentpg.New(nil, nil), providers.Builtin(), rules) + service, err := deployment.NewService(deploymentpg.New(nil, pgtest.CredentialKey(t)), deploymentpg.New(nil, pgtest.CredentialKey(t)), providers.Builtin(), rules) if err != nil { t.Fatal(err) } @@ -131,8 +129,12 @@ func TestManagedSetupNeverReusesAnotherGenerationOrUnverifiedState(t *testing.T) t.Fatal("matching immutable selection was not reused") } loadErr = errors.New("database unavailable") - if _, err := s.load(t.Context()); err == nil { - t.Fatal("stale cached selection hid storage failure") + if _, err := s.load(t.Context()); err == nil || errors.Is(err, execution.ErrExecutionUnavailable) { + t.Fatal("stale cached selection hid storage failure", err) + } + loadErr = deployment.ErrCredentialUnreadable + if _, err := s.load(t.Context()); !errors.Is(err, execution.ErrExecutionUnavailable) || !errors.Is(err, deployment.ErrCredentialUnreadable) { + t.Fatal("an unreadable credential must block execution without stopping Core", err) } loadErr = nil value.Generation = 2 diff --git a/services/core/cmd/server/runtime_history.go b/services/core/cmd/server/runtime_history.go index 7d333c097..792d04769 100644 --- a/services/core/cmd/server/runtime_history.go +++ b/services/core/cmd/server/runtime_history.go @@ -25,15 +25,10 @@ type runtimeHistoryExporter interface { Close(context.Context) error } -func runtimeHistory(ctx context.Context, units *pgunit.Pool, config processconfig.RuntimeHistory, executionEnabled bool) (runtimeHistorySetup, error) { +func runtimeHistory(ctx context.Context, units *pgunit.Pool, config processconfig.RuntimeHistory) (runtimeHistorySetup, error) { interval := config.SampleInterval - mode := runtimehistory.CollectionPeriodic - if !executionEnabled { - interval = 0 - mode = runtimehistory.CollectionOnRead - } capabilities := runtimehistory.Capabilities{ - CollectionMode: mode, SampleInterval: interval, Retention: 7 * 24 * time.Hour, + CollectionMode: runtimehistory.CollectionPeriodic, SampleInterval: interval, Retention: 7 * 24 * time.Hour, MinimumStep: max(30*time.Second, interval), MaximumRange: 24 * time.Hour, MaximumPoints: 1000, MaximumSeries: 64, MaximumTotalPoints: 10000, Metrics: []runtimehistory.Metric{runtimehistory.MetricCPU, runtimehistory.MetricMemory, runtimehistory.MetricTokens}, diff --git a/services/core/cmd/server/runtime_history_test.go b/services/core/cmd/server/runtime_history_test.go index 17c748cb5..c81e5b3e4 100644 --- a/services/core/cmd/server/runtime_history_test.go +++ b/services/core/cmd/server/runtime_history_test.go @@ -18,31 +18,26 @@ func (panicHistoryExporter) Close(context.Context) error var defaultHistory = processconfig.RuntimeHistory{QueueCapacity: 256, Timeout: 2 * time.Second, SampleInterval: 30 * time.Second} func TestRuntimeHistoryUsesCoreDatabaseByDefault(t *testing.T) { - for _, enabled := range []bool{true, false} { - setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), defaultHistory, enabled) - if err != nil { - t.Fatal(err) - } - if len(setup.Options) != 1 || setup.Exporter != nil || setup.Reader == nil || setup.Prune == nil { - t.Fatal("default history requires extra deployment") - } - capabilities := setup.Reader.Capabilities() - if capabilities.Durable() != enabled || capabilities.Retention != 7*24*time.Hour { - t.Fatalf("incorrect default capabilities: %+v", capabilities) - } - if enabled && setup.SampleInterval != 30*time.Second { - t.Fatal("default cadence missing") - } - if !enabled && setup.SampleInterval != 0 { - t.Fatal("sampler requires an execution owner") - } + setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), defaultHistory) + if err != nil { + t.Fatal(err) + } + if len(setup.Options) != 1 || setup.Exporter != nil || setup.Reader == nil || setup.Prune == nil { + t.Fatal("default history requires extra deployment") + } + capabilities := setup.Reader.Capabilities() + if !capabilities.Durable() || capabilities.Retention != 7*24*time.Hour { + t.Fatalf("incorrect default capabilities: %+v", capabilities) + } + if setup.SampleInterval != 30*time.Second { + t.Fatal("default cadence missing") } } func TestRuntimeHistoryOptionalExportAndSamplingConfiguration(t *testing.T) { config := defaultHistory config.Endpoint, config.Headers, config.SampleInterval = "https://collector.example.test/v1/metrics", map[string]string{"Authorization": "Bearer private"}, time.Minute - setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), config, true) + setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), config) if err != nil { t.Fatal(err) } diff --git a/services/core/cmd/specification-contract/main.go b/services/core/cmd/specification-contract/main.go index c79213dda..5e5956190 100644 --- a/services/core/cmd/specification-contract/main.go +++ b/services/core/cmd/specification-contract/main.go @@ -5,7 +5,6 @@ package main import ( "flag" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "os" "strings" @@ -14,12 +13,11 @@ import ( func main() { write := flag.Bool("write", false, "update deploy/node/node_spec.py and packages/agents-client/src/deployment-contract.ts from the repository root") flag.Parse() - projection, err := providers.Builtin().PythonDeploymentContract() + projection, typescript, err := providers.Builtin().DeploymentContract() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - typescript := sandbox.TypeScriptDeploymentContract() if !*write { fmt.Println(projection) fmt.Print(typescript) diff --git a/services/core/internal/agents/storage.go b/services/core/internal/agents/storage.go index 1090b6b16..b10d8ae3e 100644 --- a/services/core/internal/agents/storage.go +++ b/services/core/internal/agents/storage.go @@ -12,8 +12,7 @@ import ( // Agent. type Storage interface { // CreateAgent assigns the Agent's ID and saves it with its sealed model - // provider bundle. Sealing without a credential key is - // credentialcrypto.ErrUnavailable. + // provider bundle. CreateAgent(context.Context, NewAgent) (Agent, error) // WithAgentUpdate locks the tenant's Agent and runs update; the revision // it applies commits only when update returns nil. @@ -56,9 +55,8 @@ type Reader interface { GetAgent(ctx context.Context, tenantID, agentID string) (Agent, error) ListAgents(context.Context, ListQuery) (Page, error) // GetAgentWithModelProvider reads the Agent and its opened model provider - // bundle from one snapshot. The bundle is nil when the Agent has none. - // Opening one without a credential key is credentialcrypto.ErrUnavailable; - // a bundle that fails to open is an internal error, and one that opens but - // no longer validates returns its *v1.ModelProviderError. + // bundle from one snapshot. The bundle is nil when the Agent has none; a + // bundle that fails to open is an internal error, and one that opens but no + // longer validates returns its *v1.ModelProviderError. GetAgentWithModelProvider(ctx context.Context, tenantID, agentID string) (Agent, *v1.ModelProviderInput, error) } diff --git a/services/core/internal/api/admin_session_archive.go b/services/core/internal/api/admin_session_archive.go index fcf8ae8f8..2a7b0e46e 100644 --- a/services/core/internal/api/admin_session_archive.go +++ b/services/core/internal/api/admin_session_archive.go @@ -40,10 +40,6 @@ func (h *Handler) adminArchiveSession(w http.ResponseWriter, r *http.Request) { writeOperationError(w, r, sessions.ErrInvalidInput) return } - if h.Execution == nil { - writeOperationError(w, r, sessions.ErrEnvironmentUnavailable) - return - } result, err := h.Execution.SessionArchive.ArchiveSession(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), input.ExpectedGeneration) if err != nil { writeOperationError(w, r, err) diff --git a/services/core/internal/api/admin_session_archive_test.go b/services/core/internal/api/admin_session_archive_test.go index 4d12f04a4..d3a45ac4f 100644 --- a/services/core/internal/api/admin_session_archive_test.go +++ b/services/core/internal/api/admin_session_archive_test.go @@ -38,7 +38,6 @@ func TestAdminSessionArchiveAuthorityAndValidation(t *testing.T) { key := callerBinding() deps, fakes := managementFakes(t, key) fixture := &archiveManagementFixture{} - deps.Execution = fakes.execution() fakes.sessionArchive.archiveSession = fixture.ArchiveSession fakes.sessionAdmin.getManagedSessionArchive = fixture.GetManagedSessionArchive h := newTestHandler(t, deps) diff --git a/services/core/internal/api/admin_summary.go b/services/core/internal/api/admin_summary.go index d24efa7c0..83c1a8188 100644 --- a/services/core/internal/api/admin_summary.go +++ b/services/core/internal/api/admin_summary.go @@ -125,7 +125,7 @@ func (h *Handler) adminSummary(w http.ResponseWriter, r *http.Request) { groups[""] = &AdminSummaryRow{ProjectID: project.ID} } counts, err := h.Admin.ReadAdminSummary(ctx, project.TenantID, sessions.AdminSummaryFilter{CreatedAfter: after, CreatedBefore: before}, func(session sessions.Session, creationKeyID *string) error { - projected, err := sessionResponse(session, h.executorURL()) + projected, err := sessionResponse(session, h.Execution.ExecutorURL) if err != nil { return err } diff --git a/services/core/internal/api/contract_routes_test.go b/services/core/internal/api/contract_routes_test.go index 6b589bceb..65b557b1c 100644 --- a/services/core/internal/api/contract_routes_test.go +++ b/services/core/internal/api/contract_routes_test.go @@ -66,9 +66,8 @@ func contractOperations(t *testing.T, file, prefix string) map[string]bool { // The pinned upstream /v1 set is checked by TestEveryRouteAuthenticatesItsCanonicalPath // and the contract tests. func TestContractsPublishExactlyTheRegisteredCoreAndMachineRoutes(t *testing.T) { - // Every optional group that gates a route registration, as the server enables them. - deps, fakes := testDependencies(t) - deps.Execution, deps.Sandboxes = fakes.execution(), fakes.sandboxes() + // The native installer gates its routes, as a release build enables them. + deps, _ := testDependencies(t) deps.Execution.NativeInstaller = &NativeInstaller{Version: "contract-test", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{}} h := &Handler{Dependencies: deps} contracts := map[string]string{"/v1": "openapi.yaml", "/core/v1": "core.openapi.yaml", "/api/v1": "runtime.openapi.yaml"} diff --git a/services/core/internal/api/core_store_validation_test.go b/services/core/internal/api/core_store_validation_test.go index 326967bb7..91cb319d0 100644 --- a/services/core/internal/api/core_store_validation_test.go +++ b/services/core/internal/api/core_store_validation_test.go @@ -13,6 +13,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/deploymentpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" @@ -25,7 +26,7 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { if err != nil { t.Fatal(err) } - nodes, err := deployment.NewService(deploymentpg.New(nil, nil), deploymentpg.New(nil, nil), providers.Builtin(), rules) + nodes, err := deployment.NewService(deploymentpg.New(nil, pgtest.CredentialKey(t)), deploymentpg.New(nil, pgtest.CredentialKey(t)), providers.Builtin(), rules) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/api/credentials_oauth_test.go b/services/core/internal/api/credentials_oauth_test.go index 779bbfc2a..ae3170806 100644 --- a/services/core/internal/api/credentials_oauth_test.go +++ b/services/core/internal/api/credentials_oauth_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) @@ -149,7 +148,7 @@ func TestOAuthCredentialStoreFailuresUseSafeExistingErrors(t *testing.T) { for _, tc := range []struct { err error code int - }{{vaults.ErrNotFound, 404}, {vaults.ErrInvalidInput, 400}, {credentialcrypto.ErrUnavailable, 503}, {errors.New("access-canary"), 500}} { + }{{vaults.ErrNotFound, 404}, {vaults.ErrInvalidInput, 400}, {errors.New("access-canary"), 500}} { for _, update := range []bool{false, true} { h, f, _ := credentialHandler(t) f.err = tc.err diff --git a/services/core/internal/api/credentials_test.go b/services/core/internal/api/credentials_test.go index 0c2ace584..fcc60c811 100644 --- a/services/core/internal/api/credentials_test.go +++ b/services/core/internal/api/credentials_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" "github.com/google/uuid" ) @@ -113,7 +112,7 @@ func TestCredentialStorageErrorsStaySafe(t *testing.T) { for _, test := range []struct { err error status int - }{{vaults.ErrNotFound, 404}, {credentialcrypto.ErrUnavailable, 503}, {errors.New("credential-canary"), 500}} { + }{{vaults.ErrNotFound, 404}, {errors.New("credential-canary"), 500}} { h, f, _ := credentialHandler(t) f.err = test.err w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials", `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://example.invalid","token":"credential-canary"}}`) diff --git a/services/core/internal/api/credentials_update_test.go b/services/core/internal/api/credentials_update_test.go index 2cd883056..cd7f65050 100644 --- a/services/core/internal/api/credentials_update_test.go +++ b/services/core/internal/api/credentials_update_test.go @@ -10,7 +10,6 @@ import ( "strings" "testing" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" "github.com/google/uuid" ) @@ -107,7 +106,7 @@ func TestCredentialUpdateUsesExistingBoundariesAndSafeErrors(t *testing.T) { for _, tc := range []struct { err error status int - }{{vaults.ErrNotFound, 404}, {credentialcrypto.ErrUnavailable, 503}, {errors.New("credential-canary"), 500}} { + }{{vaults.ErrNotFound, 404}, {errors.New("credential-canary"), 500}} { h, f, _ := credentialHandler(t) f.err = tc.err w := credentialRequest(h, "POST", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, body) diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 0f686fa1a..9bf48bfb0 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -60,14 +60,8 @@ type Dependencies struct { EnvironmentTemplatesReader EnvironmentTemplatesReader - // Execution is nil when this Core runs without a Runtime gateway, and so - // without an execution Worker. Work that needs one then answers 503 - // execution_unavailable. - Execution *Execution - // Sandboxes is nil when this Core has no managed sandbox installation. The - // sandbox node and manager routes are then absent, and openai_hosted - // Sessions answer 503 execution_unavailable. It requires Execution. - Sandboxes *Sandboxes + Execution Execution + Sandboxes Sandboxes } // Execution is the execution Worker's surface. Every field is required unless @@ -145,36 +139,25 @@ func (d Dependencies) validate() error { ); err != nil { return err } - if e := d.Execution; e != nil { - if e.ExecutorURL == "" { - return errors.New("api: Execution.ExecutorURL is required") - } - if e.NativeInstaller != nil && (e.NativeInstaller.Version == "" || e.NativeInstaller.Base == "") { - return errors.New("api: Execution.NativeInstaller.Version and Base are required") - } - if err := required( - field{"Execution.SessionAdmission", e.SessionAdmission}, - field{"Execution.InputAdmission", e.InputAdmission}, - field{"Execution.SessionArchive", e.SessionArchive}, - field{"Execution.Workspaces", e.Workspaces}, - field{"Execution.Links", e.Links}, - ); err != nil { - return err - } + e, s := d.Execution, d.Sandboxes + if e.ExecutorURL == "" { + return errors.New("api: Execution.ExecutorURL is required") } - if s := d.Sandboxes; s != nil { - if d.Execution == nil { - return errors.New("api: Sandboxes requires Execution") - } - return required( - field{"Sandboxes.Deployment", s.Deployment}, - field{"Sandboxes.NodeAllocations", s.NodeAllocations}, - field{"Sandboxes.DeploymentChanges", s.DeploymentChanges}, - field{"Sandboxes.DeploymentReset", s.DeploymentReset}, - field{"Sandboxes.ConfigurationDiscovery", s.ConfigurationDiscovery}, - ) + if e.NativeInstaller != nil && (e.NativeInstaller.Version == "" || e.NativeInstaller.Base == "") { + return errors.New("api: Execution.NativeInstaller.Version and Base are required") } - return nil + return required( + field{"Execution.SessionAdmission", e.SessionAdmission}, + field{"Execution.InputAdmission", e.InputAdmission}, + field{"Execution.SessionArchive", e.SessionArchive}, + field{"Execution.Workspaces", e.Workspaces}, + field{"Execution.Links", e.Links}, + field{"Sandboxes.Deployment", s.Deployment}, + field{"Sandboxes.NodeAllocations", s.NodeAllocations}, + field{"Sandboxes.DeploymentChanges", s.DeploymentChanges}, + field{"Sandboxes.DeploymentReset", s.DeploymentReset}, + field{"Sandboxes.ConfigurationDiscovery", s.ConfigurationDiscovery}, + ) } type field struct { @@ -190,12 +173,3 @@ func required(fields ...field) error { } return nil } - -// executorURL is the daemon URL self-hosted Sessions report, or empty when -// this Core cannot execute. -func (h *Handler) executorURL() string { - if h.Execution == nil { - return "" - } - return h.Execution.ExecutorURL -} diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 863fd8464..a38c3b575 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -62,9 +62,8 @@ type testFakes struct { // testDependencies returns Dependencies in which every area is a strict fake. // A test sets the funcs it expects on the returned fakes; any other call fails -// it. Engine is "codex", CoreKeys accepts "Bearer admin", and Execution and -// Sandboxes are disabled until the test sets fakes.execution() or -// fakes.sandboxes(). +// it. Engine is "codex", CoreKeys accepts "Bearer admin", and Execution reports +// testExecutorURL without a native installer. func testDependencies(t testing.TB) (Dependencies, *testFakes) { t.Helper() f := &testFakes{ @@ -115,34 +114,24 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { SessionAdmin: f.sessionAdmin, Environments: f.environments, EnvironmentsReader: f.environmentsReader, ExecutorConnections: f.executorConnections, Admin: f.admin, AdminAudit: f.adminAudit, WriteAudit: f.writeAudit, Metrics: f.metrics, RuntimeObservations: f.runtimeObservations, RuntimeHistory: f.runtimeHistory, + Execution: Execution{ + ExecutorURL: testExecutorURL, + SessionAdmission: f.sessionAdmission, + InputAdmission: f.inputAdmission, + SessionArchive: f.sessionArchive, + Workspaces: f.workspaces, + Links: f.links, + }, + Sandboxes: Sandboxes{ + Deployment: f.deployment, + NodeAllocations: f.nodeAllocations, + DeploymentChanges: f.deploymentChanges, + DeploymentReset: f.deploymentReset, + ConfigurationDiscovery: f.configurationDiscovery, + }, }, f } -// execution is an Execution group backed by f's strict fakes, reporting -// testExecutorURL and without a native installer. -func (f *testFakes) execution() *Execution { - return &Execution{ - ExecutorURL: testExecutorURL, - SessionAdmission: f.sessionAdmission, - InputAdmission: f.inputAdmission, - SessionArchive: f.sessionArchive, - Workspaces: f.workspaces, - Links: f.links, - } -} - -// sandboxes is a Sandboxes group backed by f's strict fakes. It requires -// Execution. -func (f *testFakes) sandboxes() *Sandboxes { - return &Sandboxes{ - Deployment: f.deployment, - NodeAllocations: f.nodeAllocations, - DeploymentChanges: f.deploymentChanges, - DeploymentReset: f.deploymentReset, - ConfigurationDiscovery: f.configurationDiscovery, - } -} - // coreKeys accepts each token as a Core key. func coreKeys(t testing.TB, tokens ...string) *DeploymentAuthenticator { t.Helper() @@ -169,13 +158,11 @@ func newTestHandler(t testing.TB, deps Dependencies) http.Handler { } func TestNewHandlerAcceptsCompleteDependencies(t *testing.T) { - deps, f := testDependencies(t) + deps, _ := testDependencies(t) if _, err := NewHandler(deps); err != nil { t.Fatal(err) } - deps.Execution = f.execution() deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/"} - deps.Sandboxes = f.sandboxes() if _, err := NewHandler(deps); err != nil { t.Fatal(err) } @@ -195,27 +182,11 @@ func TestNewHandlerRejectsIncompleteDependencies(t *testing.T) { {"Turns", func(d *Dependencies, _ *testFakes) { d.Turns = nil }}, {"Items", func(d *Dependencies, _ *testFakes) { d.Items = nil }}, {"RuntimeHistory", func(d *Dependencies, _ *testFakes) { d.RuntimeHistory = nil }}, - {"Execution.ExecutorURL", func(d *Dependencies, f *testFakes) { - d.Execution = f.execution() - d.Execution.ExecutorURL = "" - }}, - {"Execution.SessionAdmission", func(d *Dependencies, f *testFakes) { - d.Execution = f.execution() - d.Execution.SessionAdmission = nil - }}, - {"Execution.InputAdmission", func(d *Dependencies, f *testFakes) { - d.Execution = f.execution() - d.Execution.InputAdmission = nil - }}, - {"Execution.NativeInstaller.Version", func(d *Dependencies, f *testFakes) { - d.Execution = f.execution() - d.Execution.NativeInstaller = &NativeInstaller{} - }}, - {"Sandboxes requires Execution", func(d *Dependencies, f *testFakes) { d.Sandboxes = f.sandboxes() }}, - {"Sandboxes.ConfigurationDiscovery", func(d *Dependencies, f *testFakes) { - d.Execution, d.Sandboxes = f.execution(), f.sandboxes() - d.Sandboxes.ConfigurationDiscovery = nil - }}, + {"Execution.ExecutorURL", func(d *Dependencies, _ *testFakes) { d.Execution.ExecutorURL = "" }}, + {"Execution.SessionAdmission", func(d *Dependencies, _ *testFakes) { d.Execution.SessionAdmission = nil }}, + {"Execution.InputAdmission", func(d *Dependencies, _ *testFakes) { d.Execution.InputAdmission = nil }}, + {"Execution.NativeInstaller.Version", func(d *Dependencies, _ *testFakes) { d.Execution.NativeInstaller = &NativeInstaller{} }}, + {"Sandboxes.ConfigurationDiscovery", func(d *Dependencies, _ *testFakes) { d.Sandboxes.ConfigurationDiscovery = nil }}, } { t.Run(test.missing, func(t *testing.T) { deps, f := testDependencies(t) diff --git a/services/core/internal/api/environment_creation_test.go b/services/core/internal/api/environment_creation_test.go index a1a8a3a0f..f4dc7e41a 100644 --- a/services/core/internal/api/environment_creation_test.go +++ b/services/core/internal/api/environment_creation_test.go @@ -66,10 +66,8 @@ func environmentCreationHandler(t *testing.T, engine string, configure ...func(* return newTestHandler(t, deps), fixture } -// selfHostedExecution enables Execution reporting environmentOrigin to -// self-hosted Sessions. +// selfHostedExecution reports environmentOrigin to self-hosted Sessions. func selfHostedExecution(d *Dependencies, f *testFakes) { - d.Execution = f.execution() d.Execution.ExecutorURL = environmentOrigin } @@ -201,41 +199,3 @@ func TestSelfHostedCreationRejectsBeforePersistence(t *testing.T) { } } } - -// Execution, which carries the executor URL, is required; a URL without -// Execution cannot be configured (TestNewHandlerRejectsIncompleteDependencies). -func TestSelfHostedCreationRequiresOperatorExecution(t *testing.T) { - for _, stream := range []bool{false, true} { - unavailable := 0 - handler, fixture := environmentCreationHandler(t, "codex", func(_ *Dependencies, f *testFakes) { f.metrics.recordUnavailable = func() { unavailable++ } }) - body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"stream":%t,%s}`, stream, fixtureSessionProvider) - request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) - request.Header.Set("Authorization", "Bearer key") - request.Header.Set("OpenAI-Beta", "agents=v1") - request.Header.Set("Content-Type", "application/json") - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - var failure v1.ErrorResponse - if response.Code != http.StatusServiceUnavailable || json.Unmarshal(response.Body.Bytes(), &failure) != nil || failure.Error.Code == nil || *failure.Error.Code != "execution_unavailable" || fixture.input.Engine != "" || unavailable != 1 { - t.Fatal("operator prerequisites did not fail before persistence", response.Code, response.Body.String(), fixture.input) - } - } -} - -func TestHostedCreationRequiresOperatorExecution(t *testing.T) { - for _, stream := range []bool{false, true} { - unavailable := 0 - handler, fixture := environmentCreationHandler(t, "codex", selfHostedExecution, func(_ *Dependencies, f *testFakes) { f.metrics.recordUnavailable = func() { unavailable++ } }) - body := fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"stream":%t,"input":"Initialize the hosted execution."}`, stream) - request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) - request.Header.Set("Authorization", "Bearer key") - request.Header.Set("OpenAI-Beta", "agents=v1") - request.Header.Set("Content-Type", "application/json") - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - var failure v1.ErrorResponse - if response.Code != http.StatusServiceUnavailable || json.Unmarshal(response.Body.Bytes(), &failure) != nil || failure.Error.Code == nil || *failure.Error.Code != "execution_unavailable" || fixture.input.Engine != "" || unavailable != 1 { - t.Fatal("hosted configuration bypassed operator prerequisites", response.Code, response.Body.String()) - } - } -} diff --git a/services/core/internal/api/environment_files.go b/services/core/internal/api/environment_files.go index d829775e0..e507f348c 100644 --- a/services/core/internal/api/environment_files.go +++ b/services/core/internal/api/environment_files.go @@ -33,7 +33,7 @@ func (h *Handler) listEnvironmentFiles(w http.ResponseWriter, r *http.Request) { if !ok || !environmentFilesAccessible(w, environment) { return } - if h.Execution == nil || !execution.LocalWorkspaceConfiguration(environment.Configuration) { + if !execution.LocalWorkspaceConfiguration(environment.Configuration) { writeSessionsError(w, r, execution.ErrExecutionUnavailable) return } diff --git a/services/core/internal/api/environment_files_completeness_test.go b/services/core/internal/api/environment_files_completeness_test.go index df17a8793..c63854d2f 100644 --- a/services/core/internal/api/environment_files_completeness_test.go +++ b/services/core/internal/api/environment_files_completeness_test.go @@ -23,7 +23,7 @@ func TestEnvironmentFilesRejectsIncompleteOrMalformedDirectories(t *testing.T) { } { t.Run(name, func(t *testing.T) { unavailable := 0 - h, f := environmentFilesHandler(t, true, countEnvironmentFilesUnavailable(&unavailable)) + h, f := environmentFilesHandler(t, countEnvironmentFilesUnavailable(&unavailable)) f.result = result w := requestEnvironmentFiles(h, f.environment.ID, "?limit=1", "files-key") if w.Code != 503 || unavailable != 1 || strings.Contains(w.Body.String(), `"data"`) || strings.Contains(w.Body.String(), `"next"`) || strings.Contains(w.Body.String(), "secret") { @@ -36,7 +36,7 @@ func TestEnvironmentFilesRejectsIncompleteOrMalformedDirectories(t *testing.T) { func TestEnvironmentFilesCursorRejectsChangesAndAcceptsNativeReordering(t *testing.T) { for _, change := range []string{"limit", "order", "path", "environment", "size", "name", "removed", "added", "native order"} { t.Run(change, func(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) f.result.Entries = []proto.WorkspaceDirectoryEntry{environmentFileEntry("A", 1), environmentFileEntry("B", 2)} page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?limit=1", "files-key")) q := url.Values{"limit": {"1"}, "page": {*page.Next}} @@ -73,7 +73,7 @@ func TestEnvironmentFilesCursorRejectsChangesAndAcceptsNativeReordering(t *testi func TestEnvironmentFilesMalformedCursorBounds(t *testing.T) { for _, change := range []string{"version", "binding", "fingerprint", "negative", "overflow", "unknown", "trailing", "non-page offset"} { t.Run(change, func(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) f.result.Entries = []proto.WorkspaceDirectoryEntry{environmentFileEntry("A", 1), environmentFileEntry("B", 2), environmentFileEntry("C", 3)} page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?limit=2", "files-key")) raw, _ := base64.RawURLEncoding.DecodeString(*page.Next) diff --git a/services/core/internal/api/environment_files_create.go b/services/core/internal/api/environment_files_create.go index 0cec14045..9a2608ddc 100644 --- a/services/core/internal/api/environment_files_create.go +++ b/services/core/internal/api/environment_files_create.go @@ -105,7 +105,7 @@ func (h *Handler) createEnvironmentFile(w http.ResponseWriter, r *http.Request) return } } - if h.Execution == nil || !execution.LocalWorkspaceConfiguration(environment.Configuration) { + if !execution.LocalWorkspaceConfiguration(environment.Configuration) { writeSessionsError(w, r, execution.ErrExecutionUnavailable) return } diff --git a/services/core/internal/api/environment_files_create_test.go b/services/core/internal/api/environment_files_create_test.go index ebeebd16a..1e8a6a737 100644 --- a/services/core/internal/api/environment_files_create_test.go +++ b/services/core/internal/api/environment_files_create_test.go @@ -48,7 +48,6 @@ func environmentFileCreateHandler(t *testing.T, configure ...func(*Dependencies, APIKey{OrganizationID: "org", ProjectID: "other", SubjectKind: "user", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential("other-key"), TenantID: uuid.NewString()}, ).ResolveAPIKey fakes.environmentsReader.getEnvironment = f.GetEnvironment - deps.Execution = fakes.execution() fakes.workspaces.readEnvironmentDirectory, fakes.workspaces.writeEnvironmentFile = f.ReadEnvironmentDirectory, f.WriteEnvironmentFile for _, c := range configure { c(&deps, fakes) diff --git a/services/core/internal/api/environment_files_deadline_test.go b/services/core/internal/api/environment_files_deadline_test.go index 6f1f2cd3b..a868b27d0 100644 --- a/services/core/internal/api/environment_files_deadline_test.go +++ b/services/core/internal/api/environment_files_deadline_test.go @@ -13,7 +13,7 @@ import ( func TestEnvironmentFilesReadOutlivesDefaultHTTPWriteDeadline(t *testing.T) { for _, status := range []int{http.StatusOK, http.StatusServiceUnavailable} { t.Run(http.StatusText(status), func(t *testing.T) { - handler, fixture := environmentFilesHandler(t, true, func(_ *Dependencies, f *testFakes) { f.metrics.recordUnavailable = func() {} }) + handler, fixture := environmentFilesHandler(t, func(_ *Dependencies, f *testFakes) { f.metrics.recordUnavailable = func() {} }) fixture.readDelay = 100 * time.Millisecond if status == http.StatusServiceUnavailable { fixture.readError = execution.ErrExecutionUnavailable diff --git a/services/core/internal/api/environment_files_test.go b/services/core/internal/api/environment_files_test.go index 4ba1ebad1..eab54eb9f 100644 --- a/services/core/internal/api/environment_files_test.go +++ b/services/core/internal/api/environment_files_test.go @@ -62,9 +62,8 @@ func newEnvironmentFilesFixture() *environmentFilesFixture { } } -// environmentFilesHandler serves f's Environment. Without enabled, Core has no -// execution Worker. -func environmentFilesHandler(t *testing.T, enabled bool, configure ...func(*Dependencies, *testFakes)) (http.Handler, *environmentFilesFixture) { +// environmentFilesHandler serves f's Environment. +func environmentFilesHandler(t *testing.T, configure ...func(*Dependencies, *testFakes)) (http.Handler, *environmentFilesFixture) { t.Helper() f := newEnvironmentFilesFixture() keys := []APIKey{} @@ -80,10 +79,7 @@ func environmentFilesHandler(t *testing.T, enabled bool, configure ...func(*Depe deps.Engine = "fake_alpha" fakes.projectsReader.resolveAPIKey = projectKeys(t, keys...).ResolveAPIKey fakes.environmentsReader.getEnvironment = f.GetEnvironment - if enabled { - deps.Execution = fakes.execution() - fakes.workspaces.readEnvironmentDirectory = f.ReadEnvironmentDirectory - } + fakes.workspaces.readEnvironmentDirectory = f.ReadEnvironmentDirectory for _, c := range configure { c(&deps, fakes) } @@ -131,7 +127,7 @@ func decodeEnvironmentFiles(t *testing.T, w *httptest.ResponseRecorder) v1.Envir func TestEnvironmentFilesOrderingPaginationAndProjection(t *testing.T) { for _, order := range []string{"", "asc", "desc"} { t.Run("order="+order, func(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) f.result.Entries = []proto.WorkspaceDirectoryEntry{ environmentFileEntry("a.txt", 14), environmentFileEntry("z.txt", 5), environmentFileEntry("A.txt", 0), environmentFileEntry("a-b.txt", 6), {Name: "directory", Kind: "directory"}, {Name: "symlink", Kind: "symlink"}, {Name: "socket", Kind: "other"}, @@ -173,7 +169,7 @@ func TestEnvironmentFilesOrderingPaginationAndProjection(t *testing.T) { } func TestEnvironmentFilesEmptyRootDefaultsAndSharedAccess(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "", "shared-key")) if len(page.Data) != 0 || page.Next != nil || f.directory != "" { t.Fatal("invalid empty root", page, f.directory) @@ -193,16 +189,14 @@ func TestEnvironmentFilesEmptyRootDefaultsAndSharedAccess(t *testing.T) { } func TestEnvironmentFilesAuthorizationPrecedesInspection(t *testing.T) { - for _, enabled := range []bool{false, true} { - for _, query := range []string{"", "?path=/foreign-secret/../&page=invalid&limit=999", "?bad=%GG"} { - h, f := environmentFilesHandler(t, enabled) - w := requestEnvironmentFiles(h, f.environment.ID, query, "other-key") - if w.Code != 404 || f.lookups != 1 || f.reads != 0 || strings.Contains(w.Body.String(), "foreign-secret") || strings.Contains(w.Body.String(), f.environment.ID) { - t.Fatal("foreign resource inspected", w.Code, w.Body, f) - } + for _, query := range []string{"", "?path=/foreign-secret/../&page=invalid&limit=999", "?bad=%GG"} { + h, f := environmentFilesHandler(t) + w := requestEnvironmentFiles(h, f.environment.ID, query, "other-key") + if w.Code != 404 || f.lookups != 1 || f.reads != 0 || strings.Contains(w.Body.String(), "foreign-secret") || strings.Contains(w.Body.String(), f.environment.ID) { + t.Fatal("foreign resource inspected", w.Code, w.Body, f) } } - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) w := requestEnvironmentFiles(h, f.environment.ID, "", "invalid") if w.Code != 401 || f.lookups != 0 || f.reads != 0 { t.Fatal("unauthenticated read", w.Code, f) @@ -214,7 +208,7 @@ func TestEnvironmentFilesRejectsInvalidRequestsBeforeRead(t *testing.T) { "limit=0", "limit=101", "limit=no", "limit=", "limit=1&limit=2", "order=ASC", "order=", "path=", "path=relative", "path=/workspace-sibling", "path=/workspace/../workspace", "path=/workspace/a/../../workspace", "path=/workspace/%00", "path=/workspace/%5C", "path=/workspace/%0A", "path=/workspace/%FF", "path=x&path=y", "path=" + strings.Repeat("a", 4097), "page=", "page=not-json", "page=" + strings.Repeat("a", 1025), "bad=%GG", "foo=1;bar=2", } { t.Run(query[:min(len(query), 70)], func(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) w := requestEnvironmentFiles(h, f.environment.ID, "?"+query, "files-key") if w.Code != 400 || f.lookups != 1 || f.reads != 0 { t.Fatal("invalid query reached runtime", w.Code, w.Body, f) @@ -235,7 +229,7 @@ func TestEnvironmentFilesSafeStoreAndReaderFailures(t *testing.T) { {sessions.ErrNotFound, 404}, {sessions.ErrInvalidInput, 400}, {execution.ErrExecutionUnavailable, 503}, {errors.New("private-native-secret"), 500}, } { unavailable := 0 - h, f := environmentFilesHandler(t, true, countEnvironmentFilesUnavailable(&unavailable)) + h, f := environmentFilesHandler(t, countEnvironmentFilesUnavailable(&unavailable)) if target == "store" { f.storeError = test.err } else { @@ -250,9 +244,4 @@ func TestEnvironmentFilesSafeStoreAndReaderFailures(t *testing.T) { } } } - unavailable := 0 - h, f := environmentFilesHandler(t, false, countEnvironmentFilesUnavailable(&unavailable)) - if w := requestEnvironmentFiles(h, f.environment.ID, "", "files-key"); w.Code != 503 || f.reads != 0 || unavailable != 1 { - t.Fatal("missing reader accepted", w.Code, f) - } } diff --git a/services/core/internal/api/environment_files_wire_test.go b/services/core/internal/api/environment_files_wire_test.go index b47a0dc49..84426cdca 100644 --- a/services/core/internal/api/environment_files_wire_test.go +++ b/services/core/internal/api/environment_files_wire_test.go @@ -14,7 +14,7 @@ import ( // Official Environment Files wire rows F1–F9 of the environment-files-wire batch. func TestEnvironmentFilesPageEnvelope(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) w := requestEnvironmentFiles(h, f.environment.ID, "", "files-key") if w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"object":"page","data":[],"next":null,"has_more":false}` { t.Fatalf("empty page: %d %s", w.Code, w.Body) @@ -33,7 +33,7 @@ func TestEnvironmentFilesPageEnvelope(t *testing.T) { } func TestEnvironmentFilesIgnoresUnknownQueryKeys(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) f.result.Entries = []proto.WorkspaceDirectoryEntry{environmentFileEntry("a", 1)} want := requestEnvironmentFiles(h, f.environment.ID, "", "files-key").Body.String() for _, query := range []string{"?foo=bar", "?after=x&after=y", "?tenant_id=" + uuid.NewString(), "?include=all&foo", "?Path=/workspace/secret"} { @@ -52,7 +52,7 @@ func TestEnvironmentFilesIgnoresUnknownQueryKeys(t *testing.T) { func TestEnvironmentFilesRejectsRepeatedQueryKeys(t *testing.T) { for _, key := range []string{"path", "limit", "order", "page"} { t.Run(key, func(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) query := "?" + key + "=1&" + key + "=2" w := requestEnvironmentFiles(h, f.environment.ID, query, "files-key") assertListQueryError(t, w, "invalid_request_error", nil, "Failed to deserialize query string: duplicate field `"+key+"`") @@ -92,7 +92,7 @@ func TestEnvironmentFilesPathErrors(t *testing.T) { "/": directory, } { t.Run(path[:min(len(path), 40)], func(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) w := requestEnvironmentFiles(h, f.environment.ID, "?"+url.Values{"path": {path}}.Encode(), "files-key") assertListQueryError(t, w, "invalid_request_error", nil, message) if f.reads != 0 { @@ -101,7 +101,7 @@ func TestEnvironmentFilesPathErrors(t *testing.T) { }) } for path, relative := range map[string]string{"/workspace": "", "/workspace/a": "a", "/workspace/a/b c": "a/b c"} { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?"+url.Values{"path": {path}}.Encode(), "files-key")) if f.directory != relative { t.Fatal("canonical path changed", path, f.directory) @@ -110,7 +110,7 @@ func TestEnvironmentFilesPathErrors(t *testing.T) { } func TestEnvironmentFilesPageTokenErrors(t *testing.T) { - h, f := environmentFilesHandler(t, true) + h, f := environmentFilesHandler(t) f.result.Entries = []proto.WorkspaceDirectoryEntry{environmentFileEntry("a", 1), environmentFileEntry("b", 2)} page := decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "?limit=1", "files-key")) for name, query := range map[string]url.Values{ diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index caa666ef7..3899bbf4d 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -25,17 +25,8 @@ type NativeInstaller struct { Catalog *nativeinstaller.Catalog } -// nativeInstaller returns this Core's native installer, or nil when it serves -// none. -func (h *Handler) nativeInstaller() *NativeInstaller { - if h.Execution == nil { - return nil - } - return h.Execution.NativeInstaller -} - func (h *Handler) installationFor(ctx context.Context, principal identity.Principal, environment string) (*v1.EnvironmentInstallation, error) { - installer := h.nativeInstaller() + installer := h.Execution.NativeInstaller result := &v1.EnvironmentInstallation{Status: "unavailable", Message: "This Core has no matching native installation distribution. Ask its operator to install the qualified release artifacts."} if installer == nil { return result, nil @@ -52,7 +43,7 @@ func (h *Handler) installationFor(ctx context.Context, principal identity.Princi } func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, response *v1.Session) error { - if response.Environment.Type != "self_hosted" || h.nativeInstaller() == nil { + if response.Environment.Type != "self_hosted" || h.Execution.NativeInstaller == nil { return nil } principal, ok := r.Context().Value(principalContextKey{}).(identity.Principal) @@ -69,7 +60,7 @@ func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, } func (h *Handler) registerNativeInstallationRoutes(r chi.Router) { - installer := h.nativeInstaller() + installer := h.Execution.NativeInstaller if installer == nil { return } @@ -123,7 +114,7 @@ func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Reque writeSessionsError(w, r, err) return } - response, err := sessionResponse(session, h.executorURL()) + response, err := sessionResponse(session, h.Execution.ExecutorURL) if err != nil { writeSessionsError(w, r, err) return diff --git a/services/core/internal/api/environment_installation_test.go b/services/core/internal/api/environment_installation_test.go index 4cf35ef2d..453bcd263 100644 --- a/services/core/internal/api/environment_installation_test.go +++ b/services/core/internal/api/environment_installation_test.go @@ -39,7 +39,6 @@ func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) fakes.sessionCreation.findSessionCreation, fakes.sessionCreation.createSession = f.FindSessionCreation, f.CreateSession fakes.modelProviders.resolve = fixtureDeploymentProvider fakes.environments.authorizeEnvironmentInstallation, fakes.environments.validateEnvironmentInstallation = f.AuthorizeEnvironmentInstallation, f.ValidateEnvironmentInstallation - deps.Execution = fakes.execution() deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build"}} handler := newTestHandler(t, deps) body := `{"agent":{"model":"model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.example/v1","api_key":"fixture-model"}}}` diff --git a/services/core/internal/api/environment_templates_test.go b/services/core/internal/api/environment_templates_test.go index 90f5d08dc..077694c45 100644 --- a/services/core/internal/api/environment_templates_test.go +++ b/services/core/internal/api/environment_templates_test.go @@ -11,7 +11,6 @@ import ( "strings" "testing" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" @@ -26,7 +25,6 @@ func TestEnvironmentTemplatesErrors(t *testing.T) { {environmenttemplates.ErrNotFound, 404, "not_found_error"}, {fmt.Errorf("create: %w", environmenttemplates.ErrInvalidInput), 400, invalidInputMessage}, {textvalue.ErrUnstorable, 400, unstorableTextMessage}, - {credentialcrypto.ErrUnavailable, 503, "credential_storage_unavailable"}, {errors.New("template-canary"), 500, "internal_error"}, } { response := httptest.NewRecorder() diff --git a/services/core/internal/api/errors.go b/services/core/internal/api/errors.go index 2e92da06d..ff91dce09 100644 --- a/services/core/internal/api/errors.go +++ b/services/core/internal/api/errors.go @@ -8,7 +8,6 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" ) @@ -139,17 +138,6 @@ func writeTextValueError(w http.ResponseWriter, r *http.Request, err error) bool return true } -// writeCredentialUnavailableError reports a request that needs credential -// encryption on a service without a credential key, and returns false for any -// other error. -func writeCredentialUnavailableError(w http.ResponseWriter, r *http.Request, err error) bool { - if !errors.Is(err, credentialcrypto.ErrUnavailable) { - return false - } - writeError(w, http.StatusServiceUnavailable, "credential_storage_unavailable", "Credential encryption is not configured on this service.") - return true -} - // writeAuditSourceError reports write or administrator provenance that cannot // be recorded, so the write failed closed, and returns false for any other // error. diff --git a/services/core/internal/api/errors_agents.go b/services/core/internal/api/errors_agents.go index e8a5b8cf9..b5f693124 100644 --- a/services/core/internal/api/errors_agents.go +++ b/services/core/internal/api/errors_agents.go @@ -17,7 +17,7 @@ const harnessRequiredMessage = "harness_config parameters require an explicit x_ // writeAgentsError reports an error of the Agent operations. func writeAgentsError(w http.ResponseWriter, r *http.Request, err error) { - if writeStoredDataError(w, r, err) || writeTextValueError(w, r, err) || writeAuditSourceError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeStoredDataError(w, r, err) || writeTextValueError(w, r, err) || writeAuditSourceError(w, r, err) { return } var provider *v1.ModelProviderError diff --git a/services/core/internal/api/errors_deployment.go b/services/core/internal/api/errors_deployment.go index 0da81d951..76646108d 100644 --- a/services/core/internal/api/errors_deployment.go +++ b/services/core/internal/api/errors_deployment.go @@ -23,7 +23,7 @@ func writeDeploymentError(w http.ResponseWriter, r *http.Request, err error) { case errors.Is(err, deployment.ErrNotFound): writeError(w, http.StatusNotFound, "not_found_error", "Resource not found.") default: - if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } writeInternalError(w, r) diff --git a/services/core/internal/api/errors_environmenttemplates.go b/services/core/internal/api/errors_environmenttemplates.go index f48c1abb0..2d935592e 100644 --- a/services/core/internal/api/errors_environmenttemplates.go +++ b/services/core/internal/api/errors_environmenttemplates.go @@ -16,7 +16,7 @@ func writeEnvironmentTemplatesError(w http.ResponseWriter, r *http.Request, err case errors.Is(err, environmenttemplates.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) default: - if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } writeInternalError(w, r) diff --git a/services/core/internal/api/errors_files.go b/services/core/internal/api/errors_files.go index ed21fc37c..0b8d1ab9b 100644 --- a/services/core/internal/api/errors_files.go +++ b/services/core/internal/api/errors_files.go @@ -12,7 +12,7 @@ import ( // writeFilesError maps a files error to its public response. notFoundParam // names the parameter a missing File came from. func writeFilesError(w http.ResponseWriter, r *http.Request, err error, notFoundParam ...string) { - if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } switch { diff --git a/services/core/internal/api/errors_modelconfiguration.go b/services/core/internal/api/errors_modelconfiguration.go index 88af07ce3..cb6fc7707 100644 --- a/services/core/internal/api/errors_modelconfiguration.go +++ b/services/core/internal/api/errors_modelconfiguration.go @@ -19,7 +19,7 @@ func writeModelConfigurationError(w http.ResponseWriter, r *http.Request, err er } return } - if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } writeInternalError(w, r) diff --git a/services/core/internal/api/errors_projects.go b/services/core/internal/api/errors_projects.go index 9882c604a..58aa882ff 100644 --- a/services/core/internal/api/errors_projects.go +++ b/services/core/internal/api/errors_projects.go @@ -31,7 +31,7 @@ func writeProjectsError(w http.ResponseWriter, r *http.Request, err error) { case errors.Is(err, projects.ErrAPIKeyExists): writeError(w, http.StatusConflict, "project_api_key_exists", "This API key ID already exists. List its metadata and revoke it explicitly if the secret was not saved.") default: - if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } writeInternalError(w, r) diff --git a/services/core/internal/api/errors_sessions.go b/services/core/internal/api/errors_sessions.go index c9e11de78..cefdf683f 100644 --- a/services/core/internal/api/errors_sessions.go +++ b/services/core/internal/api/errors_sessions.go @@ -71,7 +71,7 @@ func writeSessionsError(w http.ResponseWriter, r *http.Request, err error) { case errors.Is(err, sessions.ErrInvalidInput), errors.Is(err, environmentconfig.ErrInvalid): writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) default: - if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } writeInternalError(w, r) diff --git a/services/core/internal/api/errors_skills.go b/services/core/internal/api/errors_skills.go index 7877587a3..89109e2af 100644 --- a/services/core/internal/api/errors_skills.go +++ b/services/core/internal/api/errors_skills.go @@ -31,7 +31,7 @@ func writeSkillsError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, http.StatusNotFound, code, "Resource not found.") case errors.Is(err, skills.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid_request", invalidInputMessage) - case writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err): + case writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err): default: writeInternalError(w, r) } diff --git a/services/core/internal/api/errors_skills_test.go b/services/core/internal/api/errors_skills_test.go index 7ff09785b..f08aaa25d 100644 --- a/services/core/internal/api/errors_skills_test.go +++ b/services/core/internal/api/errors_skills_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "testing" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" @@ -41,8 +40,6 @@ func TestWriteSkillsError(t *testing.T) { `{"error":{"message":"` + unstorableTextMessage + `","type":"invalid_request_error","code":"invalid_request_error","param":null}}`}, {"audit source", "/v1/skills", fmt.Errorf("record: %w", writeaudit.ErrInvalidSource), http.StatusBadRequest, `{"error":{"message":"` + invalidInputMessage + `","type":"invalid_request_error","code":"invalid_request","param":null}}`}, - {"credential key missing", "/v1/skills", credentialcrypto.ErrUnavailable, http.StatusServiceUnavailable, - `{"error":{"message":"Credential encryption is not configured on this service.","type":"server_error","code":"credential_storage_unavailable","param":null}}`}, {"unknown", "/v1/skills", errors.New("connection reset"), http.StatusInternalServerError, `{"error":{"message":"The operation could not be completed.","type":"server_error","code":"internal_error","param":null}}`}, } { diff --git a/services/core/internal/api/errors_test.go b/services/core/internal/api/errors_test.go index 25cc78eb9..9ade94cb3 100644 --- a/services/core/internal/api/errors_test.go +++ b/services/core/internal/api/errors_test.go @@ -11,7 +11,6 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" @@ -188,7 +187,6 @@ func TestSharedPersistenceErrors(t *testing.T) { {writeSessionsError, fmt.Errorf("write: %w", textvalue.ErrUnstorable), 400, unstorableTextMessage}, {writeAuditError, textvalue.ErrUnstorable, 400, unstorableTextMessage}, {writeDeploymentError, deployment.ErrInvalidInput, 400, invalid}, - {writeSessionsError, credentialcrypto.ErrUnavailable, 503, "credential_storage_unavailable"}, {writeAuditError, errors.New("canary"), 500, "internal_error"}, } { response := respond(test.write, test.err) diff --git a/services/core/internal/api/errors_vaults.go b/services/core/internal/api/errors_vaults.go index 430e2272d..c98cd10fc 100644 --- a/services/core/internal/api/errors_vaults.go +++ b/services/core/internal/api/errors_vaults.go @@ -10,7 +10,7 @@ import ( // writeVaultsError maps a Vault, Credential or Session credential selection // error to its public error response. func writeVaultsError(w http.ResponseWriter, r *http.Request, err error) { - if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } var selection *vaults.MCPCredentialSelectionError diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index fc4b09c15..a3fcb0508 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -75,10 +75,8 @@ func (h *Handler) routes() *chi.Mux { h.registerSandboxNodeRoutes(router) h.registerCoreRoutes(router) h.registerNativeInstallationRoutes(router) - if h.Execution != nil { - router.Get("/api/v1/sandbox-link", h.sandboxLink) - router.Head("/api/v1/sandbox-link", methodNotAllowed) - } + router.Get("/api/v1/sandbox-link", h.sandboxLink) + router.Head("/api/v1/sandbox-link", methodNotAllowed) router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) r.Post("/vaults", h.createVault) @@ -248,14 +246,6 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { } return } - if input.Environment.Type == "self_hosted" && h.Execution == nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Self-hosted execution is not configured on this service.") - return - } - if input.Environment.Type == "openai_hosted" && h.Sandboxes == nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Hosted execution is not configured on this service.") - return - } executionConfiguration := sessionExecutionProjection(input, saved, inheritedProvider, provider, selectedEngine, configuration) createInput := sessions.CreateSession{ ExecutionConfiguration: &executionConfiguration, @@ -267,10 +257,6 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { } create := h.SessionCreation.CreateSession if len(initialInputs) > 0 || input.Environment.Type == "openai_hosted" { - if h.Execution == nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution input is not enabled on this service.") - return - } create = h.Execution.SessionAdmission.CreateSession } result, err := create(r.Context(), tenantID(r), createInput) @@ -299,7 +285,7 @@ func (h *Handler) respondSession(w http.ResponseWriter, r *http.Request, session } func (h *Handler) respondSessionStatus(w http.ResponseWriter, r *http.Request, session sessions.Session, status int) { - response, err := sessionResponse(session, h.executorURL()) + response, err := sessionResponse(session, h.Execution.ExecutorURL) if err != nil { writeSessionsError(w, r, err) return @@ -327,7 +313,7 @@ func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) { } response := v1.SessionList{Data: make([]v1.Session, 0, len(page.Sessions)), HasMore: page.NextCursor != ""} for _, session := range page.Sessions { - item, err := sessionResponse(session, h.executorURL()) + item, err := sessionResponse(session, h.Execution.ExecutorURL) if err != nil { writeSessionsError(w, r, err) return diff --git a/services/core/internal/api/handler_test.go b/services/core/internal/api/handler_test.go index 9539b46f1..84b361584 100644 --- a/services/core/internal/api/handler_test.go +++ b/services/core/internal/api/handler_test.go @@ -75,10 +75,9 @@ func testHandler(t *testing.T, configure ...func(*Dependencies, *testFakes)) (ht return newTestHandler(t, deps), s, tenant } -// admitSessions enables Execution whose Worker admits Session creation, as it -// does for a Session with initial input, into the recording store. +// admitSessions makes the Worker admit Session creation, as it does for a +// Session with initial input, into the recording store. func admitSessions(d *Dependencies, f *testFakes) { - d.Execution = f.execution() f.sessionAdmission.createSession = f.sessionCreation.createSession } @@ -144,10 +143,7 @@ func TestHTTPRejectsUntrustedOrUnsupportedRequests(t *testing.T) { {"invalid auth", "Bearer wrong", "agents=v1", "/v1/agents/sessions", valid, 401}, {"missing beta", "Bearer test-api-key", "", "/v1/agents/sessions", valid, 400}, {"tenant body", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"tenant_id":"other","agent":`, 1), 400}, - {"hosted environment without managed deployment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(strings.Replace(valid, `"none"`, `"openai_hosted"`, 1), `"input":`, fixtureSessionProvider+`,"input":`, 1), 503}, {"self-hosted environment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"self_hosted"`, 1), 400}, - {"initial input", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", valid, 503}, - {"stream unavailable", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"stream":true,"agent":`, 1), 503}, {"unknown saved agent", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"agent_id":"saved","agent":`, 1), 404}, {"saved agent without its model provider bundle", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"agent_id":"saved","agent":`, 1), 500}, {"unknown agent option", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"model":`, `"tools":[{}],"model":`, 1), 400}, diff --git a/services/core/internal/api/harness_test.go b/services/core/internal/api/harness_test.go index 57828de4f..38c7715ca 100644 --- a/services/core/internal/api/harness_test.go +++ b/services/core/internal/api/harness_test.go @@ -26,18 +26,13 @@ func TestSessionHarnessAdmission(t *testing.T) { {"empty", `,"x_agents_core":{}`, "", `{"type":"none"}`, "", true, 400}, {"unknown nested", `,"x_agents_core":{"harness":"codex","model":"wrong"}`, "", `{"type":"none"}`, "", true, 400}, {"claude verbosity", `,"x_agents_core":{"harness":"claude_sdk"}`, `,"text":{"verbosity":"high"}`, `{"type":"none"}`, "", true, 400}, - {"mcode self-hosted requires executor configuration", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"self_hosted","workspace_directory":"/workspace"},` + fixtureAnthropicSessionProvider, "", true, 503}, } { t.Run(tc.name, func(t *testing.T) { h, s, _ := testHandler(t, func(d *Dependencies, f *testFakes) { if tc.enabled { d.Harnesses = []string{"claude_sdk", "mcode"} } - // Self-hosted execution needs an executor, which this Core lacks. - if !strings.Contains(tc.environment, "self_hosted") { - admitSessions(d, f) - } - f.metrics.recordUnavailable = func() {} + admitSessions(d, f) }) r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"fixture"`+tc.extension+tc.extra+`},"environment":`+tc.environment+`,"input":"Run on the selected harness."}`)) r.Header.Set("Authorization", "Bearer test-api-key") diff --git a/services/core/internal/api/hosted_environment_test.go b/services/core/internal/api/hosted_environment_test.go index bcc69d9fa..322223cca 100644 --- a/services/core/internal/api/hosted_environment_test.go +++ b/services/core/internal/api/hosted_environment_test.go @@ -89,7 +89,6 @@ func TestHostedCreationUsesExecutionAdmission(t *testing.T) { for _, stream := range []bool{false, true} { recorder := &hostedCreationRecorder{} handler, fixture := environmentCreationHandler(t, "codex", func(d *Dependencies, f *testFakes) { - d.Execution, d.Sandboxes = f.execution(), f.sandboxes() f.sessionAdmission.createSession = recorder.CreateSession }) input := "" diff --git a/services/core/internal/api/inputs.go b/services/core/internal/api/inputs.go index 84a7a0adf..5bc15f86e 100644 --- a/services/core/internal/api/inputs.go +++ b/services/core/internal/api/inputs.go @@ -57,10 +57,6 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) return } - if h.Execution == nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not enabled on this service.") - return - } inputs, err := executionInputs(request.Events) if err != nil { writeSessionsError(w, r, err) diff --git a/services/core/internal/api/inputs_test.go b/services/core/internal/api/inputs_test.go index 4af2099d2..f6cb61e63 100644 --- a/services/core/internal/api/inputs_test.go +++ b/services/core/internal/api/inputs_test.go @@ -25,9 +25,8 @@ func (s *inputRecorder) SubmitInputs(_ context.Context, tenant, session, key str return nil, s.err } -// admit enables Execution whose Worker records submitted inputs in s. +// admit makes the Worker record submitted inputs in s. func (s *inputRecorder) admit(d *Dependencies, f *testFakes) { - d.Execution = f.execution() f.inputAdmission.submitInputs = s.SubmitInputs } diff --git a/services/core/internal/api/installation.go b/services/core/internal/api/installation.go index ec1ba9f55..8d18088ac 100644 --- a/services/core/internal/api/installation.go +++ b/services/core/internal/api/installation.go @@ -11,12 +11,12 @@ import ( // environment and build; configuration is the process settings it loaded. type Installation struct { Object string `json:"object" enums:"core.installation"` - // The ID in OAC_INSTALLATION_ID_FILE; null when Core runs without the sandbox manager. - InstallationID *string `json:"installation_id" extensions:"x-nullable"` - // OAC_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset. - PublicURL *string `json:"public_url" extensions:"x-nullable"` - // public_url followed by /v1; null when public_url is null. - APIBaseURL *string `json:"api_base_url" extensions:"x-nullable"` + // The ID in OAC_INSTALLATION_ID_FILE. + InstallationID string `json:"installation_id"` + // OAC_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use. + PublicURL string `json:"public_url"` + // public_url followed by /v1. + APIBaseURL string `json:"api_base_url"` // True when public_url names a loopback host, reachable only from the Core host. LocalOnly bool `json:"local_only"` // Full source commit Core was built from; null for development builds. diff --git a/services/core/internal/api/installation_test.go b/services/core/internal/api/installation_test.go index c8c047e89..8f65dcce9 100644 --- a/services/core/internal/api/installation_test.go +++ b/services/core/internal/api/installation_test.go @@ -24,7 +24,7 @@ func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) { return deployment.AddressBindings{Nodes: 2, NodesOnOtherAddress: 1}, nil } // No sandbox deployment: the read is available before any deployment exists. - deps.Installation = Installation{InstallationID: &id, PublicURL: &public, Configuration: settings} + deps.Installation = Installation{InstallationID: id, PublicURL: public, APIBaseURL: public + "/v1", Configuration: settings} h := newTestHandler(t, deps) get := func(token string) *httptest.ResponseRecorder { request := httptest.NewRequest(http.MethodGet, "/core/v1/installation", nil) diff --git a/services/core/internal/api/model_provider_fixture_test.go b/services/core/internal/api/model_provider_fixture_test.go index e2e44dcf4..6383c6446 100644 --- a/services/core/internal/api/model_provider_fixture_test.go +++ b/services/core/internal/api/model_provider_fixture_test.go @@ -27,6 +27,3 @@ func fixtureDeploymentProvider(_ context.Context, harness string) (*modelconfigu // fixtureSessionProvider is a top-level Session request member for Codex. const fixtureSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.fixture.example/v1","api_key":"fixture-model-key"}}` - -// fixtureAnthropicSessionProvider is the Claude Code and MiniMax Code equivalent. -const fixtureAnthropicSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"anthropic","base_url":"https://model.fixture.example/anthropic","api_key":"fixture-model-key","context_window":200000,"max_output_tokens":8000}}` diff --git a/services/core/internal/api/native_classification_integration_test.go b/services/core/internal/api/native_classification_integration_test.go index 0a13c9e78..197922fd5 100644 --- a/services/core/internal/api/native_classification_integration_test.go +++ b/services/core/internal/api/native_classification_integration_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -17,8 +18,8 @@ import ( func TestNativeClassificationPostgresRoundTripAndPublicPrivacy(t *testing.T) { s, pool := diagnosticDatabase(t) - h, _, tenant := adminTestHandler(t, databaseSessionReads(pool)) - reader := sessionpg.New(pgunit.NewPool(pool), nil) + h, _, tenant := adminTestHandler(t, databaseSessionReads(t, pool)) + reader := sessionpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) for _, code := range []string{"authentication_error", "connection_failed", "secret-canary"} { t.Run(code, func(t *testing.T) { created, err := s.CreateSession(t.Context(), tenant, sessions.CreateSession{Creator: identity.Subject{Kind: "service_account", ID: "native-classification"}, Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`)}) diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index b888494b3..6b802bd3b 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -97,7 +97,6 @@ func routingFixture(t *testing.T) (http.Handler, *chi.Mux, *routingStore) { return files.File{}, files.ErrNotFound } deps.CoreKeys = coreKeys(t, routingAdminKey) - deps.Execution, deps.Sandboxes = fakes.execution(), fakes.sandboxes() return newTestHandler(t, deps), (&Handler{Dependencies: deps}).routes(), s } diff --git a/services/core/internal/api/sandbox_manager.go b/services/core/internal/api/sandbox_manager.go index 99c814d47..aa1143b3c 100644 --- a/services/core/internal/api/sandbox_manager.go +++ b/services/core/internal/api/sandbox_manager.go @@ -59,9 +59,6 @@ type NodeAllocations interface { // registerSandboxNodeRoutes serves node machine connections. They authenticate // with an enrollment token or node credential, never the Core key. func (h *Handler) registerSandboxNodeRoutes(r chi.Router) { - if h.Sandboxes == nil { - return - } r.Post("/api/v1/sandbox-node/enroll", h.enrollSandboxNode) r.Get("/api/v1/sandbox-node/identity", h.sandboxNodeIdentity) r.Get("/api/v1/sandbox-node/configuration", h.sandboxNodeConfiguration) @@ -70,9 +67,6 @@ func (h *Handler) registerSandboxNodeRoutes(r chi.Router) { // registerSandboxManagerRoutes adds sandbox deployment and node administration // to the Core-key-authenticated /core/v1 router. func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { - if h.Sandboxes == nil { - return - } r.Get("/sandbox/deployment", h.sandboxDeployment) r.Post("/sandbox/providers/{provider}/discovery", h.discoverSandboxConfiguration) r.Post("/sandbox/deployment", h.initializeSandboxDeployment) diff --git a/services/core/internal/api/sandbox_manager_test.go b/services/core/internal/api/sandbox_manager_test.go index 6e7959622..aff6e084b 100644 --- a/services/core/internal/api/sandbox_manager_test.go +++ b/services/core/internal/api/sandbox_manager_test.go @@ -18,7 +18,6 @@ func sandboxFakes(t testing.TB) (Dependencies, *testFakes) { deps, fakes := testDependencies(t) fakes.projectsReader.resolveAPIKey = projectKeys(t, callerBinding()).ResolveAPIKey deps.CoreKeys = coreKeys(t, "administrator") - deps.Execution, deps.Sandboxes = fakes.execution(), fakes.sandboxes() return deps, fakes } diff --git a/services/core/internal/api/sandbox_selector_test.go b/services/core/internal/api/sandbox_selector_test.go index f70dc9dd9..00bf02154 100644 --- a/services/core/internal/api/sandbox_selector_test.go +++ b/services/core/internal/api/sandbox_selector_test.go @@ -33,7 +33,6 @@ func TestSessionCreationRejectsSandboxNodeSelector(t *testing.T) { } { recorder := &sandboxCreationRecorder{} handler, _ := environmentCreationHandler(t, "codex", func(d *Dependencies, f *testFakes) { - d.Execution, d.Sandboxes = f.execution(), f.sandboxes() f.sessionAdmission.createSession = recorder.CreateSession }) request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) diff --git a/services/core/internal/api/session_admission_test.go b/services/core/internal/api/session_admission_test.go index aedd04a7b..66e6ce827 100644 --- a/services/core/internal/api/session_admission_test.go +++ b/services/core/internal/api/session_admission_test.go @@ -28,7 +28,7 @@ func TestSessionAdmissionRejectsBeforeResourceOrExecutionAccess(t *testing.T) { } t.Run(fmt.Sprintf("%s/%s/stream=%t", environment, input, stream), func(t *testing.T) { // Any resource access, including creation retry lookup, fails the test. - handler, _, _ := testHandler(t, forbidSessionAccess, func(d *Dependencies, f *testFakes) { d.Execution = f.execution() }) + handler, _, _ := testHandler(t, forbidSessionAccess) body := fmt.Sprintf(`{"agent":{"model":"example"},"environment":{"type":%q},"stream":%t%s}`, environment, stream, input) for _, token := range []string{"test-api-key", "invalid"} { request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) diff --git a/services/core/internal/api/session_creation_stream.go b/services/core/internal/api/session_creation_stream.go index 7ee530d23..73039138e 100644 --- a/services/core/internal/api/session_creation_stream.go +++ b/services/core/internal/api/session_creation_stream.go @@ -22,7 +22,7 @@ func (h *Handler) respondSessionCreationStream(w http.ResponseWriter, r *http.Re openEventStream(w, http.StatusCreated) return } - response, err := sessionResponse(result.Session, h.executorURL()) + response, err := sessionResponse(result.Session, h.Execution.ExecutorURL) if err != nil { writeSessionsError(w, r, err) return @@ -45,7 +45,7 @@ func (h *Handler) respondSessionCreationStream(w http.ResponseWriter, r *http.Re if err != nil { return false, 0, err } - response, err := sessionResponse(session, h.executorURL()) + response, err := sessionResponse(session, h.Execution.ExecutorURL) return err == nil && sessionSettled(session, response), cursor, err } h.serveSessionEvents(w, r, result.Session, result.Cursor, &created, http.StatusCreated, settlement) diff --git a/services/core/internal/api/session_creation_stream_test.go b/services/core/internal/api/session_creation_stream_test.go index 589e32281..5f0fa0e1a 100644 --- a/services/core/internal/api/session_creation_stream_test.go +++ b/services/core/internal/api/session_creation_stream_test.go @@ -149,7 +149,6 @@ func newCreationStreamHarness(t *testing.T) *creationStreamHarness { fakes.sessionCreation.findSessionCreation = fixture.FindSessionCreation fakes.sessionEvents.sessionEventCursor, fakes.sessionEvents.sessionStreamSnapshot, fakes.sessionEvents.listSessionEvents = fixture.SessionEventCursor, fixture.SessionStreamSnapshot, fixture.ListSessionEvents fakes.modelProviders.resolve = fixtureDeploymentProvider - deps.Execution = fakes.execution() fakes.sessionAdmission.createSession = fixture.CreateSession handler := newTestHandler(t, deps) h := &creationStreamHarness{t: t, fixture: fixture} diff --git a/services/core/internal/api/session_diagnostics.go b/services/core/internal/api/session_diagnostics.go index 8eef13aac..9e14f19b5 100644 --- a/services/core/internal/api/session_diagnostics.go +++ b/services/core/internal/api/session_diagnostics.go @@ -72,7 +72,7 @@ func (h *Handler) getSessionDiagnostics(w http.ResponseWriter, r *http.Request) writeSessionsError(w, r, err) return } - public, err := sessionResponse(session, h.executorURL()) + public, err := sessionResponse(session, h.Execution.ExecutorURL) if err != nil { writeSessionsError(w, r, err) return diff --git a/services/core/internal/api/session_diagnostics_public_compat_test.go b/services/core/internal/api/session_diagnostics_public_compat_test.go index 4394dd0b2..78715c1ea 100644 --- a/services/core/internal/api/session_diagnostics_public_compat_test.go +++ b/services/core/internal/api/session_diagnostics_public_compat_test.go @@ -12,6 +12,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -27,7 +28,7 @@ func TestDiagnosticPublicCompatibility(t *testing.T) { key := callerBinding() deps, fakes := testDependencies(t) fakes.projectsReader.resolveAPIKey = projectKeys(t, key).ResolveAPIKey - databaseSessionReads(pool)(&deps, fakes) + databaseSessionReads(t, pool)(&deps, fakes) h := newTestHandler(t, deps) created, err := s.CreateSession(t.Context(), key.TenantID, sessions.CreateSession{Creator: identity.Subject{Kind: "service_account", ID: "compat-test"}, Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`)}) if err != nil { @@ -67,9 +68,9 @@ func diagnosticRequest(handler http.Handler, path, token string) *httptest.Respo // databaseSessionReads serves Session, Turn, diagnostic and Item reads from // the Session adapter on pool. -func databaseSessionReads(pool *pgxpool.Pool) func(*Dependencies, *testFakes) { +func databaseSessionReads(t *testing.T, pool *pgxpool.Pool) func(*Dependencies, *testFakes) { return func(d *Dependencies, _ *testFakes) { - reader := sessionpg.New(pgunit.NewPool(pool), nil) + reader := sessionpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) d.SessionsReader, d.SessionAdmin, d.Items, d.Turns = reader, reader, reader, reader } } @@ -78,7 +79,7 @@ func databaseSessionReads(pool *pgxpool.Pool) func(*Dependencies, *testFakes) { // pool. func submitMessage(t *testing.T, pool *pgxpool.Pool, tenant, session, key, text string) sessions.InputReceipt { t.Helper() - service, err := sessions.NewService(sessionpg.New(pgunit.NewPool(pool), nil), nil) + service, err := sessions.NewService(sessionpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)), nil) if err != nil { t.Fatal(err) } @@ -138,7 +139,7 @@ func diagnosticDatabase(t *testing.T) (*sessions.Service, *pgxpool.Pool) { if err = migrations.Apply(t.Context(), dsn); err != nil { t.Fatal(err) } - service, err := sessions.NewService(sessionpg.New(pgunit.NewPool(pool), nil), nil) + service, err := sessions.NewService(sessionpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)), nil) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/api/session_diagnostics_test.go b/services/core/internal/api/session_diagnostics_test.go index 21ad01899..3265feb8a 100644 --- a/services/core/internal/api/session_diagnostics_test.go +++ b/services/core/internal/api/session_diagnostics_test.go @@ -14,7 +14,7 @@ import ( func TestDiagnosticsCoreHandlerDatabaseBoundary(t *testing.T) { s, pool := diagnosticDatabase(t) - h, _, tenant := adminTestHandler(t, databaseSessionReads(pool)) + h, _, tenant := adminTestHandler(t, databaseSessionReads(t, pool)) created, err := s.CreateSession(t.Context(), tenant, sessions.CreateSession{Creator: identity.Subject{Kind: "service_account", ID: "diagnostic-test"}, Engine: "codex", IdempotencyKey: "diagnostics", Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`)}) if err != nil { t.Fatal(err) diff --git a/services/core/internal/api/session_environment_http_test.go b/services/core/internal/api/session_environment_http_test.go index d607345c2..ab7d088a1 100644 --- a/services/core/internal/api/session_environment_http_test.go +++ b/services/core/internal/api/session_environment_http_test.go @@ -49,8 +49,7 @@ func TestSelfHostedSessionHTTPReadListMetadataAndLiveStream(t *testing.T) { }).ResolveAPIKey fixture.serve(fakes) fakes.sessionsReader.listSessions, fakes.sessions.updateSessionMetadata = fixture.ListSessions, fixture.UpdateSessionMetadata - // Self-hosted Sessions report the executor URL of the enabled Execution. - deps.Execution = fakes.execution() + // Self-hosted Sessions report the executor URL. deps.Execution.ExecutorURL = environmentOrigin handler := newTestHandler(t, deps) want, err := sessionResponse(session, environmentOrigin) diff --git a/services/core/internal/api/session_initial_input_test.go b/services/core/internal/api/session_initial_input_test.go index 58f716c3a..b778c836f 100644 --- a/services/core/internal/api/session_initial_input_test.go +++ b/services/core/internal/api/session_initial_input_test.go @@ -8,12 +8,11 @@ import ( "testing" ) -// admitInto enables Execution whose Worker admits Session creation into s, +// admitInto makes the Worker admit Session creation into s, // apart from the store that creates Sessions without execution work. Input // submission stays unexpected. func admitInto(s *recordingStore) func(*Dependencies, *testFakes) { return func(d *Dependencies, f *testFakes) { - d.Execution = f.execution() f.sessionAdmission.createSession = s.CreateSession } } diff --git a/services/core/internal/api/stream.go b/services/core/internal/api/stream.go index cfda35b6f..983472b0d 100644 --- a/services/core/internal/api/stream.go +++ b/services/core/internal/api/stream.go @@ -31,7 +31,7 @@ func (h *Handler) streamEvents(w http.ResponseWriter, r *http.Request) { writeSessionsError(w, r, err) return } - if _, err = sessionResponse(session, h.executorURL()); err != nil { + if _, err = sessionResponse(session, h.Execution.ExecutorURL); err != nil { writeSessionsError(w, r, err) return } @@ -113,7 +113,7 @@ func (h *Handler) serveSessionEvents(w http.ResponseWriter, r *http.Request, ses if limit >= 0 && change.Sequence > limit { return } - event, err := streamResponse(session, change, h.executorURL()) + event, err := streamResponse(session, change, h.Execution.ExecutorURL) if err != nil { writeStreamFailure(write, id) return diff --git a/services/core/internal/api/testdata/core-errors.json b/services/core/internal/api/testdata/core-errors.json index 256654003..74e75ca84 100644 --- a/services/core/internal/api/testdata/core-errors.json +++ b/services/core/internal/api/testdata/core-errors.json @@ -4,7 +4,6 @@ "console_sign_in_required", "core_metrics_unavailable", "core_unreachable", - "credential_storage_unavailable", "environment_unavailable", "execution_unavailable", "executor_credential_exists", diff --git a/services/core/internal/api/validation_errors_test.go b/services/core/internal/api/validation_errors_test.go index e0ec582f7..0c84bda60 100644 --- a/services/core/internal/api/validation_errors_test.go +++ b/services/core/internal/api/validation_errors_test.go @@ -61,7 +61,6 @@ func (s *validationStore) UpdateEnvironmentTemplate(_ context.Context, command e // serve takes every write from the handler, and the Worker admits Sessions // with initial input into s. Session reads are unexpected. func (s *validationStore) serve(d *Dependencies, f *testFakes) { - d.Execution = f.execution() f.sessionAdmission.createSession = s.CreateSession f.agents.create, f.agents.update = s.CreateAgent, s.UpdateAgent f.vaults.createVault = s.CreateVault diff --git a/services/core/internal/credentialcrypto/cipher.go b/services/core/internal/credentialcrypto/cipher.go index 552bfb7fb..8a9136545 100644 --- a/services/core/internal/credentialcrypto/cipher.go +++ b/services/core/internal/credentialcrypto/cipher.go @@ -18,10 +18,6 @@ const ( bindingDomain = "parsar.agents-api.credential" ) -// ErrUnavailable reports that this service has no credential encryption key, -// so it can neither store nor read credential secrets. -var ErrUnavailable = errors.New("credential encryption is not configured") - var ( errInvalidKey = errors.New("credentialcrypto: invalid encryption key") errUnavailable = errors.New("credentialcrypto: cipher unavailable") diff --git a/services/core/internal/db/queries/sandbox_deployment_setup.sql b/services/core/internal/db/queries/sandbox_deployment_setup.sql index 09bd91540..6365c7e0f 100644 --- a/services/core/internal/db/queries/sandbox_deployment_setup.sql +++ b/services/core/internal/db/queries/sandbox_deployment_setup.sql @@ -4,7 +4,7 @@ owner_epoch=owner_epoch+1, updated_at=clock_timestamp() WHERE singleton=true; -- name: InitializeSandboxDeployment :exec UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, -idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, +generation=$3, mode=$4, provider_config=sqlc.arg(provider_config),provider_metadata=sqlc.arg(provider_metadata),provider_credential=sqlc.arg(provider_credential),specification=sqlc.arg(specification), updated_at=clock_timestamp() WHERE singleton=true; diff --git a/services/core/internal/db/queries/sandbox_reset.sql b/services/core/internal/db/queries/sandbox_reset.sql index 0c2938bb1..5b53b06e5 100644 --- a/services/core/internal/db/queries/sandbox_reset.sql +++ b/services/core/internal/db/queries/sandbox_reset.sql @@ -21,8 +21,7 @@ WHERE singleton = true; -- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', - specification = '{}', idle_seconds = 0, retention_seconds = 0, - provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, + specification = '{}', provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, reset_clear = NULL, reset_requested_at = NULL, reset_deadline_at = NULL, reset_forced_at = NULL, reset_audit = NULL, diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index 25a10a552..be47f8926 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -246,8 +246,6 @@ type RuntimeDeployment struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` ProviderKind string `json:"provider_kind"` OwnerEpoch int64 `json:"owner_epoch"` - IdleSeconds int64 `json:"idle_seconds"` - RetentionSeconds int64 `json:"retention_seconds"` Generation int64 `json:"generation"` Mode string `json:"mode"` ProviderCredential []byte `json:"provider_credential"` diff --git a/services/core/internal/db/sqlc/runtime_deployment.sql.go b/services/core/internal/db/sqlc/runtime_deployment.sql.go index 15d71eef4..56e51e53a 100644 --- a/services/core/internal/db/sqlc/runtime_deployment.sql.go +++ b/services/core/internal/db/sqlc/runtime_deployment.sql.go @@ -53,7 +53,7 @@ func (q *Queries) CountRuntimeDeploymentResources(ctx context.Context) (CountRun } const lockRuntimeDeployment = `-- name: LockRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true FOR UPDATE +SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true FOR UPDATE ` func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -66,8 +66,6 @@ func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.UpdatedAt, &i.ProviderKind, &i.OwnerEpoch, - &i.IdleSeconds, - &i.RetentionSeconds, &i.Generation, &i.Mode, &i.ProviderCredential, diff --git a/services/core/internal/db/sqlc/runtime_nodes.sql.go b/services/core/internal/db/sqlc/runtime_nodes.sql.go index e8ee97976..f6bc5b7d7 100644 --- a/services/core/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_nodes.sql.go @@ -119,7 +119,7 @@ func (q *Queries) DisconnectRuntimeNode(ctx context.Context, arg DisconnectRunti } const getRuntimeDeployment = `-- name: GetRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton=true +SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton=true ` func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -132,8 +132,6 @@ func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.UpdatedAt, &i.ProviderKind, &i.OwnerEpoch, - &i.IdleSeconds, - &i.RetentionSeconds, &i.Generation, &i.Mode, &i.ProviderCredential, diff --git a/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go b/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go index de2ee8d6f..9b169c9bf 100644 --- a/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go +++ b/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go @@ -32,16 +32,14 @@ func (q *Queries) ClaimWebSandboxDeployment(ctx context.Context, installationID const initializeSandboxDeployment = `-- name: InitializeSandboxDeployment :exec UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, -idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, -provider_config=$7,provider_metadata=$8,provider_credential=$9,specification=$10, +generation=$3, mode=$4, +provider_config=$5,provider_metadata=$6,provider_credential=$7,specification=$8, updated_at=clock_timestamp() WHERE singleton=true ` type InitializeSandboxDeploymentParams struct { ProviderKind string `json:"provider_kind"` BackendFingerprint string `json:"backend_fingerprint"` - IdleSeconds int64 `json:"idle_seconds"` - RetentionSeconds int64 `json:"retention_seconds"` Generation int64 `json:"generation"` Mode string `json:"mode"` ProviderConfig []byte `json:"provider_config"` @@ -54,8 +52,6 @@ func (q *Queries) InitializeSandboxDeployment(ctx context.Context, arg Initializ _, err := q.db.Exec(ctx, initializeSandboxDeployment, arg.ProviderKind, arg.BackendFingerprint, - arg.IdleSeconds, - arg.RetentionSeconds, arg.Generation, arg.Mode, arg.ProviderConfig, diff --git a/services/core/internal/db/sqlc/sandbox_reset.sql.go b/services/core/internal/db/sqlc/sandbox_reset.sql.go index 3791f0e5d..8929d242b 100644 --- a/services/core/internal/db/sqlc/sandbox_reset.sql.go +++ b/services/core/internal/db/sqlc/sandbox_reset.sql.go @@ -25,8 +25,7 @@ func (q *Queries) CancelSandboxReset(ctx context.Context) error { const completeSandboxReset = `-- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', - specification = '{}', idle_seconds = 0, retention_seconds = 0, - provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, + specification = '{}', provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, reset_clear = NULL, reset_requested_at = NULL, reset_deadline_at = NULL, reset_forced_at = NULL, reset_audit = NULL, @@ -51,7 +50,7 @@ func (q *Queries) ForceSandboxReset(ctx context.Context) error { } const getSandboxDeploymentSnapshot = `-- name: GetSandboxDeploymentSnapshot :one -WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true LIMIT 1), +WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true LIMIT 1), observed AS MATERIALIZED (SELECT clock_timestamp() AS as_of), held AS ( SELECT a.deployment_generation, a.node_id, s.id AS session_id, e.id AS environment_id, false AS pending, @@ -89,7 +88,7 @@ held AS ( ), offline AS ( SELECT node_id, name, count(*)::bigint AS resources FROM classified WHERE offline GROUP BY node_id, name ) -SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.updated_at, d.provider_kind, d.owner_epoch, d.idle_seconds, d.retention_seconds, d.generation, d.mode, d.provider_credential, d.specification, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.provider_config, d.provider_metadata, +SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.updated_at, d.provider_kind, d.owner_epoch, d.generation, d.mode, d.provider_credential, d.specification, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.provider_config, d.provider_metadata, (SELECT count(*) FROM classified WHERE NOT pending)::bigint AS allocations, (SELECT count(*) FROM classified WHERE pending)::bigint AS pending, jsonb_build_object( @@ -131,8 +130,6 @@ func (q *Queries) GetSandboxDeploymentSnapshot(ctx context.Context) (GetSandboxD &i.RuntimeDeployment.UpdatedAt, &i.RuntimeDeployment.ProviderKind, &i.RuntimeDeployment.OwnerEpoch, - &i.RuntimeDeployment.IdleSeconds, - &i.RuntimeDeployment.RetentionSeconds, &i.RuntimeDeployment.Generation, &i.RuntimeDeployment.Mode, &i.RuntimeDeployment.ProviderCredential, diff --git a/services/core/internal/deployment/errors.go b/services/core/internal/deployment/errors.go index ebb74367a..5357bd5e1 100644 --- a/services/core/internal/deployment/errors.go +++ b/services/core/internal/deployment/errors.go @@ -20,6 +20,9 @@ var ( ErrNotConfigured = errors.New("the sandbox deployment is not configured") ErrNodeInUse = errors.New("sandbox node retains resources") ErrNodeCredential = errors.New("invalid sandbox node credential") + // ErrCredentialUnreadable reports a stored credential the credential key + // cannot open or authenticate, such as after the key was replaced. + ErrCredentialUnreadable = errors.New("sandbox deployment credential decryption failed") // ErrAllocationConflict rejects an allocation change whose owner no longer // matches the stored allocation, device binding, state or compute revision, // or a replay for another installation. diff --git a/services/core/internal/deployment/execution.go b/services/core/internal/deployment/execution.go index 0c1ef29fa..b91abceb3 100644 --- a/services/core/internal/deployment/execution.go +++ b/services/core/internal/deployment/execution.go @@ -298,7 +298,7 @@ func (e *ExecutionOperations) saveSelection(tx DeploymentTx, d Record, input san return err } return tx.SaveSelection(SelectionRecord{InstallationID: d.InstallationID, Provider: input.Provider, BackendFingerprint: description.BackendFingerprint, Mode: description.Mode, - Generation: d.Generation + 1, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds, Specification: specification, + Generation: d.Generation + 1, Specification: specification, Configuration: sandbox.ConfigurationRecord{Public: configurationJSON(record.Public), Metadata: configurationJSON(record.Metadata), Secret: record.Secret}}) } diff --git a/services/core/internal/deployment/node.go b/services/core/internal/deployment/node.go index 2509363da..485d464e8 100644 --- a/services/core/internal/deployment/node.go +++ b/services/core/internal/deployment/node.go @@ -46,7 +46,7 @@ type Enrollment struct { type NodeHealth struct { Host *NodeHost `json:"-"` // Fixed reason for the last reported unreadiness; absent while the provider is ready. Clients treat an unknown value as provider_unavailable. - Diagnostic string `json:"diagnostic,omitempty" enums:"provider_unavailable,docker_unavailable,docker_limits_unsupported,runtime_download_failed,runtime_image_unavailable,kvm_unavailable,microsandbox_artifacts_unavailable,capacity_insufficient"` + Diagnostic string `json:"diagnostic,omitempty" enums:"provider_unavailable,host_unsupported,artifacts_unavailable,runtime_download_failed,runtime_image_unavailable,capacity_insufficient"` ProviderReady bool `json:"provider_ready"` CPUCount *int64 `json:"cpu_count"` AvailableMemoryBytes *int64 `json:"available_memory_bytes"` diff --git a/services/core/internal/deployment/placement/placement.go b/services/core/internal/deployment/placement/placement.go index bd61fd401..52669c64c 100644 --- a/services/core/internal/deployment/placement/placement.go +++ b/services/core/internal/deployment/placement/placement.go @@ -49,7 +49,7 @@ type Rules struct { } // NewRules returns the rules for the provider declarations and the -// installation public URL, which is empty when the installation has none. +// installation public URL. func NewRules(declarations Declarations, publicURL string) (*Rules, error) { if declarations == nil { return nil, errors.New("placement rules require provider declarations") diff --git a/services/core/internal/deployment/rules_test.go b/services/core/internal/deployment/rules_test.go index 0a39484cb..c7a505126 100644 --- a/services/core/internal/deployment/rules_test.go +++ b/services/core/internal/deployment/rules_test.go @@ -293,7 +293,7 @@ func TestNodeRollout(t *testing.T) { {"failed without diagnostic", NodeRecord{Online: true, ProtocolVersion: 2, TargetState: "failed", ReadyGeneration: &ready}, "failed", ""}, {"ready ignores a diagnostic", NodeRecord{Online: true, ProtocolVersion: 2, TargetState: "ready", TargetDiagnostic: "boom", ReadyGeneration: &ready}, "ready", ""}, {"unknown target state", NodeRecord{Online: true, ProtocolVersion: 2, TargetState: "other", ReadyGeneration: &ready}, "unknown", ""}, - {"protocol 1 failed on the target", NodeRecord{Online: true, ProtocolVersion: 1, DeploymentGeneration: 2, TargetGeneration: 2, TargetState: "failed", TargetDiagnostic: "kvm_unavailable", ReadyGeneration: &ready}, "failed", "kvm_unavailable"}, + {"protocol 1 failed on the target", NodeRecord{Online: true, ProtocolVersion: 1, DeploymentGeneration: 2, TargetGeneration: 2, TargetState: "failed", TargetDiagnostic: "host_unsupported", ReadyGeneration: &ready}, "failed", "host_unsupported"}, {"protocol 1 without a target state", NodeRecord{Online: true, ProtocolVersion: 1, DeploymentGeneration: 2, TargetGeneration: 2, ReadyGeneration: &ready}, "unknown", ""}, } { got := nodeRollout(c.n) diff --git a/services/core/internal/deployment/service.go b/services/core/internal/deployment/service.go index 2eb3eda9c..ae96ff02f 100644 --- a/services/core/internal/deployment/service.go +++ b/services/core/internal/deployment/service.go @@ -64,13 +64,9 @@ func (s *Service) view(snapshot Snapshot) (View, error) { result.Configuration = configurationJSON(record.Public) result.Metadata = configurationJSON(record.Metadata) result.CredentialConfigured = d.CredentialStored - checkpoint, err := s.registry.SupportsCheckpoint(d.Provider) - if err != nil { + if result.Suspension, err = s.suspension(d.Provider); err != nil { return View{}, err } - if checkpoint { - result.Suspension = &Suspension{IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds} - } } if d.Reset != nil { result.Reset = &Reset{Clear: d.Reset.Clear, RequestedAt: d.Reset.RequestedAt, DeadlineAt: d.Reset.DeadlineAt, ForcedAt: d.Reset.ForcedAt, Remaining: snapshot.Remaining} @@ -111,24 +107,20 @@ func (s *Service) setup(d Record) (Setup, error) { if err != nil { return Setup{}, ErrConflict } - return s.describe(result, d.IdleSeconds, d.RetentionSeconds) + return s.describe(result) } // describe adds the provider's declared operations, suspension policy and // credential use. -func (s *Service) describe(setup Setup, idleSeconds, retentionSeconds int64) (Setup, error) { +func (s *Service) describe(setup Setup) (Setup, error) { adapter, err := s.registry.Lookup(setup.Provider) if err != nil { return Setup{}, err } setup.Operations = adapter.Operations() - checkpoint, err := s.registry.SupportsCheckpoint(setup.Provider) - if err != nil { + if setup.Suspension, err = s.suspension(setup.Provider); err != nil { return Setup{}, err } - if checkpoint { - setup.Suspension = &Suspension{IdleSeconds: idleSeconds, RetentionSeconds: retentionSeconds} - } setup.UsesCredential, err = s.registry.UsesCredential(setup.Provider) if err != nil { return Setup{}, err @@ -253,7 +245,17 @@ func (s *Service) SetupForSelection(installationID string, input sandbox.Selecti return Setup{}, err } result := Setup{InstallationID: installationID, Provider: input.Provider, Mode: description.Mode, Specification: normalized.DeploymentSpec, Configuration: normalized.Configuration, BackendFingerprint: description.BackendFingerprint} - return s.describe(result, description.IdleSeconds, description.RetentionSeconds) + return s.describe(result) +} + +// suspension returns Core's idle suspension policy for a provider that +// declares checkpoint support, and nil for any other provider. +func (s *Service) suspension(provider string) (*Suspension, error) { + checkpoint, err := s.registry.SupportsCheckpoint(provider) + if err != nil || !checkpoint { + return nil, err + } + return &Suspension{IdleSeconds: 5 * 60, RetentionSeconds: 24 * 60 * 60}, nil } // validateSelection rejects a selection its provider cannot normalize. diff --git a/services/core/internal/deployment/setup.go b/services/core/internal/deployment/setup.go index 2f17624b0..dae24b6c7 100644 --- a/services/core/internal/deployment/setup.go +++ b/services/core/internal/deployment/setup.go @@ -21,8 +21,8 @@ type Setup struct { // transport proxies. Operations providercontract.Operations Configuration sandbox.Configuration `json:"-"` - // Suspension is the idle suspension policy of a provider that suspends - // sandboxes, and nil otherwise. + // Suspension is the idle suspension policy of a provider that declares + // checkpoint support, and nil otherwise. Suspension *Suspension // UsesCredential reports whether the provider's configuration carries a // credential. diff --git a/services/core/internal/deployment/storage.go b/services/core/internal/deployment/storage.go index 0dc062914..ab6dac7f9 100644 --- a/services/core/internal/deployment/storage.go +++ b/services/core/internal/deployment/storage.go @@ -303,8 +303,7 @@ type DeploymentTx interface { // previous owner epoch's node presence. ClaimInstallation(installationID string) error // SaveSelection stores the next generation. It seals a secret bound to the - // installation and generation; without a key it returns - // credentialcrypto.ErrUnavailable. + // installation and generation. SaveSelection(selection SelectionRecord) error RecordConfigurationMetadata(metadata json.RawMessage) error // RetainGeneration keeps the current generation for the allocations that @@ -344,17 +343,14 @@ type Record struct { Generation uint64 OwnerEpoch uint64 Mode string - IdleSeconds int64 - RetentionSeconds int64 // Specification is the stored specification document. Specification json.RawMessage // Configuration holds the public configuration and metadata and, when a // credential is stored and could be opened, its secret. Configuration sandbox.ConfigurationRecord CredentialStored bool - // CredentialError is why the stored credential could not be opened: no key - // (credentialcrypto.ErrUnavailable) or a ciphertext the key cannot open or - // authenticate (an internal error). + // CredentialError is why the stored credential could not be opened: a + // ciphertext the key cannot open or authenticate (an internal error). CredentialError error // Reset is nil unless a reset is in progress. Reset *ResetState @@ -380,7 +376,6 @@ type Snapshot struct { type SelectionRecord struct { InstallationID, Provider, BackendFingerprint, Mode string Generation uint64 - IdleSeconds, RetentionSeconds int64 Specification json.RawMessage // Configuration carries the secret in plaintext; the adapter seals it. Configuration sandbox.ConfigurationRecord diff --git a/services/core/internal/deployment/view.go b/services/core/internal/deployment/view.go index 806a138fd..4c6beb6bc 100644 --- a/services/core/internal/deployment/view.go +++ b/services/core/internal/deployment/view.go @@ -18,7 +18,7 @@ type View struct { Configuration json.RawMessage `json:"configuration,omitempty" swaggertype:"object"` Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` CredentialConfigured bool `json:"credential_configured"` - // Idle suspension policy; microsandbox only, otherwise null. + // Idle suspension policy; null unless the selected Provider declares checkpoint support. Suspension *Suspension `json:"suspension" extensions:"x-nullable"` InstallationID string `json:"installation_id"` Provider string `json:"provider"` @@ -34,8 +34,8 @@ type Resources struct { Pending int64 `json:"pending"` } -// Suspension is the idle suspension policy. Only microsandbox suspends -// sandboxes; Docker and E2B deployments return null. +// Suspension is Core's idle suspension policy, which applies to every Provider +// that declares checkpoint support. type Suspension struct { IdleSeconds int64 `json:"idle_seconds"` RetentionSeconds int64 `json:"retention_seconds"` @@ -46,7 +46,7 @@ type NodeRollout struct { State string `json:"state" enums:"ready,preparing,failed,update_required,unknown"` // Durable serving-generation pin; online and provider_ready still gate placement. ReadyGeneration *uint64 `json:"ready_generation" extensions:"x-nullable"` - Diagnostic string `json:"diagnostic,omitempty" enums:"provider_unavailable,docker_unavailable,docker_limits_unsupported,runtime_download_failed,runtime_image_unavailable,kvm_unavailable,microsandbox_artifacts_unavailable,capacity_insufficient"` + Diagnostic string `json:"diagnostic,omitempty" enums:"provider_unavailable,host_unsupported,artifacts_unavailable,runtime_download_failed,runtime_image_unavailable,capacity_insufficient"` } type RolloutNodes struct { diff --git a/services/core/internal/environmenttemplates/template.go b/services/core/internal/environmenttemplates/template.go index dfb868cc0..7c6209ced 100644 --- a/services/core/internal/environmenttemplates/template.go +++ b/services/core/internal/environmenttemplates/template.go @@ -11,7 +11,7 @@ import ( ) // Template is a saved Template's safe metadata: it holds no confidential -// setup, file contents or archives, so reading it needs no credential key. +// setup, file contents or archives. type Template struct { ID string Name *string diff --git a/services/core/internal/execution/archive_cancellation_cleanup_test.go b/services/core/internal/execution/archive_cancellation_cleanup_test.go index a9043697c..8129521b9 100644 --- a/services/core/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/core/internal/execution/archive_cancellation_cleanup_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/projectpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" @@ -78,7 +79,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { if err != nil { t.Fatal(err) } - _, service := testSessions(t, pool, testCredentialCipher(t)) + _, service := testSessions(t, pool, pgtest.CredentialKey(t)) created, err := service.CreateSession(t.Context(), project.TenantID, sessions.CreateSession{Creator: identity.Subject{Kind: "service_account", ID: "fixture"}, Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`), ModelProvider: &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-key"}, ModelProviderSource: v1.ModelProviderSourceSession}) if err != nil { t.Fatal(err) @@ -118,7 +119,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { if scenario.delivery { server := httptest.NewUnstartedServer(nil) wsURL := "ws://" + server.Listener.Addr().String() + "/api/v1/agent-daemon/ws" - credentials, heartbeat := testSessions(t, pool, testCredentialCipher(t)) + credentials, heartbeat := testSessions(t, pool, pgtest.CredentialKey(t)) handler, liveRegistry, err := runtime.NewGateway(credentials, heartbeat, credentials, runtimegateway.NewLinkAuthority(credentials), wsURL) if err != nil { t.Fatal(err) @@ -158,7 +159,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { if err != nil { t.Fatal(err) } - sessionReader, _ := testSessions(t, pool, testCredentialCipher(t)) + sessionReader, _ := testSessions(t, pool, pgtest.CredentialKey(t)) kills := 0 expectedStatus := sessions.TurnWaiting provider := waitingCleanupProvider{beforeKill: func() { diff --git a/services/core/internal/execution/deployment_fixture_test.go b/services/core/internal/execution/deployment_fixture_test.go index 6562e189a..1cb8c27b5 100644 --- a/services/core/internal/execution/deployment_fixture_test.go +++ b/services/core/internal/execution/deployment_fixture_test.go @@ -35,7 +35,6 @@ func fixtureRules(t *testing.T) *placement.Rules { // testDeployment builds the pooled deployment service and reader and the // deployment execution operations on lease, as cmd/server does for the Worker. -// cipher is nil when the owner has no credential key. func testDeployment(t *testing.T, pool *pgxpool.Pool, cipher *credentialcrypto.Cipher, lease *pgunit.Lease) (*deployment.Service, deployment.Reader, *deployment.ExecutionOperations) { t.Helper() adapter := deploymentpg.New(pgunit.NewPool(pool), cipher) diff --git a/services/core/internal/execution/deployment_provider_observations_test.go b/services/core/internal/execution/deployment_provider_observations_test.go index d2b3c7539..440da13b2 100644 --- a/services/core/internal/execution/deployment_provider_observations_test.go +++ b/services/core/internal/execution/deployment_provider_observations_test.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -81,7 +82,7 @@ func newFinishObservationFixture(t *testing.T, maxConnections int32) finishObser } func (f finishObservationFixture) start(t *testing.T) sessions.InputReceipt { t.Helper() - _, service := testSessions(t, f.pool, nil) + _, service := testSessions(t, f.pool, pgtest.CredentialKey(t)) receipts, err := service.SubmitInputs(t.Context(), f.tenant, f.session.ID, uuid.NewString(), []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"fixture"}]}]}`)}}) if err != nil { t.Fatal(err) @@ -200,7 +201,7 @@ func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { if err != nil { t.Fatal(err) } - d := Dispatcher{Observer: modelconfigurationpg.New(pgunit.NewPool(pool), nil)} + d := Dispatcher{Observer: modelconfigurationpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t))} started := time.Now() d.observeDeploymentProvider(f.tenant, f.session.ID, turn) if elapsed := time.Since(started); elapsed < 900*time.Millisecond || elapsed > 2*time.Second { @@ -226,7 +227,7 @@ func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { if cleanup != nil { cleanup() } - persisted, err := sessionpg.New(pgunit.NewPool(f.pool), nil).GetTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID) + persisted, err := sessionpg.New(pgunit.NewPool(f.pool), pgtest.CredentialKey(t)).GetTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID) if err != nil || persisted.Status != sessions.TurnCompleted { t.Fatal("terminal outcome lost", err) } diff --git a/services/core/internal/execution/owner_test.go b/services/core/internal/execution/owner_test.go index 6216a3132..9cbeda22f 100644 --- a/services/core/internal/execution/owner_test.go +++ b/services/core/internal/execution/owner_test.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -194,7 +195,7 @@ func TestWorkerRunClosesLeaseAfterDrain(t *testing.T) { lease := &closeCountingLease{t: t, inner: owner.Lease} id := uuid.NewString() // The Worker's first reconciliation scans the Session work. - reader, service := testSessions(t, pool, nil) + reader, service := testSessions(t, pool, pgtest.CredentialKey(t)) dispatcher := &Dispatcher{Registry: runtimegateway.NewRegistry(), Credentials: &recordingCredentials{}, Observer: unusedObserver{t}, Deployment: deployments, DeploymentReader: deploymentReader, Sessions: service, SessionsReader: reader, Links: relay.New(nil), ManagedRuntimes: NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil }, unusedPreparation(t))} worker, err := StartWorker(t.Context(), dispatcher, Owner{Lease: lease, Deployment: owner.Deployment, Sessions: owner.Sessions}) if err != nil { diff --git a/services/core/internal/execution/sandbox_deployment_drain_test.go b/services/core/internal/execution/sandbox_deployment_drain_test.go index 303df46da..5a48bd2a4 100644 --- a/services/core/internal/execution/sandbox_deployment_drain_test.go +++ b/services/core/internal/execution/sandbox_deployment_drain_test.go @@ -71,7 +71,7 @@ func delayedReadWriter(t *testing.T, armed *atomic.Bool, reading chan struct{}, t.Error(err) } }) - deployments, reader, operations := testDeployment(t, pool, nil, lease) + deployments, reader, operations := testDeployment(t, pool, pgtest.CredentialKey(t), lease) return Owner{Lease: lease, Deployment: operations, Sessions: sessionExecution(t, lease)}, deployments, reader, pool } diff --git a/services/core/internal/execution/sandbox_reset_test.go b/services/core/internal/execution/sandbox_reset_test.go index 27938a648..aab7ac473 100644 --- a/services/core/internal/execution/sandbox_reset_test.go +++ b/services/core/internal/execution/sandbox_reset_test.go @@ -1,7 +1,6 @@ package execution import ( - "bytes" "context" "errors" "reflect" @@ -46,21 +45,10 @@ func resetManagerConfig(t *testing.T, configure func(*pgxpool.Config)) (Owner, * func resetManagerDB(t *testing.T, configure func(*pgxpool.Config)) (Owner, *deployment.Service, deployment.Reader, *pgxpool.Pool) { t.Helper() pool := pgtest.OpenIsolated(t, configure) - owner, deployments, reader := testOwner(t, pool, testCredentialCipher(t)) + owner, deployments, reader := testOwner(t, pool, pgtest.CredentialKey(t)) return owner, deployments, reader, pool } -// testCredentialCipher is the credential key of the adapters these tests -// build on one database. -func testCredentialCipher(t *testing.T) *credentialcrypto.Cipher { - t.Helper() - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{8}, 32)) - if err != nil { - t.Fatal(err) - } - return cipher -} - // testOwner acquires the execution lease on pool and builds the deployment and // Session execution operations on it, and the pooled deployment service and // reader, as cmd/server does. The lease closes when the test ends. @@ -196,7 +184,7 @@ func TestSandboxResetPublishesCommittedGenerationWithoutReading(t *testing.T) { id := initializeE2BDeployment(t, owner) // The manager reads the deployment through a reader that fails every read // of the committed reset, so publication cannot depend on one. - adapter := deploymentpg.New(pgunit.NewPool(pool), nil) + adapter := deploymentpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) errCommittedRead := errors.New("committed deployment read failed") committedReads := 0 reader := &strictDeploymentReader{t: t, snapshot: func(ctx context.Context) (deployment.Snapshot, error) { diff --git a/services/core/internal/modelconfiguration/errors.go b/services/core/internal/modelconfiguration/errors.go index 1c4719fc9..b118885fc 100644 --- a/services/core/internal/modelconfiguration/errors.go +++ b/services/core/internal/modelconfiguration/errors.go @@ -3,8 +3,7 @@ package modelconfiguration import "errors" // Replace and Resolve report a configuration the Harness declaration rejects -// with the contract's *v1.ModelProviderError, which names the field, and a -// missing credential key with credentialcrypto.ErrUnavailable. A bundle that +// with the contract's *v1.ModelProviderError, which names the field. A bundle that // fails to open is an internal error. Storage passes textvalue.ErrUnstorable // and adminaudit.ErrInvalidSource through unchanged. var ( diff --git a/services/core/internal/modelconfiguration/service.go b/services/core/internal/modelconfiguration/service.go index fa79b9b61..7b52a5148 100644 --- a/services/core/internal/modelconfiguration/service.go +++ b/services/core/internal/modelconfiguration/service.go @@ -40,8 +40,7 @@ func (s *Service) Delete(ctx context.Context, harness string) error { } // Resolve opens the Harness's default for Session creation. It returns nil -// when the Harness has none, and credentialcrypto.ErrUnavailable without a -// credential key. +// when the Harness has none. func (s *Service) Resolve(ctx context.Context, harness string) (*Snapshot, error) { bundle, err := s.storage.LoadBundle(ctx, harness) if errors.Is(err, ErrNotFound) { diff --git a/services/core/internal/modelconfiguration/service_test.go b/services/core/internal/modelconfiguration/service_test.go index 3f23c5ad1..909df0ed6 100644 --- a/services/core/internal/modelconfiguration/service_test.go +++ b/services/core/internal/modelconfiguration/service_test.go @@ -7,7 +7,6 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) @@ -77,12 +76,6 @@ func TestReplacePassesTheCompleteBundleWithItsSafeColumns(t *testing.T) { if !reflect.DeepEqual(record.Configuration, validConfiguration()) { t.Fatalf("bundle: %+v", record.Configuration) } - storage.replace = func(context.Context, Record) (Configuration, error) { - return Configuration{}, credentialcrypto.ErrUnavailable - } - if _, err := newService(t, storage).Replace(t.Context(), Replacement{Harness: "codex", Configuration: validConfiguration()}); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("missing credential key", err) - } } func TestReplaceRejectsBeforeStorage(t *testing.T) { @@ -124,10 +117,9 @@ func TestResolveValidatesTheOpenedBundle(t *testing.T) { if snapshot, err := newService(t, loaded(v1.ModelConfigurationInput{}, ErrNotFound)).Resolve(t.Context(), "codex"); snapshot != nil || err != nil { t.Fatal("missing default", snapshot, err) } - for _, failure := range []error{errors.New("storage failed"), credentialcrypto.ErrUnavailable} { - if _, err := newService(t, loaded(v1.ModelConfigurationInput{}, failure)).Resolve(t.Context(), "codex"); !errors.Is(err, failure) { - t.Fatal("storage failure", err) - } + failure := errors.New("storage failed") + if _, err := newService(t, loaded(v1.ModelConfigurationInput{}, failure)).Resolve(t.Context(), "codex"); !errors.Is(err, failure) { + t.Fatal("storage failure", err) } invalid := validConfiguration() invalid.Model = "" diff --git a/services/core/internal/modelconfiguration/storage.go b/services/core/internal/modelconfiguration/storage.go index b132e80cc..49622417f 100644 --- a/services/core/internal/modelconfiguration/storage.go +++ b/services/core/internal/modelconfiguration/storage.go @@ -3,8 +3,7 @@ package modelconfiguration import "context" // Storage keeps one deployment default per Harness and seals and opens its -// bundle; without a credential key, Replace and LoadBundle return -// credentialcrypto.ErrUnavailable. Replace and Delete record the administrator +// bundle. Replace and Delete record the administrator // mutation in the same transaction, from the provenance the context carries; // an audit failure aborts the change. type Storage interface { diff --git a/services/core/internal/persistence/postgres/agentpg/store.go b/services/core/internal/persistence/postgres/agentpg/store.go index 4a77d15d8..40bfcc18e 100644 --- a/services/core/internal/persistence/postgres/agentpg/store.go +++ b/services/core/internal/persistence/postgres/agentpg/store.go @@ -27,10 +27,8 @@ type Store struct { cipher *credentialcrypto.Cipher } -// New returns the Agent store. cipher is nil when Core has no credential key; -// saving or opening a model provider bundle then fails with -// credentialcrypto.ErrUnavailable. A bundle the key cannot open is an internal -// error. +// New returns the Agent store, which seals model provider bundles with cipher. +// A bundle the key cannot open is an internal error. func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { return &Store{pool: pool, cipher: cipher} } @@ -247,9 +245,6 @@ func (s *Store) GetAgentWithModelProvider(ctx context.Context, tenantID, agentID if row.EncryptedConfig == nil { return agent, nil, nil } - if s.cipher == nil { - return agents.Agent{}, nil, credentialcrypto.ErrUnavailable - } raw, err := s.cipher.OpenAgentModelExecution(row.EncryptedConfig, agent.TenantID, agent.ID) if err != nil { return agents.Agent{}, nil, errors.New("agent model provider decryption failed") @@ -273,7 +268,7 @@ func (s *Store) saveModelProvider(ctx context.Context, q *sqlc.Queries, tenant, } sealed, err := s.cipher.SealAgentModelExecution(raw, uuid.UUID(tenant.Bytes).String(), uuid.UUID(agent.Bytes).String()) if err != nil { - return credentialcrypto.ErrUnavailable + return errors.New("agent model provider encryption failed") } return q.SaveAgentModelExecution(ctx, sqlc.SaveAgentModelExecutionParams{AgentID: agent, EncryptedConfig: sealed}) } diff --git a/services/core/internal/persistence/postgres/agentpg/store_test.go b/services/core/internal/persistence/postgres/agentpg/store_test.go index 2d30e5b2e..97e765d32 100644 --- a/services/core/internal/persistence/postgres/agentpg/store_test.go +++ b/services/core/internal/persistence/postgres/agentpg/store_test.go @@ -70,7 +70,7 @@ func count(t *testing.T, pool *pgxpool.Pool, query string, args ...any) int { func TestAgentsPersistIndependentlyAndStayTenantScoped(t *testing.T) { pool := pgtest.Open(t) - store, service := open(t, pool, nil) + store, service := open(t, pool, pgtest.CredentialKey(t)) ctx := t.Context() tenantA, tenantB := uuid.NewString(), uuid.NewString() // Configuration is preserved without applying one harness's capabilities. @@ -116,7 +116,7 @@ func TestAgentsPersistIndependentlyAndStayTenantScoped(t *testing.T) { func TestAgentsRejectInvalidTenantsAndEmptyMetadataIsAMap(t *testing.T) { pool := pgtest.Open(t) - store, service := open(t, pool, nil) + store, service := open(t, pool, pgtest.CredentialKey(t)) ctx := t.Context() tenant := uuid.NewString() valid := agents.CreateCommand{Configuration: []byte(`{"model":"x"}`)} @@ -144,7 +144,7 @@ func TestAgentsRejectInvalidTenantsAndEmptyMetadataIsAMap(t *testing.T) { func TestAgentListPaginationIsolationAndReconnect(t *testing.T) { pool := pgtest.Open(t) - store, service := open(t, pool, nil) + store, service := open(t, pool, pgtest.CredentialKey(t)) ctx := t.Context() tenant, other := uuid.NewString(), uuid.NewString() empty, err := store.ListAgents(ctx, agents.ListQuery{TenantID: tenant, Limit: 2}) @@ -230,7 +230,7 @@ func TestAgentListPaginationIsolationAndReconnect(t *testing.T) { func TestAgentUpdateRollbackAndCompleteSizeBound(t *testing.T) { pool := pgtest.Open(t) - store, service := open(t, pool, nil) + store, service := open(t, pool, pgtest.CredentialKey(t)) ctx := t.Context() tenant := uuid.NewString() configuration, err := json.Marshal(map[string]any{"model": "original", "instructions": strings.Repeat("x", 400*1024), "number": json.Number("9007199254740993")}) @@ -286,7 +286,7 @@ func TestAgentUpdateRollbackAndCompleteSizeBound(t *testing.T) { func TestAgentDeleteIsTenantScoped(t *testing.T) { pool := pgtest.Open(t) - store, service := open(t, pool, nil) + store, service := open(t, pool, pgtest.CredentialKey(t)) ctx := t.Context() tenant := uuid.NewString() agent, err := service.Create(ctx, agents.CreateCommand{TenantID: tenant, Configuration: []byte(`{"model":"x"}`)}) @@ -347,23 +347,12 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { if _, inherited := snapshot(store); inherited == nil || *inherited != *provider { t.Fatal("provider snapshot mismatch") } - // Omitted provider updates and plain reads do not require the encryption key. - keylessStore, keyless := open(t, pool, nil) - if _, err := keyless.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"model":"new-model"}`)}); err != nil { + if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"model":"new-model"}`)}); err != nil { t.Fatal(err) } - if _, err := keylessStore.GetAgent(ctx, tenant, agent.ID); err != nil { - t.Fatal("plain read required Agent decryption", err) - } - if _, _, err := keylessStore.GetAgentWithModelProvider(ctx, tenant, agent.ID); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("missing key opened the bundle", err) - } if _, _, err := agentpg.New(pgunit.NewPool(pool), testCipher(t, 99)).GetAgentWithModelProvider(ctx, tenant, agent.ID); err == nil || err.Error() != "agent model provider decryption failed" { t.Fatal("wrong key was not a decryption failure", err) } - if _, err := keyless.Create(ctx, create); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("unencrypted Agent create accepted", err) - } replacement := providerFixture(1) replace := func(tenant string) agents.UpdateCommand { return agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: providerConfiguration(t, replacement, "codex"), ModelProvider: &agents.ModelProviderChange{Provider: replacement}} @@ -371,9 +360,6 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { if _, err := service.Update(ctx, replace(uuid.NewString())); !errors.Is(err, agents.ErrNotFound) { t.Fatal("foreign tenant replacement accepted", err) } - if _, err := keyless.Update(ctx, replace(tenant)); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("unencrypted replacement accepted", err) - } if current, inherited := snapshot(store); inherited == nil || *inherited != *provider || !bytes.Contains(current.Configuration, []byte("new-model")) { t.Fatal("failed replacement changed snapshot") } @@ -385,9 +371,6 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { if current, inherited := snapshot(store); inherited == nil || *inherited != *provider || !bytes.Contains(current.Configuration, []byte("new-model")) { t.Fatal("database rejection left a partial replacement") } - if n := count(t, pool, "SELECT count(*) FROM agents WHERE tenant_id=$1", tenant); n != 1 { - t.Fatal("failed create persisted partial Agent", n) - } if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"harness":"claude_sdk"}}`)}); !errors.Is(err, agents.ErrInvalidInput) { t.Fatal("incompatible Harness-only update accepted", err) } @@ -399,13 +382,13 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { if current, inherited := snapshot(store); inherited == nil || *inherited != *replacement || !bytes.Contains(current.Configuration, []byte(`"harness": "codex"`)) { t.Fatal("provider-only replacement failed") } - if _, err := keyless.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"harness":"codex"}}`)}); err != nil { + if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"harness":"codex"}}`)}); err != nil { t.Fatal(err) } if _, inherited := snapshot(store); inherited == nil || *inherited != *replacement { t.Fatal("harness-only update lost provider") } - if _, err := keyless.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"model_provider":null}}`), ModelProvider: &agents.ModelProviderChange{}}); err != nil { + if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"model_provider":null}}`), ModelProvider: &agents.ModelProviderChange{}}); err != nil { t.Fatal(err) } if current, inherited := snapshot(store); inherited != nil || !bytes.Contains(current.Configuration, []byte(`"harness": "codex"`)) { @@ -645,7 +628,7 @@ func TestAgentWritesAuditInTheirTransaction(t *testing.T) { // after another key updates and deletes the Agent. func TestAgentAuditReadsFailuresAndStableOwnership(t *testing.T) { pool := pgtest.Open(t) - store, service := open(t, pool, nil) + store, service := open(t, pool, pgtest.CredentialKey(t)) tenant := uuid.NewString() first, err := service.Create(auditContext(t.Context(), tenant, uuid.NewString(), "a"), agents.CreateCommand{TenantID: tenant, Configuration: []byte(`{"model":"fixture"}`)}) if err != nil { @@ -686,7 +669,7 @@ func TestAgentAuditReadsFailuresAndStableOwnership(t *testing.T) { // Malformed supplied provenance fails the write closed. func TestAgentWriteRejectsInvalidAuditSource(t *testing.T) { pool := pgtest.Open(t) - _, service := open(t, pool, nil) + _, service := open(t, pool, pgtest.CredentialKey(t)) tenant := uuid.NewString() ctx := writeaudit.WithSource(t.Context(), writeaudit.Source{KeyID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Prefix: "pc_aaaaaaaa", Kind: "issued", TenantID: tenant, RequestID: uuid.NewString()}) if _, err := service.Create(ctx, agents.CreateCommand{TenantID: tenant, Configuration: []byte(`{"model":"x"}`)}); !errors.Is(err, writeaudit.ErrInvalidSource) { diff --git a/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go b/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go index a06cb97f0..a3c75e268 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go @@ -419,3 +419,45 @@ func TestNodeGenerationDowngradePreservesServingProtocol(t *testing.T) { }) } } + +// Stored vendor codes from before the readiness classes, including an offline +// node's last report, are rewritten to their class. +func TestNodeReadinessClassMigrationRewritesStoredCodes(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + _, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) + node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + connection := f.connect(t, node.NodeID) + failed := []sandbox.GenerationStatus{{Generation: view.Generation, SpecificationDigest: view.SpecificationDigest, State: "failed", Diagnostic: "provider_unavailable"}} + if err := f.service.HeartbeatGenerations(t.Context(), node.NodeID, connection, view.OwnerEpoch, deployment.NodeHealth{Diagnostic: "provider_unavailable"}, failed); err != nil { + t.Fatal(err) + } + db := sql.OpenDB(stdlib.GetConnector(*f.pool.Config().ConnConfig)) + defer db.Close() + migration, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + var version int64 + for _, source := range migration.ListSources() { + if strings.HasSuffix(source.Path, "_node_readiness_classes.sql") { + version = source.Version + } + } + if _, err := migration.DownTo(t.Context(), version-1); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_nodes SET health=jsonb_set(health,'{diagnostic}','"kvm_unavailable"') WHERE id=$1`, node.NodeID); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), "UPDATE runtime_node_generation_status SET diagnostic='microsandbox_artifacts_unavailable' WHERE node_id=$1", node.NodeID); err != nil { + t.Fatal(err) + } + if _, err := migration.Up(t.Context()); err != nil { + t.Fatal(err) + } + detail, err := f.service.NodeDetail(t.Context(), node.NodeID, "1h") + if err != nil || detail.Diagnostic != "host_unsupported" || detail.Rollout.State != "failed" || detail.Rollout.Diagnostic != "artifacts_unavailable" { + t.Fatal(detail.Diagnostic, detail.Rollout, err) + } +} diff --git a/services/core/internal/persistence/postgres/deploymentpg/presence_test.go b/services/core/internal/persistence/postgres/deploymentpg/presence_test.go index 7787174dd..7c8fd3905 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/presence_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/presence_test.go @@ -270,13 +270,13 @@ func TestNodeDiagnosticReachesListAndDetail(t *testing.T) { reported, want string ready bool }{ - {reported: "docker_unavailable", want: "docker_unavailable"}, + {reported: "host_unsupported", want: "host_unsupported"}, {reported: "capacity_insufficient", want: "capacity_insufficient"}, // Older nodes send provider_unavailable or nothing; unknown text is never stored. {reported: "provider_unavailable", want: "provider_unavailable"}, {reported: "", want: ""}, {reported: "dial unix /var/run/docker.sock: permission denied", want: "provider_unavailable"}, - {reported: "kvm_unavailable", want: "", ready: true}, + {reported: "artifacts_unavailable", want: "", ready: true}, } { if err := f.service.Heartbeat(t.Context(), node.NodeID, connection, epoch, deployment.NodeHealth{ProviderReady: tc.ready, Diagnostic: tc.reported}); err != nil { t.Fatal(tc.reported, err) diff --git a/services/core/internal/persistence/postgres/deploymentpg/records.go b/services/core/internal/persistence/postgres/deploymentpg/records.go index 84eed9174..0fc164c42 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/records.go +++ b/services/core/internal/persistence/postgres/deploymentpg/records.go @@ -55,19 +55,15 @@ func translate(err error) error { } // record converts the stored deployment. open opens a stored credential; a -// view never needs it. Without a key the credential error is -// credentialcrypto.ErrUnavailable; a credential the key cannot open or -// authenticate is an internal decryption error. +// view never needs it. A credential the key cannot open or authenticate is an +// internal decryption error. func record(d sqlc.RuntimeDeployment, cipher *credentialcrypto.Cipher, open bool) deployment.Record { r := deployment.Record{InstallationID: uuidString(d.InstallationID), Provider: d.ProviderKind, BackendFingerprint: d.BackendFingerprint, - Generation: uint64(d.Generation), OwnerEpoch: uint64(d.OwnerEpoch), Mode: d.Mode, - IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds, Specification: d.Specification, + Generation: uint64(d.Generation), OwnerEpoch: uint64(d.OwnerEpoch), Mode: d.Mode, Specification: d.Specification, Configuration: sandbox.ConfigurationRecord{Public: d.ProviderConfig, Metadata: d.ProviderMetadata}, CredentialStored: len(d.ProviderCredential) > 0} if r.CredentialStored && open { - if cipher == nil { - r.CredentialError = credentialcrypto.ErrUnavailable - } else if secret, err := cipher.OpenSandboxDeployment(d.ProviderCredential, r.InstallationID, r.Generation); err != nil { - r.CredentialError = errors.New("sandbox deployment credential decryption failed") + if secret, err := cipher.OpenSandboxDeployment(d.ProviderCredential, r.InstallationID, r.Generation); err != nil { + r.CredentialError = deployment.ErrCredentialUnreadable } else { r.Configuration.Secret = secret } diff --git a/services/core/internal/persistence/postgres/deploymentpg/seal_test.go b/services/core/internal/persistence/postgres/deploymentpg/seal_test.go index f52e389ca..0fed62306 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/seal_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/seal_test.go @@ -4,23 +4,23 @@ import ( "errors" "testing" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/deploymentpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" ) -// A missing key is credentialcrypto.ErrUnavailable, and a credential sealed -// with another binding is a decryption failure, never a missing key. Node -// transactions and snapshots never open the credential, so they need no key. +// A credential sealed with another key or binding is a decryption failure. +// Node transactions and snapshots never open the credential, so they keep +// working under a replaced key. func TestStoredCredentialNeedsTheKeyAndItsBinding(t *testing.T) { f := newFixture(t) changes, _ := f.execution(t) id, view := f.initialize(t, changes, setupE2BSelection()) - keyless := deploymentpg.New(pgunit.NewPool(f.pool), nil) - service := newService(t, keyless, fixturePublicURL) - if _, err := service.Setup(t.Context()); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("a keyless Store opened the credential", err) + replaced := deploymentpg.New(pgunit.NewPool(f.pool), pgtest.CredentialKey(t)) + service := newService(t, replaced, fixturePublicURL) + if _, err := service.Setup(t.Context()); !errors.Is(err, deployment.ErrCredentialUnreadable) { + t.Fatal("a replaced key opened the credential", err) } withoutCredential := func(reads deployment.NodeReads) error { d, err := reads.LoadDeployment() @@ -29,10 +29,10 @@ func TestStoredCredentialNeedsTheKeyAndItsBinding(t *testing.T) { } return err } - if err := keyless.WithNodes(t.Context(), func(tx deployment.NodeTx) error { return withoutCredential(tx) }); err != nil { + if err := replaced.WithNodes(t.Context(), func(tx deployment.NodeTx) error { return withoutCredential(tx) }); err != nil { t.Fatal(err) } - if err := keyless.ReadNodes(t.Context(), withoutCredential); err != nil { + if err := replaced.ReadNodes(t.Context(), withoutCredential); err != nil { t.Fatal(err) } sealed, err := f.cipher.SealSandboxDeployment([]byte("fixture-private-api-key"), id, view.Generation+1) @@ -42,7 +42,7 @@ func TestStoredCredentialNeedsTheKeyAndItsBinding(t *testing.T) { if _, err := f.pool.Exec(t.Context(), "UPDATE runtime_deployment SET provider_credential=$1", sealed); err != nil { t.Fatal(err) } - if _, err := f.service.Setup(t.Context()); err == nil || err.Error() != "sandbox deployment credential decryption failed" || errors.Is(err, credentialcrypto.ErrUnavailable) { + if _, err := f.service.Setup(t.Context()); !errors.Is(err, deployment.ErrCredentialUnreadable) { t.Fatal("a wrong binding was not a decryption failure", err) } } diff --git a/services/core/internal/persistence/postgres/deploymentpg/store.go b/services/core/internal/persistence/postgres/deploymentpg/store.go index decc3aaa9..612af96bc 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/store.go +++ b/services/core/internal/persistence/postgres/deploymentpg/store.go @@ -25,8 +25,8 @@ type Store struct { cipher *credentialcrypto.Cipher } -// New returns the deployment store. cipher is nil when Core has no credential -// key; a stored credential then reads as credentialcrypto.ErrUnavailable. +// New returns the deployment store, which seals the provider credential with +// cipher. func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { return &Store{pool: pool, cipher: cipher} } diff --git a/services/core/internal/persistence/postgres/deploymentpg/tx.go b/services/core/internal/persistence/postgres/deploymentpg/tx.go index e0952eb19..28f35e1c5 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/tx.go +++ b/services/core/internal/persistence/postgres/deploymentpg/tx.go @@ -301,11 +301,8 @@ func (t *deploymentTx) SaveSelection(selection deployment.SelectionRecord) error if selection.Generation > math.MaxInt64 { return deployment.ErrInvalidInput } - params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: selection.Provider, BackendFingerprint: selection.BackendFingerprint, Generation: int64(selection.Generation), Mode: selection.Mode, IdleSeconds: selection.IdleSeconds, RetentionSeconds: selection.RetentionSeconds, ProviderConfig: selection.Configuration.Public, ProviderMetadata: selection.Configuration.Metadata, Specification: selection.Specification} + params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: selection.Provider, BackendFingerprint: selection.BackendFingerprint, Generation: int64(selection.Generation), Mode: selection.Mode, ProviderConfig: selection.Configuration.Public, ProviderMetadata: selection.Configuration.Metadata, Specification: selection.Specification} if len(selection.Configuration.Secret) > 0 { - if t.cipher == nil { - return credentialcrypto.ErrUnavailable - } sealed, err := t.cipher.SealSandboxDeployment(selection.Configuration.Secret, selection.InstallationID, selection.Generation) if err != nil { return errors.New("sandbox deployment credential encryption failed") diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/seal_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/seal_test.go index b482438dc..181e8a5f8 100644 --- a/services/core/internal/persistence/postgres/modelconfigurationpg/seal_test.go +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/seal_test.go @@ -8,30 +8,23 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" ) // The Store seals the bundle to its Harness as Core always has, so a bundle -// sealed before the Store owned the key still opens. A missing key is -// credentialcrypto.ErrUnavailable, and a wrong binding is a decryption -// failure, never a missing key or default. +// sealed before the Store owned the key still opens. A bundle sealed under +// another key or binding is a decryption failure, never a missing default. func TestBundlesKeepTheirSealedFormat(t *testing.T) { f := newFixture(t) - keyless := modelconfigurationpg.New(pgunit.NewPool(f.pool), nil) + replaced := modelconfigurationpg.New(pgunit.NewPool(f.pool), pgtest.CredentialKey(t)) configuration := v1.ModelConfigurationInput{ModelProvider: fixtureProvider, Model: "fixture"} - if _, err := keyless.LoadBundle(t.Context(), "codex"); !errors.Is(err, modelconfiguration.ErrNotFound) { - t.Fatal("a missing default was not reported before the key", err) + if _, err := replaced.LoadBundle(t.Context(), "codex"); !errors.Is(err, modelconfiguration.ErrNotFound) { + t.Fatal("a missing default was not reported before decryption", err) } record := modelconfiguration.Record{Harness: "codex", Provider: *fixtureProvider.SafeView(), Model: "fixture", HarnessConfig: json.RawMessage(`{}`), Configuration: configuration} - if _, err := keyless.Replace(admin(t), record); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("a keyless Store sealed a bundle", err) - } - if listed := f.list(t); len(listed) != 0 { - t.Fatal("a keyless replacement was stored", listed) - } if _, err := f.adapter.Replace(admin(t), record); err != nil { t.Fatal(err) } @@ -39,8 +32,8 @@ func TestBundlesKeepTheirSealedFormat(t *testing.T) { if err != nil || !reflect.DeepEqual(loaded.Configuration, configuration) { t.Fatal("the bundle did not round-trip", err) } - if _, err := keyless.LoadBundle(t.Context(), "codex"); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("a keyless Store opened a bundle", err) + if _, err := replaced.LoadBundle(t.Context(), "codex"); err == nil || err.Error() != "deployment model configuration decryption failed" { + t.Fatal("a replaced key opened a bundle", err) } // write stores a bundle sealed the way Core sealed it before this Store // owned the key. diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/store.go b/services/core/internal/persistence/postgres/modelconfigurationpg/store.go index 1b6db598f..02a0a7d0d 100644 --- a/services/core/internal/persistence/postgres/modelconfigurationpg/store.go +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/store.go @@ -43,9 +43,7 @@ var ( _ modelconfiguration.Observer = (*Store)(nil) ) -// New returns a Store. Without a credential key (cipher nil), Replace and -// LoadBundle fail with credentialcrypto.ErrUnavailable; List, Delete and -// observations keep working. +// New returns a Store that seals and opens bundles with cipher. func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { return &Store{pool: pool, cipher: cipher} } @@ -67,16 +65,13 @@ func (s *Store) List(ctx context.Context) ([]modelconfiguration.Configuration, e // new revision, which clears the replaced revision's observations, and audits // the write in the same transaction. func (s *Store) Replace(ctx context.Context, record modelconfiguration.Record) (modelconfiguration.Configuration, error) { - if s.cipher == nil { - return modelconfiguration.Configuration{}, credentialcrypto.ErrUnavailable - } raw, err := json.Marshal(record.Configuration) if err != nil { return modelconfiguration.Configuration{}, err } sealed, err := s.cipher.SealDeploymentModelProvider(raw, record.Harness) if err != nil { - return modelconfiguration.Configuration{}, credentialcrypto.ErrUnavailable + return modelconfiguration.Configuration{}, errors.New("model provider encryption failed") } var result modelconfiguration.Configuration err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { @@ -121,9 +116,6 @@ func (s *Store) LoadBundle(ctx context.Context, harness string) (modelconfigurat if err != nil { return modelconfiguration.Bundle{}, translate(err) } - if s.cipher == nil { - return modelconfiguration.Bundle{}, credentialcrypto.ErrUnavailable - } raw, err := s.cipher.OpenDeploymentModelProvider(row.EncryptedConfig, harness) if err != nil { return modelconfiguration.Bundle{}, errors.New("deployment model configuration decryption failed") diff --git a/services/core/internal/persistence/postgres/pgtest/pgtest.go b/services/core/internal/persistence/postgres/pgtest/pgtest.go index 8a9fcdcb3..da2094abe 100644 --- a/services/core/internal/persistence/postgres/pgtest/pgtest.go +++ b/services/core/internal/persistence/postgres/pgtest/pgtest.go @@ -3,6 +3,7 @@ package pgtest import ( + "bytes" "context" "errors" "os" @@ -15,9 +16,21 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/migrations" ) +// CredentialKey returns a cipher under a fixed test credential key, for tests +// that need a key but not a particular one. +func CredentialKey(t testing.TB) *credentialcrypto.Cipher { + t.Helper() + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{0x7e}, 32)) + if err != nil { + t.Fatal(err) + } + return cipher +} + // Open returns a pool on the dedicated test database named by // OAC_TEST_DATABASE_URL, with Core's migrations applied, and skips the test when // the variable is unset. Tests on this shared database isolate their data with diff --git a/services/core/internal/persistence/postgres/sessionpg/admin_test.go b/services/core/internal/persistence/postgres/sessionpg/admin_test.go index 43d37f264..f6a1c6ba8 100644 --- a/services/core/internal/persistence/postgres/sessionpg/admin_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/admin_test.go @@ -15,7 +15,7 @@ import ( func TestListAdminRuntimeTargetsPagesLiveSessionsOfTenants(t *testing.T) { pool := pgtest.Open(t) - store := New(pgunit.NewPool(pool), nil) + store := New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) first, second, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() created := time.Now().UTC().Add(-time.Hour) session := func(tenant string, offset time.Duration, deleted bool) sessions.AdminRuntimeTarget { @@ -64,7 +64,7 @@ func TestListAdminRuntimeTargetsPagesLiveSessionsOfTenants(t *testing.T) { func TestReadAdminSummaryVisitsTheSelectedSessions(t *testing.T) { pool := pgtest.Open(t) - store := New(pgunit.NewPool(pool), nil) + store := New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) tenant, created := uuid.NewString(), time.Now().UTC().Truncate(time.Second).Add(-time.Hour) session := func(offset time.Duration, deleted bool) string { id := uuid.NewString() diff --git a/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go b/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go index 30a299d63..4e17c6e55 100644 --- a/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/artifacts_test.go @@ -67,7 +67,7 @@ func stagedRows(t *testing.T, pool *pgxpool.Pool, turn string) int { func stagingService(t *testing.T, pool *pgxpool.Pool) (*Store, *sessions.Service) { t.Helper() - store := New(pgunit.NewPool(pool), nil) + store := New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) service, err := sessions.NewService(store, nil) if err != nil { t.Fatal(err) diff --git a/services/core/internal/persistence/postgres/sessionpg/creation.go b/services/core/internal/persistence/postgres/sessionpg/creation.go index 5c3a93c57..be18c68d7 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation.go @@ -33,9 +33,6 @@ const modelProviderKeyPurpose = "parsar.agents-api.model-provider-api-key.v1" var _ sessions.CreationTx = (*creationTx)(nil) func (s *Store) FingerprintProviderKey(secret string) (string, error) { - if s.cipher == nil { - return "", credentialcrypto.ErrUnavailable - } return s.cipher.Fingerprint(modelProviderKeyPurpose, secret) } @@ -133,9 +130,6 @@ func skillError(err error) error { } func (t *creationTx) SaveModelExecution(ctx context.Context, provider v1.ModelProviderInput) error { - if t.cipher == nil { - return credentialcrypto.ErrUnavailable - } raw, err := json.Marshal(provider) if err != nil { return err @@ -160,9 +154,6 @@ func (t *creationTx) SaveExecutionConfiguration(ctx context.Context, projection } func (t *creationTx) SaveInitialFiles(ctx context.Context, initial []environmentconfig.InitialFile) error { - if t.cipher == nil { - return credentialcrypto.ErrUnavailable - } metadata := environmentconfig.InitialFilesMetadata(initial) for i, f := range initial { body := f.Data @@ -200,9 +191,6 @@ func (t *creationTx) SaveInitialFiles(ctx context.Context, initial []environment } func (t *creationTx) SaveSetup(ctx context.Context, setup environmentconfig.Setup) error { - if t.cipher == nil { - return credentialcrypto.ErrUnavailable - } plaintext, err := json.Marshal(setup) if err != nil { return err diff --git a/services/core/internal/persistence/postgres/sessionpg/creation_test.go b/services/core/internal/persistence/postgres/sessionpg/creation_test.go index 0d9577c95..e1aa531d5 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation_test.go @@ -39,13 +39,13 @@ var hostedConfiguration = json.RawMessage(`{"agent":{"model":"m"},"environment": // creationService returns the Session store and service over pool with the // built-in placement rules. -func creationService(t *testing.T, pool *pgxpool.Pool, cipher *credentialcrypto.Cipher) (*Store, *sessions.Service) { +func creationService(t *testing.T, pool *pgxpool.Pool) (*Store, *sessions.Service) { t.Helper() rules, err := placement.NewRules(providers.Builtin(), "") if err != nil { t.Fatal(err) } - store := New(pgunit.NewPool(pool), cipher) + store := New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) service, err := sessions.NewService(store, rules) if err != nil { t.Fatal(err) @@ -53,9 +53,9 @@ func creationService(t *testing.T, pool *pgxpool.Pool, cipher *credentialcrypto. return store, service } -func skillService(t *testing.T, pool *pgxpool.Pool, cipher *credentialcrypto.Cipher) *skills.Service { +func skillService(t *testing.T, pool *pgxpool.Pool) *skills.Service { t.Helper() - store := skillpg.New(pgunit.NewPool(pool), cipher) + store := skillpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) service, err := skills.NewService(store, store) if err != nil { t.Fatal(err) @@ -133,8 +133,8 @@ func awaitWaiters(t *testing.T, pool *pgxpool.Pool, holder int32, count int) { // submitting the input again. func TestCreationRetriesNeverReplayInitialInput(t *testing.T) { pool := pgtest.Open(t) - store, service := creationService(t, pool, nil) - _, other := creationService(t, pgtest.Open(t), nil) + store, service := creationService(t, pool) + _, other := creationService(t, pgtest.Open(t)) ctx := t.Context() tenant := uuid.NewString() input := sessions.CreateSession{Creator: creator, Engine: "codex", IdempotencyKey: "initial", CreationRequest: json.RawMessage(`{"agent_id":"source"}`), InitialInputs: []sessions.Input{messageInput("first"), messageInput("second")}} @@ -232,7 +232,7 @@ func TestCreationRetriesNeverReplayInitialInput(t *testing.T) { // one creates. func TestCreationIdentity(t *testing.T) { pool := pgtest.Open(t) - _, service := creationService(t, pool, nil) + _, service := creationService(t, pool) ctx := t.Context() tenant := uuid.NewString() request := json.RawMessage(`{"agent_id":"source","agent":{"tools":[{"parameters":{"const":9007199254740993}}]}}`) @@ -326,7 +326,7 @@ func TestCreationIdentity(t *testing.T) { } // The provider-key fingerprint in retry identities depends on the credential -// key and needs one. +// key. func TestProviderKeyFingerprintIsKeyed(t *testing.T) { fingerprint := func(seed byte) string { cipher, err := credentialcrypto.New(bytes.Repeat([]byte{seed}, 32)) @@ -342,9 +342,6 @@ func TestProviderKeyFingerprintIsKeyed(t *testing.T) { if fingerprint(71) == fingerprint(72) { t.Fatal("fingerprint ignores the credential key") } - if _, err := New(nil, nil).FingerprintProviderKey("provider-key"); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal(err) - } } // A new Session freezes its provider, Skills and initial files sealed under @@ -352,9 +349,8 @@ func TestProviderKeyFingerprintIsKeyed(t *testing.T) { // bytes conflict, and a foreign source is missing. func TestCreationFreezesResourcesOnce(t *testing.T) { pool := pgtest.Open(t) - cipher := frozenCipher(t) - store, service := creationService(t, pool, cipher) - skillsService := skillService(t, pool, cipher) + store, service := creationService(t, pool) + skillsService := skillService(t, pool) filesService, err := files.NewService(filepg.New(pgunit.NewPool(pool))) if err != nil { t.Fatal(err) @@ -444,21 +440,13 @@ func TestCreationFreezesResourcesOnce(t *testing.T) { if _, err := service.CreateSession(ctx, tenant, changed); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal("changed bytes accepted", err) } - _, keyless := creationService(t, pool, nil) - sealedInput := sessions.CreateSession{Creator: creator, Engine: "codex", IdempotencyKey: "keyless", Configuration: hostedConfiguration, InitialFiles: changed.InitialFiles[:1]} - if _, err := keyless.CreateSession(ctx, tenant, sealedInput); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("sealed without a credential key", err) - } - if countRows(t, pool, "SELECT count(*) FROM sessions WHERE tenant_id=$1 AND idempotency_key='keyless'", tenant) != 0 { - t.Fatal("a failed creation left a Session") - } } // An Environment Session reserves its initial input, tracking its activity, // instead of admitting a Turn. func TestEnvironmentCreationReservesItsInitialInput(t *testing.T) { pool := pgtest.Open(t) - _, service := creationService(t, pool, nil) + _, service := creationService(t, pool) input := sessions.CreateSession{Creator: creator, Engine: "codex", IdempotencyKey: "environment", InitialInputs: []sessions.Input{messageInput("first")}, Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)} created, err := service.CreateSession(t.Context(), uuid.NewString(), input) @@ -479,7 +467,7 @@ func TestEnvironmentCreationReservesItsInitialInput(t *testing.T) { // nothing and still returns its Session. func TestHostedCreationAdmitsAndPlacesUnderTheDeploymentLock(t *testing.T) { pool := pgtest.OpenIsolated(t, nil) - _, service := creationService(t, pool, nil) + _, service := creationService(t, pool) ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() tenant := uuid.NewString() @@ -531,9 +519,8 @@ func TestHostedCreationAdmitsAndPlacesUnderTheDeploymentLock(t *testing.T) { // whichever goes first decides, and no Session refers to a missing version. func TestSkillFreezeSerializesWithVersionDeletion(t *testing.T) { pool := pgtest.Open(t) - cipher := frozenCipher(t) - store, service := creationService(t, pool, cipher) - skillsService := skillService(t, pool, cipher) + store, service := creationService(t, pool) + skillsService := skillService(t, pool) ctx := t.Context() tenant := uuid.NewString() for _, freezeFirst := range []bool{true, false} { @@ -605,7 +592,7 @@ func TestSkillFreezeSerializesWithVersionDeletion(t *testing.T) { // creation closed and rolls it back. func TestCreationAudit(t *testing.T) { pool := pgtest.Open(t) - _, service := creationService(t, pool, nil) + _, service := creationService(t, pool) tenant := uuid.NewString() source := writeaudit.Source{KeyID: uuid.NewString(), Prefix: "pc_aaaaaaaa", Name: "test key", Kind: "issued", TenantID: uuid.NewString(), RequestID: uuid.NewString(), TraceID: uuid.NewString()} input := sessions.CreateSession{Creator: creator, Engine: "codex", IdempotencyKey: "audited", InitialInputs: []sessions.Input{messageInput("first")}, diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_turns_test.go b/services/core/internal/persistence/postgres/sessionpg/execution_turns_test.go index 6e06a2780..5b2270441 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_turns_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_turns_test.go @@ -72,7 +72,7 @@ func TestConcurrentTerminalTransitionsKeepOneOutcome(t *testing.T) { if _, err := transition(t.Context(), pool, tenant, session, turn, sessions.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"late":true}`)}); !errors.Is(err, sessions.ErrTurnConflict) { t.Fatalf("late terminal callback accepted: %v", err) } - got, err := New(pgunit.NewPool(pgtest.Open(t)), nil).GetTurn(t.Context(), text(tenant), text(session), turn) + got, err := New(pgunit.NewPool(pgtest.Open(t)), pgtest.CredentialKey(t)).GetTurn(t.Context(), text(tenant), text(session), turn) if err != nil || !reflect.DeepEqual(got, winner) { t.Fatalf("terminal outcome changed: %+v, %v", got, err) } @@ -154,7 +154,7 @@ func TestCompleteExecutionSettlesTheTurnOnTheLease(t *testing.T) { // and a cursor or Session outside the tenant's Session is missing. func TestTurnPagesKeepScopeAndOrder(t *testing.T) { pool := pgtest.Open(t) - store := New(pgunit.NewPool(pool), nil) + store := New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) ctx := t.Context() tenantID, sessionID, first := newTurn(t, pool, sessions.TurnCancelled) tenant, session := text(tenantID), text(sessionID) diff --git a/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go b/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go index b47335ecd..6d77d7a8b 100644 --- a/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go +++ b/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go @@ -12,7 +12,6 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" @@ -176,11 +175,8 @@ func (s *Store) VerifyInstallation(_ context.Context, payload, signature string) } // installationSignature is the keyed digest of an installation authorization -// payload. A service without the credential key cannot sign or verify one. +// payload. func (s *Store) installationSignature(payload string) (string, error) { - if s.cipher == nil { - return "", credentialcrypto.ErrUnavailable - } return s.cipher.Fingerprint(installationPurpose, payload) } diff --git a/services/core/internal/persistence/postgres/sessionpg/executor_credentials_test.go b/services/core/internal/persistence/postgres/sessionpg/executor_credentials_test.go index fc5f3ade4..ec8d0c810 100644 --- a/services/core/internal/persistence/postgres/sessionpg/executor_credentials_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/executor_credentials_test.go @@ -9,7 +9,6 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" @@ -17,7 +16,7 @@ import ( ) func TestInstallationSignaturesUseTheCredentialKey(t *testing.T) { - keyed := New(nil, frozenCipher(t)) + keyed := New(nil, pgtest.CredentialKey(t)) signature, err := keyed.SignInstallation(t.Context(), "payload") if err != nil { t.Fatal(err) @@ -28,18 +27,11 @@ func TestInstallationSignaturesUseTheCredentialKey(t *testing.T) { if err := keyed.VerifyInstallation(t.Context(), "other", signature); !errors.Is(err, sessions.ErrInstallationAuthorization) { t.Fatalf("another payload: %v", err) } - keyless := New(nil, nil) - if _, err := keyless.SignInstallation(t.Context(), "payload"); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatalf("keyless signature: %v", err) - } - if err := keyless.VerifyInstallation(t.Context(), "payload", signature); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatalf("keyless verification: %v", err) - } } func TestExecutorCredentialTxTranslatesOutcomes(t *testing.T) { pool := pgtest.Open(t) - store := New(pgunit.NewPool(pool), nil) + store := New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) tenantID, sessionID, environmentID := newEnvironment(t, pool, "self_hosted", "pending") tenant, environment := uuidText(tenantID), uuidText(environmentID) exec(t, pool, `INSERT INTO execution_project_scopes(tenant_id, organization_id, project_id) VALUES ($1, 'org', $2)`, tenantID, tenant) diff --git a/services/core/internal/persistence/postgres/sessionpg/frozen.go b/services/core/internal/persistence/postgres/sessionpg/frozen.go index d7a638f78..ad2217270 100644 --- a/services/core/internal/persistence/postgres/sessionpg/frozen.go +++ b/services/core/internal/persistence/postgres/sessionpg/frozen.go @@ -34,9 +34,6 @@ func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string if len(encrypted) == 0 { return setup, nil } - if s.cipher == nil { - return environmentconfig.Setup{}, credentialcrypto.ErrUnavailable - } plaintext, err := s.cipher.OpenEnvironmentSetup(encrypted, setupBinding(lookup.TenantID, lookup.ID)) if err != nil { return environmentconfig.Setup{}, fmt.Errorf("open frozen environment setup: %w", err) @@ -62,9 +59,6 @@ func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, session if err != nil { return environmentconfig.InitialFileMetadata{}, nil, err } - if s.cipher == nil { - return environmentconfig.InitialFileMetadata{}, nil, credentialcrypto.ErrUnavailable - } id := uuid.UUID(row.ID.Bytes).String() body, err := s.cipher.OpenEnvironmentFile(row.Contents, fileBinding(lookup.TenantID, lookup.ID, id)) if err != nil { diff --git a/services/core/internal/persistence/postgres/sessionpg/frozen_test.go b/services/core/internal/persistence/postgres/sessionpg/frozen_test.go index da87dc7c9..99cdb5d04 100644 --- a/services/core/internal/persistence/postgres/sessionpg/frozen_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/frozen_test.go @@ -1,7 +1,6 @@ package sessionpg import ( - "bytes" "errors" "reflect" "testing" @@ -15,25 +14,16 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -func frozenCipher(t *testing.T) *credentialcrypto.Cipher { - t.Helper() - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{5}, 32)) - if err != nil { - t.Fatal(err) - } - return cipher -} - // corruptFrozenData reports whether err classifies frozen data as corrupt -// stored data: an internal error, never the caller's input, a missing record -// or a missing key. +// stored data: an internal error, never the caller's input or a missing +// record. func corruptFrozenData(err error) bool { - return err != nil && !errors.Is(err, sessions.ErrInvalidInput) && !errors.Is(err, sessions.ErrNotFound) && !errors.Is(err, credentialcrypto.ErrUnavailable) + return err != nil && !errors.Is(err, sessions.ErrInvalidInput) && !errors.Is(err, sessions.ErrNotFound) } func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { pool := pgtest.Open(t) - cipher := frozenCipher(t) + cipher := pgtest.CredentialKey(t) adapter := New(pgunit.NewPool(pool), cipher) tenantID, sessionID, _ := newEnvironment(t, pool, "self_hosted", "pending") tenant, session := uuidText(tenantID), uuidText(sessionID) @@ -61,9 +51,6 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { if err != nil || setup.Env["GREETING"] != "hello" || !reflect.DeepEqual(setup.Packages.NPM, []string{"left-pad"}) { t.Fatalf("frozen setup %+v, %v", setup, err) } - if _, err := New(pgunit.NewPool(pool), nil).ReadEnvironmentSetup(t.Context(), tenant, session); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatalf("without a credential key: %v", err) - } otherSession := binding otherSession.OwnerID = uuid.NewString() @@ -91,7 +78,7 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { func TestReadInitialEnvironmentFileClassifiesFrozenData(t *testing.T) { pool := pgtest.Open(t) - cipher := frozenCipher(t) + cipher := pgtest.CredentialKey(t) adapter := New(pgunit.NewPool(pool), cipher) tenantID, sessionID, _ := newEnvironment(t, pool, "self_hosted", "pending") tenant, session := uuidText(tenantID), uuidText(sessionID) @@ -117,9 +104,6 @@ func TestReadInitialEnvironmentFileClassifiesFrozenData(t *testing.T) { if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), uuid.NewString(), session, 0); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("other tenant: %v", err) } - if _, _, err := New(pgunit.NewPool(pool), nil).ReadInitialEnvironmentFile(t.Context(), tenant, session, 0); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatalf("without a credential key: %v", err) - } otherFile := binding otherFile.FileID = uuid.NewString() diff --git a/services/core/internal/persistence/postgres/sessionpg/link.go b/services/core/internal/persistence/postgres/sessionpg/link.go index 3a55129e3..3219ec32a 100644 --- a/services/core/internal/persistence/postgres/sessionpg/link.go +++ b/services/core/internal/persistence/postgres/sessionpg/link.go @@ -9,7 +9,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" @@ -92,12 +91,8 @@ func (s *Store) GetLinkAssignment(ctx context.Context, assignment string) (runti }, true, nil } -// SignAttachGrant returns the keyed digest of an attach grant's payload. A -// service without the credential key cannot sign or verify a grant. +// SignAttachGrant returns the keyed digest of an attach grant's payload. func (s *Store) SignAttachGrant(_ context.Context, payload string) (string, error) { - if s.cipher == nil { - return "", credentialcrypto.ErrUnavailable - } return s.cipher.Fingerprint(attachGrantPurpose, payload) } diff --git a/services/core/internal/persistence/postgres/sessionpg/model_execution.go b/services/core/internal/persistence/postgres/sessionpg/model_execution.go index 8f5cfbfbb..68e91a3a7 100644 --- a/services/core/internal/persistence/postgres/sessionpg/model_execution.go +++ b/services/core/internal/persistence/postgres/sessionpg/model_execution.go @@ -11,7 +11,6 @@ import ( "github.com/jackc/pgx/v5" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -34,9 +33,6 @@ func (s *Store) SessionModelExecution(ctx context.Context, tenant, session strin if err != nil { return nil, err } - if s.cipher == nil { - return nil, credentialcrypto.ErrUnavailable - } raw, err := s.cipher.OpenModelExecution(ciphertext, tenant, session) if err != nil { return nil, fmt.Errorf("open session model execution: %w", err) diff --git a/services/core/internal/persistence/postgres/skillpg/skillpg.go b/services/core/internal/persistence/postgres/skillpg/skillpg.go index 8453bbb55..b65dfd8eb 100644 --- a/services/core/internal/persistence/postgres/skillpg/skillpg.go +++ b/services/core/internal/persistence/postgres/skillpg/skillpg.go @@ -33,8 +33,7 @@ var ( _ skills.Reader = (*Store)(nil) ) -// New builds the Skill store. Without a cipher, uploads and content reads -// fail with credentialcrypto.ErrUnavailable and metadata reads still work. +// New builds the Skill store, which seals archives with cipher. func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { return &Store{pool: pool, cipher: cipher} } @@ -301,9 +300,6 @@ func (s *Store) DefaultVersionContent(ctx context.Context, tenantID string, skil // insertVersion seals the archive under a new version ID and stores it. func (s *Store) insertVersion(ctx context.Context, q *sqlc.Queries, tenant, skill pgtype.UUID, version int64, name, description string, archive []byte) (skills.Version, error) { - if s.cipher == nil { - return skills.Version{}, credentialcrypto.ErrUnavailable - } id := newID() body, err := s.cipher.SealSkill(archive, credentialcrypto.NewSkillBinding(tenant.Bytes, skill.Bytes, id.Bytes, version)) if err != nil { @@ -338,8 +334,7 @@ func LockSkills(ctx context.Context, q *sqlc.Queries, tenant pgtype.UUID, ids [] // ReadVersionForFreeze reads, on q, a version of the tenant's Skill, opens // it with cipher and verifies it is still the archive the version records. A -// missing version is skills.ErrNotFound and a missing cipher -// credentialcrypto.ErrUnavailable; one that does not open or verify is +// missing version is skills.ErrNotFound; one that does not open or verify is // corrupt stored data, an internal error. func ReadVersionForFreeze(ctx context.Context, q *sqlc.Queries, cipher *credentialcrypto.Cipher, tenant pgtype.UUID, skillID string, version int64) (skills.Content, error) { key, err := skills.ParseID(skillID) @@ -361,9 +356,6 @@ func ReadVersionForFreeze(ctx context.Context, q *sqlc.Queries, cipher *credenti } func open(cipher *credentialcrypto.Cipher, row sqlc.SkillVersion) (skills.Content, error) { - if cipher == nil { - return skills.Content{}, credentialcrypto.ErrUnavailable - } archive, err := cipher.OpenSkill(row.Contents, credentialcrypto.NewSkillBinding(row.TenantID.Bytes, row.SkillID.Bytes, row.ID.Bytes, row.Version)) if err != nil { return skills.Content{}, err diff --git a/services/core/internal/persistence/postgres/skillpg/skillpg_test.go b/services/core/internal/persistence/postgres/skillpg/skillpg_test.go index f4fee68ef..8824d4534 100644 --- a/services/core/internal/persistence/postgres/skillpg/skillpg_test.go +++ b/services/core/internal/persistence/postgres/skillpg/skillpg_test.go @@ -106,9 +106,9 @@ func TestOwnershipEncryptionAndVersions(t *testing.T) { if created.DefaultVersion != 1 || created.LatestVersion != 1 { t.Fatal("initial pointers", created) } - metadata, err := skillpg.New(pgunit.NewPool(f.pool), nil).Skill(ctx, tenant, id) + metadata, err := skillpg.New(pgunit.NewPool(f.pool), pgtest.CredentialKey(t)).Skill(ctx, tenant, id) if err != nil || metadata.Name != "proof" { - t.Fatal("metadata requires no content key", err) + t.Fatal("metadata required the content key", err) } var contents []byte if err = f.pool.QueryRow(ctx, "SELECT contents FROM skill_versions WHERE tenant_id=$1", tenant).Scan(&contents); err != nil { @@ -288,8 +288,8 @@ func TestListsAcceptLimitZero(t *testing.T) { func TestMetadataTracksDefaultVersion(t *testing.T) { pool := pgtest.Open(t) f := newFixture(t, pool, testCipher(t, 74)) - // Metadata reads and default changes need no content key. - metadataOnly := newFixture(t, pool, nil) + // Metadata reads and default changes work under a replaced key. + replaced := newFixture(t, pool, pgtest.CredentialKey(t)) ctx := t.Context() tenant, foreign := uuid.NewString(), uuid.NewString() names := []string{"first-proof", "second-proof", "third-proof"} @@ -308,12 +308,12 @@ func TestMetadataTracksDefaultVersion(t *testing.T) { } assertStored := func(version, latest int64) { t.Helper() - value, err := metadataOnly.store.Skill(ctx, tenant, id) + value, err := replaced.store.Skill(ctx, tenant, id) if err != nil { t.Fatal("metadata read without content key", err) } assertMetadata(value, version, latest) - page, err := metadataOnly.service.ListSkills(ctx, skills.ListSkills{TenantID: tenant, Limit: 10, Ascending: true}) + page, err := replaced.service.ListSkills(ctx, skills.ListSkills{TenantID: tenant, Limit: 10, Ascending: true}) if err != nil || len(page.Skills) != 1 { t.Fatal("metadata list without content key", page, err) } @@ -329,28 +329,23 @@ func TestMetadataTracksDefaultVersion(t *testing.T) { } assertStored(1, 2) for _, version := range []string{"2", "1"} { - updated, err := metadataOnly.service.SetDefaultVersion(ctx, skills.SetDefaultVersion{TenantID: tenant, SkillID: id, Version: version}) + updated, err := replaced.service.SetDefaultVersion(ctx, skills.SetDefaultVersion{TenantID: tenant, SkillID: id, Version: version}) if err != nil { t.Fatal("default update without content key", err) } assertMetadata(updated, updated.DefaultVersion, 2) assertStored(updated.DefaultVersion, 2) } - if _, err := metadataOnly.service.SetDefaultVersion(ctx, skills.SetDefaultVersion{TenantID: foreign, SkillID: id, Version: "2"}); !errors.Is(err, skills.ErrNotFound) { + if _, err := replaced.service.SetDefaultVersion(ctx, skills.SetDefaultVersion{TenantID: foreign, SkillID: id, Version: "2"}); !errors.Is(err, skills.ErrNotFound) { t.Fatal("foreign default update", err) } - if _, err := metadataOnly.service.SetDefaultVersion(ctx, skills.SetDefaultVersion{TenantID: tenant, SkillID: id, Version: "999"}); !errors.Is(err, skills.ErrNotFound) { + if _, err := replaced.service.SetDefaultVersion(ctx, skills.SetDefaultVersion{TenantID: tenant, SkillID: id, Version: "999"}); !errors.Is(err, skills.ErrNotFound) { t.Fatal("missing default update", err) } assertStored(1, 2) - // Uploads and content reads need the key; the failed upload stores nothing. - if _, err := metadataOnly.service.CreateVersion(ctx, skills.CreateVersion{TenantID: tenant, SkillID: id, Archive: archives[2]}); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("upload without content key", err) + if _, err := replaced.service.ReadDefaultVersion(ctx, skills.ReadDefaultVersion{TenantID: tenant, SkillID: id}); err == nil || errors.Is(err, skills.ErrNotFound) { + t.Fatal("a replaced key opened content", err) } - if _, err := metadataOnly.service.ReadDefaultVersion(ctx, skills.ReadDefaultVersion{TenantID: tenant, SkillID: id}); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("content read without content key", err) - } - assertStored(1, 2) if _, err := f.service.CreateVersion(ctx, skills.CreateVersion{TenantID: tenant, SkillID: id, Archive: archives[2], MakeDefault: true}); err != nil { t.Fatal(err) } @@ -502,10 +497,7 @@ func TestFreezeReads(t *testing.T) { if _, err := skillpg.ReadVersionForFreeze(ctx, q, cipher, tenantID, first.ID, 2); !errors.Is(err, skills.ErrNotFound) { t.Fatal("missing version", err) } - if _, err := skillpg.ReadVersionForFreeze(ctx, q, nil, tenantID, first.ID, 1); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("read without a key", err) - } - if _, err := skillpg.ReadVersionForFreeze(ctx, q, testCipher(t, 49), tenantID, first.ID, 1); err == nil || errors.Is(err, skills.ErrNotFound) || errors.Is(err, credentialcrypto.ErrUnavailable) { + if _, err := skillpg.ReadVersionForFreeze(ctx, q, testCipher(t, 49), tenantID, first.ID, 1); err == nil || errors.Is(err, skills.ErrNotFound) { t.Fatal("another key opened the version", err) } } diff --git a/services/core/internal/persistence/postgres/templatepg/audit_test.go b/services/core/internal/persistence/postgres/templatepg/audit_test.go index b4611b238..2dc1e8de4 100644 --- a/services/core/internal/persistence/postgres/templatepg/audit_test.go +++ b/services/core/internal/persistence/postgres/templatepg/audit_test.go @@ -114,7 +114,7 @@ func TestInvalidWriteAuditSourcePassesThrough(t *testing.T) { if !errors.Is(err, writeaudit.ErrInvalidSource) { t.Fatal("mismatched source tenant", err) } - if page, err := f.keyless.List(t.Context(), tenant, environmenttemplates.ListQuery{Limit: 1}); err != nil || len(page.Templates) != 0 { + if page, err := f.replaced.List(t.Context(), tenant, environmenttemplates.ListQuery{Limit: 1}); err != nil || len(page.Templates) != 0 { t.Fatal("rejected source left a Template", err) } } @@ -168,7 +168,7 @@ func TestAdminDeleteAuditCommitsWithTheDeletion(t *testing.T) { if err := f.pool.QueryRow(t.Context(), `SELECT (SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1 AND action='delete'),(SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1 AND resource_type='environment_template' AND resource_id=$2)`, tenant, id).Scan(&operations, &owners); err != nil || operations != 0 || owners != 0 { t.Fatal("administrator impersonated public-key provenance", err) } - if _, err := f.keyless.Get(t.Context(), tenant, id); !errors.Is(err, environmenttemplates.ErrNotFound) { + if _, err := f.replaced.Get(t.Context(), tenant, id); !errors.Is(err, environmenttemplates.ErrNotFound) { t.Fatal("deleted Template is visible", err) } } diff --git a/services/core/internal/persistence/postgres/templatepg/seal.go b/services/core/internal/persistence/postgres/templatepg/seal.go index 06b2f0749..c9e45ac9e 100644 --- a/services/core/internal/persistence/postgres/templatepg/seal.go +++ b/services/core/internal/persistence/postgres/templatepg/seal.go @@ -12,8 +12,7 @@ import ( ) // sealed is an Input's column values. Empty confidential fields are stored as -// NULL without using the key, so Templates without them need none; any other -// confidential field without a key fails with credentialcrypto.ErrUnavailable. +// NULL without using the key. type sealed struct { files, fileContents []byte packages, env, commands []byte @@ -28,9 +27,6 @@ func (s *Store) seal(tenant, id pgtype.UUID, in environmenttemplates.Input) (sea return out, err } if len(in.Files) > 0 { - if s.cipher == nil { - return out, credentialcrypto.ErrUnavailable - } plaintext, err := json.Marshal(in.Files) if err != nil { return out, err @@ -62,9 +58,6 @@ func (s *Store) seal(tenant, id pgtype.UUID, in environmenttemplates.Input) (sea if field.empty { continue } - if s.cipher == nil { - return out, credentialcrypto.ErrUnavailable - } plaintext, err := json.Marshal(field.value) if err != nil { return out, err @@ -80,9 +73,6 @@ func (s *Store) openSetup(tenant, id pgtype.UUID, field string, ciphertext []byt if len(ciphertext) == 0 { return nil } - if s.cipher == nil { - return credentialcrypto.ErrUnavailable - } plaintext, err := s.cipher.OpenEnvironmentSetup(ciphertext, setupBinding(tenant, id, field)) if err != nil { return err diff --git a/services/core/internal/persistence/postgres/templatepg/store.go b/services/core/internal/persistence/postgres/templatepg/store.go index cb3d1d502..b4afbb0dc 100644 --- a/services/core/internal/persistence/postgres/templatepg/store.go +++ b/services/core/internal/persistence/postgres/templatepg/store.go @@ -36,9 +36,8 @@ var ( _ environmenttemplates.Reader = (*Store)(nil) ) -// New returns a Store. Without a credential key (cipher nil), writes and -// resolutions that seal or open confidential configuration fail with -// credentialcrypto.ErrUnavailable; safe metadata stays readable. +// New returns a Store that seals and opens confidential configuration with +// cipher. func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { return &Store{pool: pool, cipher: cipher} } @@ -217,9 +216,6 @@ func (s *Store) Resolve(ctx context.Context, tenantID, templateID string) (envir } return resolved, nil } - if s.cipher == nil { - return environmenttemplates.Resolved{}, credentialcrypto.ErrUnavailable - } plaintext, err := s.cipher.OpenEnvironmentFile(row.FileContents, fileBinding(tenant, id)) if err != nil { return environmenttemplates.Resolved{}, err diff --git a/services/core/internal/persistence/postgres/templatepg/store_test.go b/services/core/internal/persistence/postgres/templatepg/store_test.go index 7710eb447..589462c3f 100644 --- a/services/core/internal/persistence/postgres/templatepg/store_test.go +++ b/services/core/internal/persistence/postgres/templatepg/store_test.go @@ -22,13 +22,13 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// fixture is a Template adapter with the credential key and one without it, -// on the same database, plus the service that validates writes. +// fixture is a Template adapter under the credential key and one under a +// replaced key, on the same database, plus the service that validates writes. type fixture struct { - pool *pgxpool.Pool - keyed *templatepg.Store - keyless *templatepg.Store - service *environmenttemplates.Service + pool *pgxpool.Pool + keyed *templatepg.Store + replaced *templatepg.Store + service *environmenttemplates.Service } func newFixture(t *testing.T, pool *pgxpool.Pool) fixture { @@ -42,7 +42,7 @@ func newFixture(t *testing.T, pool *pgxpool.Pool) fixture { if err != nil { t.Fatal(err) } - return fixture{pool: pool, keyed: keyed, keyless: templatepg.New(pgunit.NewPool(pool), nil), service: service} + return fixture{pool: pool, keyed: keyed, replaced: templatepg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)), service: service} } func (f fixture) create(t *testing.T, tenant string, in environmenttemplates.Input) environmenttemplates.Template { @@ -172,12 +172,12 @@ func TestEmptyUpdateTouchesTimeWithoutKeyOrContents(t *testing.T) { return contents } before := row() - if _, err := f.keyless.Update(t.Context(), uuid.NewString(), original.ID, environmenttemplates.Input{}); !errors.Is(err, environmenttemplates.ErrNotFound) { + if _, err := f.replaced.Update(t.Context(), uuid.NewString(), original.ID, environmenttemplates.Input{}); !errors.Is(err, environmenttemplates.ErrNotFound) { t.Fatal("foreign empty update was admitted", err) } - updated, err := f.keyless.Update(t.Context(), tenant, original.ID, environmenttemplates.Input{}) + updated, err := f.replaced.Update(t.Context(), tenant, original.ID, environmenttemplates.Input{}) if err != nil || !updated.UpdatedAt.After(original.UpdatedAt) { - t.Fatal("empty update did not advance the timestamp without a key", err) + t.Fatal("empty update did not advance the timestamp under a replaced key", err) } original.UpdatedAt = updated.UpdatedAt if !reflect.DeepEqual(updated, original) || !bytes.Equal(before, row()) { @@ -201,9 +201,9 @@ func TestNetworkPolicyRoundTripAndReplacement(t *testing.T) { } created := f.create(t, tenant, environmenttemplates.Input{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains}) check(created, nil) - check(f.keyless.Get(ctx, tenant, created.ID)) + check(f.replaced.Get(ctx, tenant, created.ID)) check(f.resolve(t, tenant, created.ID).Template, nil) - page, err := f.keyless.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 1, Ascending: true}) + page, err := f.replaced.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 1, Ascending: true}) if err != nil || len(page.Templates) != 1 { t.Fatal(page, err) } @@ -220,7 +220,7 @@ func TestNetworkPolicyRoundTripAndReplacement(t *testing.T) { if _, err := f.service.Update(ctx, environmenttemplates.UpdateCommand{TenantID: tenant, TemplateID: created.ID, Input: in}); !errors.Is(err, environmenttemplates.ErrInvalidInput) { t.Fatal("invalid policy replacement", err) } - check(f.keyless.Get(ctx, tenant, created.ID)) + check(f.replaced.Get(ctx, tenant, created.ID)) } domains = []string{"other.example.com"} check(f.update(t, tenant, created.ID, environmenttemplates.Input{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains}), nil) @@ -237,7 +237,7 @@ func TestSetupSealedAndReplacedPerField(t *testing.T) { tenant, foreign := uuid.NewString(), uuid.NewString() setup := environmentconfig.Setup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []environmentconfig.SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}} template := f.create(t, tenant, environmenttemplates.Input{Setup: setup, SetEnv: true, SetCommands: true, SetPackages: true}) - public, err := f.keyless.Get(t.Context(), tenant, template.ID) + public, err := f.replaced.Get(t.Context(), tenant, template.ID) if err != nil || !reflect.DeepEqual(public.Packages.NPM, setup.Packages.NPM) { t.Fatal("public metadata requires plaintext or key", err) } @@ -266,7 +266,7 @@ func TestInitialFilesSealedWithNoncanonicalIDs(t *testing.T) { canary := []byte("private-initial-file-canary\x00\xff") files := []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/a/data", Data: canary}, {Type: "file_id", Path: "/workspace/b", FileID: "file-" + uuid.NewString()}} template := f.create(t, tenant, environmenttemplates.Input{SetFiles: true, Files: files}) - public, err := f.keyless.Get(t.Context(), tenant, template.ID) + public, err := f.replaced.Get(t.Context(), tenant, template.ID) if err != nil || len(public.Files) != 2 || *public.Files[0].SizeBytes != int64(len(canary)) { t.Fatal("public read depends on the key", err) } @@ -294,11 +294,11 @@ func TestSkillsAndPluginsSealedAndPreserved(t *testing.T) { } tenant, foreign := uuid.NewString(), uuid.NewString() template := f.create(t, tenant, environmenttemplates.Input{SetSkills: true, SetPlugins: true, SetDirectories: true, Setup: setup}) - public, err := f.keyless.Get(ctx, tenant, template.ID) + public, err := f.replaced.Get(ctx, tenant, template.ID) if err != nil || len(public.Skills) != 1 || len(public.Plugins) != 1 || !reflect.DeepEqual(public.CapabilityDirectories, setup.CapabilityDirectories) { t.Fatal("safe metadata without the key", err) } - if page, err := f.keyless.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 20}); err != nil || len(page.Templates) != 1 || len(page.Templates[0].Plugins) != 1 { + if page, err := f.replaced.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 20}); err != nil || len(page.Templates) != 1 || len(page.Templates[0].Plugins) != 1 { t.Fatal("list", err) } f.requireSealed(t, template.ID, "skill_contents", "skill-private-canary") @@ -354,18 +354,15 @@ func TestUnstorableTextIsRejected(t *testing.T) { } } -func TestMissingKeyIsUnavailable(t *testing.T) { +func TestReplacedKeyCannotResolve(t *testing.T) { f := newFixture(t, pgtest.Open(t)) tenant := uuid.NewString() setup := environmenttemplates.Input{Setup: environmentconfig.Setup{Env: map[string]string{"PRIVATE_SETUP": "env-canary"}}} files := environmenttemplates.Input{SetFiles: true, Files: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/input.txt", Data: []byte("file-canary")}}} for _, in := range []environmenttemplates.Input{setup, files} { - if _, err := f.keyless.Create(t.Context(), tenant, in); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("confidential create without a key", err) - } template := f.create(t, tenant, in) - if _, err := f.keyless.Resolve(t.Context(), tenant, template.ID); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("confidential resolve without a key", err) + if _, err := f.replaced.Resolve(t.Context(), tenant, template.ID); err == nil || errors.Is(err, environmenttemplates.ErrNotFound) || strings.Contains(err.Error(), "canary") { + t.Fatal("confidential resolve under a replaced key", err) } } } @@ -387,10 +384,10 @@ func TestStoredPackagesRejected(t *testing.T) { internal := func(err error) bool { return err != nil && !errors.Is(err, environmenttemplates.ErrInvalidInput) } for _, value := range []string{`null`, `[]`, `["jq"]`} { store(`{"npm":[],"python":[],"system":` + value + `}`) - if _, err := f.keyless.Get(ctx, tenant, template.ID); !internal(err) { + if _, err := f.replaced.Get(ctx, tenant, template.ID); !internal(err) { t.Fatal("get did not fail internally on removed system packages", value, err) } - if _, err := f.keyless.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 1}); !internal(err) { + if _, err := f.replaced.List(ctx, tenant, environmenttemplates.ListQuery{Limit: 1}); !internal(err) { t.Fatal("list did not fail internally on removed system packages", value, err) } if _, err := f.keyed.Resolve(ctx, tenant, template.ID); !internal(err) { @@ -402,7 +399,7 @@ func TestStoredPackagesRejected(t *testing.T) { t.Fatal("resolve accepted packages that fail validation", err) } store(`{"npm":["semver"],"python":["packaging"]}`) - if got, err := f.keyless.Get(ctx, tenant, template.ID); err != nil || !reflect.DeepEqual(got.Packages, v1.EnvironmentPackages{NPM: []string{"semver"}, Python: []string{"packaging"}}) { + if got, err := f.replaced.Get(ctx, tenant, template.ID); err != nil || !reflect.DeepEqual(got.Packages, v1.EnvironmentPackages{NPM: []string{"semver"}, Python: []string{"packaging"}}) { t.Fatal("supported stored package managers rejected", got.Packages, err) } } diff --git a/services/core/internal/persistence/postgres/vaultpg/credentials_test.go b/services/core/internal/persistence/postgres/vaultpg/credentials_test.go index a299087b4..a6516b503 100644 --- a/services/core/internal/persistence/postgres/vaultpg/credentials_test.go +++ b/services/core/internal/persistence/postgres/vaultpg/credentials_test.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) @@ -23,10 +24,10 @@ func TestStaticCredentialsPersistEncryptedAndRemainScoped(t *testing.T) { store, pool := openStore(t) ctx := t.Context() tenant, foreignTenant := uuid.NewString(), uuid.NewString() - keyless := newService(t, pool, nil, nil) + replaced := newService(t, pool, pgtest.CredentialKey(t), nil) var owned []vaults.Vault for _, owner := range []string{tenant, tenant, foreignTenant} { - owned = append(owned, createVault(t, keyless, owner)) + owned = append(owned, createVault(t, replaced, owner)) } key, randomToken := make([]byte, 32), make([]byte, 32) if _, err := rand.Read(key); err != nil { @@ -60,9 +61,6 @@ func TestStaticCredentialsPersistEncryptedAndRemainScoped(t *testing.T) { t.Fatal("separate creates reused a credential identity") } valid := vaults.CreateStaticCredential{TenantID: tenant, VaultID: owned[0].ID, Name: "Rejected", MCPServerURL: "https://mcp.example/tools", Token: opaque} - if _, err := keyless.CreateStaticCredential(ctx, valid); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("missing encryption key did not fail writes closed") - } for _, target := range []struct{ tenant, vault string }{{tenant, owned[2].ID}, {foreignTenant, owned[0].ID}, {tenant, uuid.NewString()}, {tenant, "invalid"}} { command := valid command.TenantID, command.VaultID = target.tenant, target.vault @@ -137,7 +135,7 @@ func TestStaticCredentialsPersistEncryptedAndRemainScoped(t *testing.T) { } } -func TestCredentialListFilteringOwnershipAndKeylessReconnect(t *testing.T) { +func TestCredentialListFilteringOwnershipAndReplacedKeyReconnect(t *testing.T) { store, pool := openStore(t) ctx := t.Context() tenant, foreign := uuid.NewString(), uuid.NewString() @@ -255,7 +253,7 @@ func TestCredentialListFilteringOwnershipAndKeylessReconnect(t *testing.T) { pool.Close() reopened, pool := openStore(t) if got := read(reopened, true, nil, 20); !reflect.DeepEqual(got, all) || snapshot() != before { - t.Fatal("keyless reads/restart changed metadata, classification or ciphertext") + t.Fatal("reads or a restart under a replaced key changed metadata, classification or ciphertext") } } @@ -400,7 +398,7 @@ func TestCredentialDeletionScopeBindingAndRestart(t *testing.T) { tenant, foreign := uuid.NewString(), uuid.NewString() key := bytes.Repeat([]byte{41}, 32) service := newService(t, pool, newCipher(t, key), nil) - keyless := newService(t, pool, nil, nil) + replaced := newService(t, pool, pgtest.CredentialKey(t), nil) vault, wrong := createVault(t, service, tenant), createVault(t, service, tenant) original := createStatic(t, service, tenant, vault.ID, "original", "https://mcp.example/tools", "original-secret") attached := []string{vault.ID} @@ -417,12 +415,12 @@ func TestCredentialDeletionScopeBindingAndRestart(t *testing.T) { {foreign, vault.ID, original.ID}, {tenant, wrong.ID, original.ID}, {tenant, vault.ID, uuid.NewString()}, {tenant, "invalid", original.ID}, {tenant, vault.ID, "invalid"}, } { - if _, err := remove(keyless, scope.tenant, scope.vault, scope.id); !errors.Is(err, vaults.ErrNotFound) { + if _, err := remove(replaced, scope.tenant, scope.vault, scope.id); !errors.Is(err, vaults.ErrNotFound) { t.Fatal("foreign or invalid delete was accepted", err) } } // An actual database write failure must leave the resource and token intact. - _, deletionErr := remove(newService(t, readOnlyPool(t, pool), nil, nil), tenant, vault.ID, original.ID) + _, deletionErr := remove(newService(t, readOnlyPool(t, pool), pgtest.CredentialKey(t), nil), tenant, vault.ID, original.ID) if !isReadOnlyFailure(deletionErr) { t.Fatal("failed mutation was accepted or translated", deletionErr) } @@ -432,12 +430,12 @@ func TestCredentialDeletionScopeBindingAndRestart(t *testing.T) { if token, err := bearerToken(t.Context(), service, tenant, attached, selected[0]); err != nil || token != retained { t.Fatal("rejected deletion changed the stored token") } - // Delete without a key, even if the stored payload is damaged. + // Delete under a replaced key, even if the stored payload is damaged. if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=decode('00','hex') WHERE id=$1", original.ID); err != nil { t.Fatal(err) } - if id, err := remove(keyless, tenant, vault.ID, original.ID); err != nil || id != original.ID { - t.Fatal("keyless deletion failed", err) + if id, err := remove(replaced, tenant, vault.ID, original.ID); err != nil || id != original.ID { + t.Fatal("deletion under a replaced key failed", err) } var count int if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM vault_credentials WHERE id=$1", original.ID).Scan(&count); err != nil || count != 0 { diff --git a/services/core/internal/persistence/postgres/vaultpg/fixture_test.go b/services/core/internal/persistence/postgres/vaultpg/fixture_test.go index 6d23ff73e..853529a4d 100644 --- a/services/core/internal/persistence/postgres/vaultpg/fixture_test.go +++ b/services/core/internal/persistence/postgres/vaultpg/fixture_test.go @@ -18,11 +18,11 @@ import ( ) // openStore opens the shared test database, as a restarted Core would. The -// Store has no credential key, which reads never need. +// Store has the shared test key, which reads never need. func openStore(t *testing.T) (*vaultpg.Store, *pgxpool.Pool) { t.Helper() pool := pgtest.Open(t) - return vaultpg.New(pgunit.NewPool(pool), nil), pool + return vaultpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)), pool } // readOnlyPool fails every write with a real PostgreSQL error. @@ -54,8 +54,7 @@ func newCipher(t *testing.T, key []byte) *credentialcrypto.Cipher { return cipher } -// keyedStore is a new Store on pool; a nil cipher is a Core without a -// credential key. +// keyedStore is a new Store on pool under cipher. func keyedStore(pool *pgxpool.Pool, cipher *credentialcrypto.Cipher) *vaultpg.Store { return vaultpg.New(pgunit.NewPool(pool), cipher) } diff --git a/services/core/internal/persistence/postgres/vaultpg/oauth_test.go b/services/core/internal/persistence/postgres/vaultpg/oauth_test.go index 5b4c47efe..18ed72bc5 100644 --- a/services/core/internal/persistence/postgres/vaultpg/oauth_test.go +++ b/services/core/internal/persistence/postgres/vaultpg/oauth_test.go @@ -97,11 +97,11 @@ func TestOAuthCredentialMetadataEncryptionAndScope(t *testing.T) { credential := f.create(t, input) got, err := f.store.GetCredential(t.Context(), f.tenant, f.vault.ID, credential.ID) if err != nil || !reflect.DeepEqual(got, credential) { - t.Fatal("keyless safe metadata changed", err) + t.Fatal("safe metadata changed", err) } page, err := f.store.ListCredentials(t.Context(), f.tenant, f.vault.ID, vaults.PageQuery{Limit: 20, Ascending: true}) if err != nil || len(page.Credentials) != 1 || !reflect.DeepEqual(page.Credentials[0], credential) { - t.Fatal("keyless listing failed", err) + t.Fatal("listing failed", err) } encoded, _ := json.Marshal(page) var ciphertext []byte @@ -137,13 +137,6 @@ func TestOAuthCredentialMetadataEncryptionAndScope(t *testing.T) { if _, err := f.service.CreateOAuthCredential(t.Context(), foreign); !errors.Is(err, vaults.ErrNotFound) { t.Fatal("foreign creation admitted") } - keyless := f.otherService(t, nil, counting) - if _, err := keyless.CreateOAuthCredential(t.Context(), input); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("keyless creation admitted") - } - if token, err := f.token(t.Context(), keyless, credential); !errors.Is(err, credentialcrypto.ErrUnavailable) || token != "" { - t.Fatal("keyless execution admitted") - } wrong := f.otherService(t, newCipher(t, bytes.Repeat([]byte{18}, 32)), counting) if token, err := f.token(t.Context(), wrong, credential); err == nil || token != "" { t.Fatal("wrong key executed") diff --git a/services/core/internal/persistence/postgres/vaultpg/seal.go b/services/core/internal/persistence/postgres/vaultpg/seal.go index 58e73e2d0..0ea527470 100644 --- a/services/core/internal/persistence/postgres/vaultpg/seal.go +++ b/services/core/internal/persistence/postgres/vaultpg/seal.go @@ -32,9 +32,6 @@ type oauthPayload struct { // sealStatic seals a static_bearer token. func (s *Store) sealStatic(scope credentialcrypto.Binding, token string) ([]byte, error) { - if s.cipher == nil { - return nil, credentialcrypto.ErrUnavailable - } ciphertext, err := s.cipher.Seal([]byte(token), scope) if err != nil { return nil, errors.New("credential encryption failed") @@ -44,9 +41,6 @@ func (s *Store) sealStatic(scope credentialcrypto.Binding, token string) ([]byte // openStatic opens a static_bearer token. func (s *Store) openStatic(scope credentialcrypto.Binding, ciphertext []byte) (string, error) { - if s.cipher == nil { - return "", credentialcrypto.ErrUnavailable - } plaintext, err := s.cipher.Open(ciphertext, scope) if err != nil { return "", errors.New("MCP credential decryption failed") @@ -57,9 +51,6 @@ func (s *Store) openStatic(scope credentialcrypto.Binding, ciphertext []byte) (s // sealOAuth encodes the grant's metadata for its column and seals the whole // grant. func (s *Store) sealOAuth(scope credentialcrypto.Binding, grant vaults.OAuthGrant) (metadata, ciphertext []byte, err error) { - if s.cipher == nil { - return nil, nil, credentialcrypto.ErrUnavailable - } metadata, err = json.Marshal(grant.Metadata) if err != nil { return nil, nil, errors.New("credential encoding failed") @@ -79,9 +70,6 @@ func (s *Store) sealOAuth(scope credentialcrypto.Binding, grant vaults.OAuthGran // openOAuth opens a grant and authenticates the stored metadata against the // sealed copy. func (s *Store) openOAuth(scope credentialcrypto.Binding, stored *vaults.OAuthMetadata, ciphertext []byte) (vaults.OAuthGrant, error) { - if s.cipher == nil { - return vaults.OAuthGrant{}, credentialcrypto.ErrUnavailable - } plaintext, err := s.cipher.Open(ciphertext, scope) if err != nil { return vaults.OAuthGrant{}, errors.New("OAuth credential decryption failed") diff --git a/services/core/internal/persistence/postgres/vaultpg/seal_test.go b/services/core/internal/persistence/postgres/vaultpg/seal_test.go index e04ff428c..d77172ab8 100644 --- a/services/core/internal/persistence/postgres/vaultpg/seal_test.go +++ b/services/core/internal/persistence/postgres/vaultpg/seal_test.go @@ -11,17 +11,17 @@ import ( "github.com/google/uuid" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) // The Store seals to the canonical binding Core has always used, so secrets -// sealed before the Store owned the key still open. A missing key is -// credentialcrypto.ErrUnavailable, and a wrong binding is a decryption -// failure, never a missing key, row or token. +// sealed before the Store owned the key still open. A replaced key or a wrong +// binding is a decryption failure, never a missing row or token. func TestCredentialSecretsKeepTheirSealedFormat(t *testing.T) { _, pool := openStore(t) cipher := newCipher(t, bytes.Repeat([]byte{61}, 32)) - service, keyless := newService(t, pool, cipher, nil), newService(t, pool, nil, nil) + service, replaced := newService(t, pool, cipher, nil), newService(t, pool, pgtest.CredentialKey(t), nil) tenant, url := uuid.NewString(), "https://mcp.example/tools" vault := createVault(t, service, tenant) expiry := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) @@ -77,14 +77,14 @@ func TestCredentialSecretsKeepTheirSealedFormat(t *testing.T) { write(static, []byte("old-static"), scope(static)) write(oauth, legacy, scope(oauth)) for _, tc := range []struct { - credential vaults.Credential - want string - }{{static, "old-static"}, {oauth, "old-access"}} { + credential vaults.Credential + want, fails string + }{{static, "old-static", "MCP credential decryption failed"}, {oauth, "old-access", "OAuth credential decryption failed"}} { if got, err := token(service, tc.credential); err != nil || got != tc.want { t.Fatal("a secret sealed before the move did not open", err) } - if got, err := token(keyless, tc.credential); !errors.Is(err, credentialcrypto.ErrUnavailable) || got != "" { - t.Fatal("a keyless Store opened a secret", err) + if got, err := token(replaced, tc.credential); err == nil || err.Error() != tc.fails || got != "" { + t.Fatal("a replaced key opened a secret", err) } } if _, err := service.UpdateOAuthCredential(t.Context(), vaults.UpdateOAuthCredential{TenantID: tenant, VaultID: vault.ID, CredentialID: oauth.ID, AccessToken: ptr("patched-access")}); err != nil { @@ -93,21 +93,11 @@ func TestCredentialSecretsKeepTheirSealedFormat(t *testing.T) { if grant := readGrant(t, pool, cipher, tenant, oauth); grant.AccessToken != "patched-access" || grant.RefreshToken != "refresh" { t.Fatal("the replaced grant lost its material") } - // Without a key, every write that seals fails first, before a malformed Vault. for _, create := range []func(*vaults.Service, string) (vaults.Credential, error){createToken, createOAuth} { - if _, err := create(keyless, "not-a-vault"); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("a keyless creation did not fail closed", err) - } if _, err := create(service, "not-a-vault"); !errors.Is(err, vaults.ErrNotFound) { t.Fatal("a malformed Vault named one", err) } } - if _, err := keyless.UpdateStaticCredential(t.Context(), vaults.UpdateStaticCredential{TenantID: tenant, VaultID: vault.ID, CredentialID: static.ID, Token: "rejected"}); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("a keyless replacement did not fail closed", err) - } - if _, err := keyless.UpdateOAuthCredential(t.Context(), vaults.UpdateOAuthCredential{TenantID: tenant, VaultID: vault.ID, CredentialID: oauth.ID, AccessToken: ptr("rejected")}); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("a keyless OAuth replacement did not fail closed", err) - } // A secret sealed to another Credential or destination does not open. wrongID, wrongDestination := scope(static), scope(oauth) wrongID.CredentialID = oauth.ID diff --git a/services/core/internal/persistence/postgres/vaultpg/selection_test.go b/services/core/internal/persistence/postgres/vaultpg/selection_test.go index 81f0a658e..449057c61 100644 --- a/services/core/internal/persistence/postgres/vaultpg/selection_test.go +++ b/services/core/internal/persistence/postgres/vaultpg/selection_test.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) @@ -22,7 +22,7 @@ func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { t.Fatal(err) } service := newService(t, pool, newCipher(t, key), nil) - keyless := newService(t, pool, nil, nil) + replaced := newService(t, pool, pgtest.CredentialKey(t), nil) var owned []vaults.Vault for _, owner := range []string{tenant, tenant, foreign} { owned = append(owned, createVault(t, service, owner)) @@ -39,8 +39,8 @@ func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { selectFor := func(service *vaults.Service, tenant string, attached []string, requests []vaults.MCPCredentialRequest) ([]vaults.MCPCredentialBinding, error) { return service.ResolveMCPCredentials(t.Context(), vaults.ResolveMCPCredentials{TenantID: tenant, VaultIDs: attached, Requests: requests}) } - // Selection reads metadata only, so a keyless Core can select. - bindings, err := selectFor(keyless, tenant, attached, requests) + // Selection reads metadata only, so it works under a replaced key. + bindings, err := selectFor(replaced, tenant, attached, requests) if err != nil || len(bindings) != 2 || bindings[0].CredentialID != first.ID || bindings[0].AuthType != vaults.AuthStaticBearer || bindings[1].CredentialID != "" { t.Fatal("metadata selection or frozen anonymous decision differs", err) } @@ -49,11 +49,11 @@ func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { t.Fatal("private binding contains secret material") } second := create(owned[1]) - if _, err := selectFor(keyless, tenant, attached, requests); !isSelectionError(err, true, "multiple attached vault credentials match MCP server_url "+destination+"; specify credential_id") { + if _, err := selectFor(replaced, tenant, attached, requests); !isSelectionError(err, true, "multiple attached vault credentials match MCP server_url "+destination+"; specify credential_id") { t.Fatal("ambiguous selection was admitted", err) } requests[0].CredentialID = &second.ID - explicit, err := selectFor(keyless, tenant, attached, requests) + explicit, err := selectFor(replaced, tenant, attached, requests) if err != nil || explicit[0].CredentialID != second.ID || requests[1].CredentialID != nil { t.Fatal("explicit selection did not disambiguate", err) } @@ -72,25 +72,21 @@ func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { {tenant, []string{owned[0].ID, owned[2].ID}, first.ID, destination, ""}, {tenant, []string{uuid.NewString()}, first.ID, destination, ""}, } { - _, err := selectFor(keyless, tc.owner, tc.vaults, []vaults.MCPCredentialRequest{{ServerLabel: "tools", ServerURL: tc.url, CredentialID: &tc.id}}) + _, err := selectFor(replaced, tc.owner, tc.vaults, []vaults.MCPCredentialRequest{{ServerLabel: "tools", ServerURL: tc.url, CredentialID: &tc.id}}) if tc.message == "" && !errors.Is(err, vaults.ErrNotFound) || tc.message != "" && !isSelectionError(err, false, tc.message) { t.Fatal("unowned, unattached or wrong-destination selection was admitted", err) } } - if _, err := selectFor(keyless, tenant, nil, []vaults.MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination, CredentialID: &first.ID}}); !isSelectionError(err, false, "MCP credential_id requires an attached vault") { + if _, err := selectFor(replaced, tenant, nil, []vaults.MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination, CredentialID: &first.ID}}); !isSelectionError(err, false, "MCP credential_id requires an attached vault") { t.Fatal("a reference without attachments was admitted", err) } pool.Close() store, pool = openStore(t) service = newService(t, pool, newCipher(t, bytes.Clone(key)), nil) - keyless = newService(t, pool, nil, nil) got, err := bearerToken(t.Context(), service, tenant, attached, bindings[0]) if err != nil || got != token { t.Fatal("frozen selection or opaque bytes changed across restart", err) } - if got, err := bearerToken(t.Context(), keyless, tenant, attached, bindings[0]); !errors.Is(err, credentialcrypto.ErrUnavailable) || got != "" { - t.Fatal("missing key did not fail execution closed") - } key[0] ^= 1 if got, err := bearerToken(t.Context(), newService(t, pool, newCipher(t, key), nil), tenant, attached, bindings[0]); err == nil || got != "" || strings.Contains(err.Error(), token) { t.Fatal("wrong key leaked or decrypted a credential") diff --git a/services/core/internal/persistence/postgres/vaultpg/vaultpg.go b/services/core/internal/persistence/postgres/vaultpg/vaultpg.go index f8863309e..1adb7e834 100644 --- a/services/core/internal/persistence/postgres/vaultpg/vaultpg.go +++ b/services/core/internal/persistence/postgres/vaultpg/vaultpg.go @@ -30,9 +30,7 @@ type Store struct { var _ vaults.Storage = (*Store)(nil) -// New returns a Store. Without a credential key (cipher nil), operations that -// seal or open a secret fail with credentialcrypto.ErrUnavailable; Vaults, -// Credential metadata, selection and deletion keep working. +// New returns a Store that seals and opens secrets with cipher. func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { return &Store{pool: pool, cipher: cipher} } diff --git a/services/core/internal/persistence/postgres/vaultpg/vaults_test.go b/services/core/internal/persistence/postgres/vaultpg/vaults_test.go index f95282651..6a7ce4da8 100644 --- a/services/core/internal/persistence/postgres/vaultpg/vaults_test.go +++ b/services/core/internal/persistence/postgres/vaultpg/vaults_test.go @@ -13,13 +13,14 @@ import ( "github.com/google/uuid" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) func TestVaultsPersistAndStayTenantScoped(t *testing.T) { store, pool := openStore(t) - service := newService(t, pool, nil, nil) + service := newService(t, pool, pgtest.CredentialKey(t), nil) ctx := t.Context() tenantA, tenantB := uuid.NewString(), uuid.NewString() before := time.Now().Add(-time.Second) @@ -67,7 +68,7 @@ func TestVaultsPersistAndStayTenantScoped(t *testing.T) { func TestVaultsRejectInvalidInputWithoutWrites(t *testing.T) { store, pool := openStore(t) - service := newService(t, pool, nil, nil) + service := newService(t, pool, pgtest.CredentialKey(t), nil) ctx := t.Context() tenant := uuid.NewString() for _, name := range []string{"", strings.Repeat("x", 257), strings.Repeat("é", 129), string([]byte{0xff})} { @@ -97,7 +98,7 @@ func TestVaultsRejectInvalidInputWithoutWrites(t *testing.T) { func TestVaultListFilteringPaginationAndReconnect(t *testing.T) { store, pool := openStore(t) - service := newService(t, pool, nil, nil) + service := newService(t, pool, pgtest.CredentialKey(t), nil) ctx := t.Context() tenant, other := uuid.NewString(), uuid.NewString() empty, err := store.ListVaults(ctx, tenant, vaults.PageQuery{Limit: 20}) @@ -206,7 +207,7 @@ func TestVaultDeletionCascadeBindingAndRestart(t *testing.T) { tenant, foreign := uuid.NewString(), uuid.NewString() key := bytes.Repeat([]byte{43}, 32) service := newService(t, pool, newCipher(t, key), nil) - keyless := newService(t, pool, nil, nil) + replaced := newService(t, pool, pgtest.CredentialKey(t), nil) vault, retained, empty := createVault(t, service, tenant), createVault(t, service, tenant), createVault(t, service, tenant) original := createStatic(t, service, tenant, vault.ID, "original", "https://mcp.example/tools", "original-secret") attached := []string{vault.ID, retained.ID} @@ -220,11 +221,11 @@ func TestVaultDeletionCascadeBindingAndRestart(t *testing.T) { t.Fatal(err) } for _, scope := range []struct{ tenant, id string }{{foreign, vault.ID}, {tenant, uuid.NewString()}, {tenant, "invalid"}, {"invalid", vault.ID}} { - if _, err := keyless.DeleteVault(t.Context(), vaults.DeleteVault{TenantID: scope.tenant, VaultID: scope.id}); !errors.Is(err, vaults.ErrNotFound) { + if _, err := replaced.DeleteVault(t.Context(), vaults.DeleteVault{TenantID: scope.tenant, VaultID: scope.id}); !errors.Is(err, vaults.ErrNotFound) { t.Fatal("foreign or invalid deletion was accepted", err) } } - _, deletionErr := newService(t, readOnlyPool(t, pool), nil, nil).DeleteVault(t.Context(), vaults.DeleteVault{TenantID: tenant, VaultID: vault.ID}) + _, deletionErr := newService(t, readOnlyPool(t, pool), pgtest.CredentialKey(t), nil).DeleteVault(t.Context(), vaults.DeleteVault{TenantID: tenant, VaultID: vault.ID}) if !isReadOnlyFailure(deletionErr) { t.Fatal("failed mutation was accepted or translated", deletionErr) } @@ -259,8 +260,8 @@ func TestVaultDeletionCascadeBindingAndRestart(t *testing.T) { t.Fatal(err) } for _, target := range []vaults.Vault{empty, vault} { - if id, err := keyless.DeleteVault(t.Context(), vaults.DeleteVault{TenantID: tenant, VaultID: target.ID}); err != nil || id != target.ID { - t.Fatal("keyless deletion failed", err) + if id, err := replaced.DeleteVault(t.Context(), vaults.DeleteVault{TenantID: tenant, VaultID: target.ID}); err != nil || id != target.ID { + t.Fatal("deletion under a replaced key failed", err) } } if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { diff --git a/services/core/internal/processconfig/config.go b/services/core/internal/processconfig/config.go index b7082ff64..81f2754c7 100644 --- a/services/core/internal/processconfig/config.go +++ b/services/core/internal/processconfig/config.go @@ -45,23 +45,20 @@ const ( type Config struct { // Addr is OAC_ADDR, the listener address. Addr string - // PublicOrigin is OAC_PUBLIC_URL. Nil disables the Runtime gateway and - // the execution Worker. - PublicOrigin *deployment.PublicOrigin + // PublicOrigin is OAC_PUBLIC_URL. + PublicOrigin deployment.PublicOrigin // DatabaseURL is OAC_DATABASE_URL with the password from // OAC_DATABASE_PASSWORD_FILE. DatabaseURL string - // InstallationID comes from OAC_INSTALLATION_ID_FILE. Empty leaves the - // sandbox deployment and node routes off. + // InstallationID comes from OAC_INSTALLATION_ID_FILE. InstallationID string // AgentHostID and AgentHostCredentialHash are the deployment's agent host - // from OAC_AGENT_HOST_IDENTITY_FILE, which the Runtime gateway requires; - // Core registers it at startup. The hash is the one Runtime and Link - // authentication compare. + // from OAC_AGENT_HOST_IDENTITY_FILE, which Core registers at startup. The + // hash is the one Runtime and Link authentication compare. AgentHostID string AgentHostCredentialHash string - // CredentialKey seals stored credentials. Nil when - // OAC_CREDENTIAL_KEY_FILE is unset. + // CredentialKey seals stored credentials; it comes from + // OAC_CREDENTIAL_KEY_FILE. CredentialKey *credentialcrypto.Cipher // CoreKeys authenticates the Core key from OAC_CORE_KEY_DIGESTS_FILE. CoreKeys *api.DeploymentAuthenticator @@ -108,7 +105,7 @@ type runtimeHistoryFile struct { } // Load reads and validates the process environment. An unset or empty -// variable selects its default. +// optional variable selects its default. func Load() (Config, error) { var c Config var err error @@ -116,12 +113,12 @@ func Load() (Config, error) { return Config{}, err } c.Addr = cmp.Or(os.Getenv("OAC_ADDR"), defaultAddr) - if value := os.Getenv("OAC_PUBLIC_URL"); value != "" { - origin, err := deployment.NewPublicOrigin(value) - if err != nil { - return Config{}, configError("OAC_PUBLIC_URL must be a canonical http or https origin without path, credentials, query or fragment, such as https://core.example") - } - c.PublicOrigin = &origin + value := os.Getenv("OAC_PUBLIC_URL") + if value == "" { + return Config{}, configError("OAC_PUBLIC_URL is required; applications, nodes and sandboxes reach Core at this origin") + } + if c.PublicOrigin, err = deployment.NewPublicOrigin(value); err != nil { + return Config{}, configError("OAC_PUBLIC_URL must be a canonical http or https origin without path, credentials, query or fragment, such as https://core.example") } if c.DatabaseURL, err = databaseurl.FromEnvironment(); err != nil { return Config{}, err @@ -132,18 +129,9 @@ func Load() (Config, error) { if c.InstallationID, err = installationID(); err != nil { return Config{}, err } - if c.InstallationID != "" && c.PublicOrigin == nil { - return Config{}, configError("OAC_INSTALLATION_ID_FILE requires OAC_PUBLIC_URL, the origin nodes and sandboxes use to reach Core") - } if c.AgentHostID, c.AgentHostCredentialHash, err = agentHost(); err != nil { return Config{}, err } - if c.AgentHostID != "" && c.PublicOrigin == nil { - return Config{}, configError("OAC_AGENT_HOST_IDENTITY_FILE requires OAC_PUBLIC_URL, the origin of the Runtime gateway the agent host connects to") - } - if c.AgentHostID == "" && c.PublicOrigin != nil { - return Config{}, configError("OAC_PUBLIC_URL requires OAC_AGENT_HOST_IDENTITY_FILE, the agent host its Runtime gateway serves") - } if c.CredentialKey, err = credentialKey(); err != nil { return Config{}, err } @@ -179,10 +167,6 @@ func Load() (Config, error) { // Settings projects the configuration that GET /core/v1/installation // reports. Sensitive file settings report only whether they are configured. func (c Config) Settings() []api.InstallationSetting { - var public any - if c.PublicOrigin != nil { - public = c.PublicOrigin.String() - } format := c.Log.Format if format == "" { format = "auto" @@ -192,7 +176,7 @@ func (c Config) Settings() []api.InstallationSetting { origins = []string{} } return []api.InstallationSetting{ - setting("public_url", public, nil, []string{"core", "web"}), + setting("public_url", c.PublicOrigin.String(), nil, []string{"core", "web"}), setting("log.level", strings.ToLower(c.Log.Level.String()), "info", []string{"core", "web"}), setting("log.format", format, "auto", []string{"core", "web"}), setting("log.add_source", c.Log.AddSource, false, []string{"core", "web"}), @@ -229,7 +213,7 @@ func sensitive(key string, configured bool, restarts []string) api.InstallationS func installationID() (string, error) { path := os.Getenv("OAC_INSTALLATION_ID_FILE") if path == "" { - return "", nil + return "", configError("OAC_INSTALLATION_ID_FILE is required; it names the file that holds this installation's ID") } raw, err := os.ReadFile(path) if err != nil { @@ -247,7 +231,7 @@ func installationID() (string, error) { func agentHost() (string, string, error) { path := os.Getenv("OAC_AGENT_HOST_IDENTITY_FILE") if path == "" { - return "", "", nil + return "", "", configError("OAC_AGENT_HOST_IDENTITY_FILE is required; it names the agent host the Runtime gateway serves") } raw, err := os.ReadFile(path) if err != nil { @@ -271,7 +255,7 @@ func agentHost() (string, string, error) { func credentialKey() (*credentialcrypto.Cipher, error) { path := os.Getenv("OAC_CREDENTIAL_KEY_FILE") if path == "" { - return nil, nil + return nil, configError("OAC_CREDENTIAL_KEY_FILE is required; Core seals stored credentials with this key") } content, err := os.ReadFile(path) if err != nil { diff --git a/services/core/internal/processconfig/config_test.go b/services/core/internal/processconfig/config_test.go index a3ebfd2a6..fb33337a1 100644 --- a/services/core/internal/processconfig/config_test.go +++ b/services/core/internal/processconfig/config_test.go @@ -26,11 +26,17 @@ func required(t *testing.T) func(name, content string) string { } return path } + t.Setenv("OAC_PUBLIC_URL", "https://core.example") t.Setenv("OAC_DATABASE_URL", "postgres://core@database/core") + t.Setenv("OAC_INSTALLATION_ID_FILE", write("installation.id", testInstallationID+"\n")) + t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("identity.json", agentHostIdentity)) + t.Setenv("OAC_CREDENTIAL_KEY_FILE", write("credential.key", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x91}, 32))+"\n")) t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", write("digests.json", `["`+strings.Repeat("ab", 32)+`"]`)) return write } +const testInstallationID = "8c5f4f5e-2c55-4c43-9a49-7f2f3f2d1d10" + // rejects asserts that Load fails, names variable and does not echo secret. func rejects(t *testing.T, variable, secret string) { t.Helper() @@ -46,7 +52,7 @@ func TestLoadAppliesDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - if c.Addr != "127.0.0.1:8091" || c.PublicOrigin != nil || c.InstallationID != "" || c.AgentHostID != "" || c.CredentialKey != nil || c.CoreKeys == nil || + if c.Addr != "127.0.0.1:8091" || c.PublicOrigin.String() != "https://core.example" || c.InstallationID != testInstallationID || c.AgentHostID != "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b" || c.CredentialKey == nil || c.CoreKeys == nil || c.ExecutionConcurrency != 4 || c.DefaultHarness != "codex" || strings.Join(c.Harnesses, ",") != "claude_sdk,codex,mcode" || c.WriteAuditRetention != 90*24*time.Hour || c.OAuthTrustedOrigins != nil || c.NativeInstallers != "" || c.ProviderPaths.StateRoot != "/state" { t.Fatalf("%+v", c) @@ -62,11 +68,12 @@ func TestLoadAppliesDefaults(t *testing.T) { func TestLoadRejectsInvalidValuesWithoutEchoingThem(t *testing.T) { write := required(t) - t.Setenv("OAC_DATABASE_URL", "") - rejects(t, "OAC_DATABASE_URL", "") - required(t) - t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", "") - rejects(t, "OAC_CORE_KEY_DIGESTS_FILE", "") + for _, variable := range []string{"OAC_PUBLIC_URL", "OAC_DATABASE_URL", "OAC_INSTALLATION_ID_FILE", "OAC_AGENT_HOST_IDENTITY_FILE", "OAC_CREDENTIAL_KEY_FILE", "OAC_CORE_KEY_DIGESTS_FILE"} { + t.Run(variable+" missing", func(t *testing.T) { + t.Setenv(variable, "") + rejects(t, variable, "") + }) + } t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", write("bad-digests.json", `["synthetic-secret"]`)) rejects(t, "OAC_CORE_KEY_DIGESTS_FILE", "synthetic-secret") required(t) @@ -78,9 +85,9 @@ func TestLoadRejectsInvalidValuesWithoutEchoingThem(t *testing.T) { "OAC_WRITE_AUDIT_RETENTION": "synthetic-secret", "OAC_OAUTH_TRUSTED_ORIGINS": "https://synthetic-secret.example/token", "OAC_LOG_LEVEL": "verbose", - "OAC_INSTALLATION_ID_FILE": write("installation.id", "synthetic-secret"), - "OAC_AGENT_HOST_IDENTITY_FILE": write("identity.json", `{"runtime_id": "synthetic-secret", "credential": "c"}`), - "OAC_CREDENTIAL_KEY_FILE": write("credential.key", "synthetic-secret"), + "OAC_INSTALLATION_ID_FILE": write("bad.id", "synthetic-secret"), + "OAC_AGENT_HOST_IDENTITY_FILE": write("bad-identity.json", `{"runtime_id": "synthetic-secret", "credential": "c"}`), + "OAC_CREDENTIAL_KEY_FILE": write("bad.key", "synthetic-secret"), "OAC_HISTORY_SETTINGS_FILE": write("history.json", `{"secret":"synthetic-secret"}`), } { t.Run(variable, func(t *testing.T) { @@ -88,19 +95,10 @@ func TestLoadRejectsInvalidValuesWithoutEchoingThem(t *testing.T) { rejects(t, variable, "synthetic-secret") }) } - t.Setenv("OAC_INSTALLATION_ID_FILE", write("valid.id", "8c5f4f5e-2c55-4c43-9a49-7f2f3f2d1d10\n")) - rejects(t, "OAC_PUBLIC_URL", "") - t.Setenv("OAC_PUBLIC_URL", "https://core.example") - t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("valid.json", agentHostIdentity)) - if c, err := Load(); err != nil || c.InstallationID != "8c5f4f5e-2c55-4c43-9a49-7f2f3f2d1d10" { - t.Fatal(c.InstallationID, err) - } } -func TestAgentHostIdentityComesWithTheRuntimeGateway(t *testing.T) { +func TestAgentHostIdentity(t *testing.T) { write := required(t) - t.Setenv("OAC_PUBLIC_URL", "https://core.example") - rejects(t, "OAC_AGENT_HOST_IDENTITY_FILE", "") for _, content := range []string{ `{"runtime_id": "2F1C4A7E-9B3D-4E5F-8A6B-1C2D3E4F5A6B", "credential": "synthetic-secret"}`, `{"runtime_id": "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b", "credential": " "}`, @@ -115,13 +113,10 @@ func TestAgentHostIdentityComesWithTheRuntimeGateway(t *testing.T) { if err != nil || c.AgentHostID != "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b" || c.AgentHostCredentialHash != runtimedevice.HashCredential("synthetic-credential") { t.Fatal(c.AgentHostID, err) } - t.Setenv("OAC_PUBLIC_URL", "") - rejects(t, "OAC_AGENT_HOST_IDENTITY_FILE", "") } func TestPublicURLMustBeACanonicalOrigin(t *testing.T) { - write := required(t) - t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("identity.json", agentHostIdentity)) + required(t) for _, value := range []string{"https://core.example", "https://core.example:8443", "http://127.0.0.1:8091", "http://core.example"} { t.Setenv("OAC_PUBLIC_URL", value) if c, err := Load(); err != nil || c.PublicOrigin.String() != value { @@ -179,13 +174,6 @@ func TestOAuthTrustedOrigins(t *testing.T) { func TestCredentialKey(t *testing.T) { write := required(t) - t.Setenv("OAC_CREDENTIAL_KEY_FILE", filepath.Join(t.TempDir(), "missing.key")) - rejects(t, "OAC_CREDENTIAL_KEY_FILE", "") - for _, content := range []string{"", base64.StdEncoding.EncodeToString(make([]byte, 31))} { - t.Setenv("OAC_CREDENTIAL_KEY_FILE", write("short.key", content)) - rejects(t, "OAC_CREDENTIAL_KEY_FILE", "") - } - t.Setenv("OAC_CREDENTIAL_KEY_FILE", write("credential.key", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x91}, 32))+"\n")) first, err := Load() if err != nil { t.Fatal(err) @@ -202,6 +190,12 @@ func TestCredentialKey(t *testing.T) { if got, err := reopened.CredentialKey.Open(sealed, binding); err != nil || string(got) != "opaque storage test" { t.Fatal("persisted key did not recover ciphertext", err) } + t.Setenv("OAC_CREDENTIAL_KEY_FILE", filepath.Join(t.TempDir(), "missing.key")) + rejects(t, "OAC_CREDENTIAL_KEY_FILE", "") + for _, content := range []string{"", base64.StdEncoding.EncodeToString(make([]byte, 31))} { + t.Setenv("OAC_CREDENTIAL_KEY_FILE", write("short.key", content)) + rejects(t, "OAC_CREDENTIAL_KEY_FILE", "") + } } func TestRuntimeHistoryFile(t *testing.T) { @@ -235,8 +229,6 @@ func TestRuntimeHistoryFile(t *testing.T) { func TestSettingsReportEffectiveValuesAndHideHistory(t *testing.T) { write := required(t) - t.Setenv("OAC_PUBLIC_URL", "https://core.example") - t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("identity.json", agentHostIdentity)) t.Setenv("OAC_EXECUTION_CONCURRENCY", "8") t.Setenv("OAC_LOG_LEVEL", "warn") t.Setenv("OAC_WRITE_AUDIT_RETENTION", "1440m") diff --git a/services/core/internal/runtimeenrollment/connection.go b/services/core/internal/runtimeenrollment/connection.go index 8cbb2b0f6..7bebb425f 100644 --- a/services/core/internal/runtimeenrollment/connection.go +++ b/services/core/internal/runtimeenrollment/connection.go @@ -102,9 +102,6 @@ func RuntimeConnected(ctx context.Context, s ConnectionStore, registry *runtimeg if credential.CredentialHash != digest { return false, sessions.ErrDeviceBindingConflict } - if registry == nil { - return false, nil - } peer, err := registry.LookupDevice(bound.ID) if errors.Is(err, runtimegateway.ErrDeviceNotRegistered) { return false, nil diff --git a/services/core/internal/runtimeenrollment/connection_test.go b/services/core/internal/runtimeenrollment/connection_test.go index e10519f92..4db7b557e 100644 --- a/services/core/internal/runtimeenrollment/connection_test.go +++ b/services/core/internal/runtimeenrollment/connection_test.go @@ -111,7 +111,7 @@ func (s *liveConnectionStore) GetDeviceCredential(context.Context, string) (runt return runtimedevice.Credential{ID: "device", WorkspaceID: "tenant", Type: runtimedevice.RuntimeTypeAgentDaemon, CredentialHash: s.digest}, true, nil } func TestRuntimeConnectedCurrentAuthorityAfterPeer(t *testing.T) { - for _, name := range []string{"connected", "rotated before read", "revoked after peer", "device revoked after peer", "retired after peer", "store error after peer", "device store error after peer", "closed", "no registry"} { + for _, name := range []string{"connected", "rotated before read", "revoked after peer", "device revoked after peer", "retired after peer", "store error after peer", "device store error after peer", "closed"} { t.Run(name, func(t *testing.T) { digest := runtimedevice.HashCredential("fixture-key") s := &liveConnectionStore{digest: digest} @@ -153,8 +153,6 @@ func TestRuntimeConnectedCurrentAuthorityAfterPeer(t *testing.T) { wantErr = s.credentialRecheckError case "closed": peer.Close("closed before observation") - case "no registry": - registry = nil } connected, err := RuntimeConnected(t.Context(), s, registry, "environment", digest) if connected != want || !errors.Is(err, wantErr) { diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go index 712d9e27b..dbfda8746 100644 --- a/services/core/internal/sandbox/deployment.go +++ b/services/core/internal/sandbox/deployment.go @@ -109,8 +109,12 @@ func (s DeploymentSpec) Digest(provider string) string { } // Description is what a provider registration says about a deployment of it: -// its mode, its backend namespace fingerprint and its checkpoint timing. +// its mode and its backend namespace fingerprint. type Description struct { - Mode, BackendFingerprint string - IdleSeconds, RetentionSeconds int64 + Mode, BackendFingerprint string +} + +func BackendFingerprint(kind, namespace string) string { + digest := sha256.Sum256([]byte(kind + "\x00" + namespace)) + return hex.EncodeToString(digest[:]) } diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index e79ea2f40..da40c7ca3 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -24,13 +24,6 @@ type runtimeRule struct { Pattern string `json:"pattern"` } -// DeploymentPolicy is declared by an adapter and projected to the installer. -type DeploymentPolicy struct { - RuntimeError string `json:"-"` - Disk bool `json:"disk"` - Runtime bool `json:"runtime"` -} - var resourceContract = []resourceRule{ {"cpus", 1, 255, false, "cpus must be 1..255 and memory_mib must be 512..1048576"}, {"memory_mib", 512, 1048576, false, "cpus must be 1..255 and memory_mib must be 512..1048576"}, @@ -46,31 +39,16 @@ var runtimeContract = []runtimeRule{ {"firmware_sha256", "[0-9a-f]{64}"}, } -// PythonDeploymentContract generates the installer projection. -func PythonDeploymentContract(policies map[string]DeploymentPolicy) string { - raw := projectDeploymentContract(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - Providers map[string]DeploymentPolicy `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, policies, minimumDiskMiB}) - return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT" -} - -// TypeScriptDeploymentContract generates the client projection of the bounds -// and patterns; provider policies are not part of it. -func TypeScriptDeploymentContract() string { - raw := projectDeploymentContract(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, minimumDiskMiB}) - return "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + string(raw) + " as const;\n" +// ProviderProjection is one registered Provider in the generated projections. +type ProviderProjection struct { + Mode string `json:"mode"` + DeploymentPolicy } -// projectDeploymentContract encodes a projection. Struct order is checked -// first because it also defines Go's canonical JSON bytes. -func projectDeploymentContract(projection any) []byte { +// DeploymentContract generates the installer and TypeScript client projections +// of one contract. Struct order is checked first because it also defines Go's +// canonical JSON bytes. +func DeploymentContract(providers map[string]ProviderProjection) (python, typescript string) { for _, item := range []struct { value any names []string @@ -87,8 +65,14 @@ func projectDeploymentContract(projection any) []byte { } } } - raw, _ := json.Marshal(projection) - return raw + raw, _ := json.Marshal(struct { + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + Providers map[string]ProviderProjection `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, providers, minimumDiskMiB}) + return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT", + "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + string(raw) + " as const;\n" } func resourceNames() []string { var names []string diff --git a/services/core/internal/sandbox/docker/node.go b/services/core/internal/sandbox/docker/node.go new file mode 100644 index 000000000..63a2570fe --- /dev/null +++ b/services/core/internal/sandbox/docker/node.go @@ -0,0 +1,108 @@ +package docker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/moby/moby/client" +) + +// Native is the node configuration's native object for Docker. Image is the +// local ID of the release's Runtime image: the host's image store decides +// whether the config digest (image_id) or the manifest digest +// (image_manifest_digest) names the loaded image, so the installer records the +// one this host resolves. +type Native struct { + Host string `json:"host"` + Image string `json:"image"` + Network string `json:"network"` + SeccompFile string `json:"seccomp_file"` + ExtraHosts []string `json:"extra_hosts"` + NestedSandbox bool `json:"nested_sandbox"` +} + +func decodeNative(config sandbox.NodeConfig) (Native, error) { + var entry Native + if sandbox.DecodeConfigurationObject(config.Native, &entry, "host", "image", "network", "seccomp_file", "extra_hosts", "nested_sandbox") != nil { + return entry, errors.New("invalid managed Docker node configuration") + } + release := config.Specification.Runtime + if entry.Image != release.ImageID && entry.Image != release.ImageManifestDigest { + return entry, errors.New("Docker Runtime image differs from the deployment release") + } + return entry, nil +} + +// BuildNode constructs the node-local Docker adapter from a validated node configuration. +func BuildNode(config sandbox.NodeConfig, _ sandbox.LocalOptions, result *sandbox.Built) (func(), error) { + closeProvider := func() {} + entry, err := decodeNative(config) + if err != nil { + return closeProvider, err + } + host, err := url.Parse(entry.Host) + if err != nil || host.Scheme != "unix" || host.Host != "" || host.User != nil || host.RawQuery != "" || host.Fragment != "" || host.RawPath != "" || host.Path == "/" || !filepath.IsAbs(host.Path) || filepath.Clean(host.Path) != host.Path || entry.Host != "unix://"+host.Path { + return closeProvider, errors.New("managed Docker host must be an explicit canonical unix socket") + } + seccomp, err := os.ReadFile(entry.SeccompFile) + if err != nil { + return closeProvider, fmt.Errorf("cannot read managed Docker seccomp JSON: %w", err) + } + if !json.Valid(seccomp) { + return closeProvider, errors.New("invalid managed Docker seccomp JSON") + } + c, err := client.New(client.WithHost(entry.Host)) + if err != nil { + return closeProvider, errors.New("invalid managed Docker endpoint") + } + closeProvider = func() { _ = c.Close() } + provider, err := New(c, Config{InstallationID: config.InstallationID, Image: entry.Image, Network: entry.Network, Seccomp: string(seccomp), ExtraHosts: entry.ExtraHosts, NestedSandbox: entry.NestedSandbox, Resources: &config.Specification.Resources}) + if err != nil { + closeProvider() + return func() {}, errors.New("invalid managed Docker provider configuration") + } + result.Provider = provider + result.Probe = dockerProbe(c, entry.Image, config.Specification.Resources) + result.BackendFingerprint = sandbox.BackendFingerprint(config.Provider, entry.Host) + return closeProvider, nil +} + +// The probe reports its first failed check as its readiness class: the Docker +// daemon, then limit support, then host capacity for one sandbox of the +// deployment specification, then the pinned Runtime image. Unclassified +// failures stay provider_unavailable. The returned text is local; only its +// class is reported. +func dockerProbe(c *client.Client, image string, resources sandbox.Resources) func(context.Context) error { + return func(ctx context.Context) error { + if _, err := c.Ping(ctx, client.PingOptions{}); err != nil { + return fmt.Errorf("%w: Docker daemon is unreachable", sandbox.ErrProviderUnavailable) + } + host, err := c.Info(ctx, client.InfoOptions{}) + if err != nil { + return fmt.Errorf("%w: cannot inspect Docker host resource support", sandbox.ErrProviderUnavailable) + } + if !host.Info.MemoryLimit || !host.Info.CPUCfsQuota { + return fmt.Errorf("%w: Docker does not enforce CPU and memory limits", sandbox.ErrHostUnsupported) + } + if host.Info.MemTotal <= 0 { + return errors.New("Docker host memory capacity is unavailable") + } + if err := sandbox.CheckCapacity(resources, host.Info.NCPU, uint64(host.Info.MemTotal)); err != nil { + return err + } + if _, err = c.ImageInspect(ctx, image); errdefs.IsNotFound(err) { + return sandbox.ErrRuntimeImageUnavailable + } else if err != nil { + // The daemon did not answer; the image may still be present. + return fmt.Errorf("%w: cannot inspect the pinned Runtime image", sandbox.ErrProviderUnavailable) + } + return nil + } +} diff --git a/services/core/internal/sandbox/docker/node_test.go b/services/core/internal/sandbox/docker/node_test.go new file mode 100644 index 000000000..0d5f576c2 --- /dev/null +++ b/services/core/internal/sandbox/docker/node_test.go @@ -0,0 +1,85 @@ +package docker + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/moby/moby/client" +) + +// The host's image store decides which release digest names the loaded image. +func TestNativeImageIsAReleaseIdentity(t *testing.T) { + release := sandbox.RuntimeRelease{ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64)} + config := sandbox.NodeConfig{Specification: sandbox.DeploymentSpec{Runtime: &release}} + for _, image := range []string{release.ImageID, release.ImageManifestDigest} { + config.Native = json.RawMessage(`{"image":"` + image + `"}`) + if entry, err := decodeNative(config); err != nil || entry.Image != image { + t.Fatalf("rejected release image %s: %v", image, err) + } + } + for _, native := range []string{`{"image":"sha256:` + strings.Repeat("a", 64) + `"}`, `{"image":"` + release.ImageID + `","cpus":2}`, `{"image":null}`} { + config.Native = json.RawMessage(native) + if _, err := decodeNative(config); err == nil { + t.Fatalf("accepted native %s", native) + } + } +} + +func TestDockerProbeDiagnostics(t *testing.T) { + image := "sha256:" + strings.Repeat("c", 64) + for _, tc := range []struct { + name string + limits bool + cpus int + imageStatus int + want string + unreachable, infoFails bool + }{ + {name: "unreachable", unreachable: true, want: "provider_unavailable"}, + {name: "info", infoFails: true, want: "provider_unavailable"}, + // The pinned image is also missing below; earlier checks take precedence. + {name: "limits", cpus: 8, imageStatus: 404, want: "host_unsupported"}, + {name: "capacity", limits: true, cpus: 1, imageStatus: 404, want: "capacity_insufficient"}, + {name: "image", limits: true, cpus: 8, imageStatus: 404, want: "runtime_image_unavailable"}, + {name: "image_inspect_fails", limits: true, cpus: 8, imageStatus: 500, want: "provider_unavailable"}, + {name: "ready", limits: true, cpus: 8, imageStatus: 200}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch path := strings.TrimPrefix(r.URL.Path, "/v1.52"); { + case path == "/_ping": + _, _ = w.Write([]byte("OK")) + case path == "/info" && !tc.infoFails: + _ = json.NewEncoder(w).Encode(map[string]any{"MemoryLimit": tc.limits, "CpuCfsQuota": tc.limits, "NCPU": tc.cpus, "MemTotal": int64(64) << 30}) + case path == "/images/"+image+"/json" && tc.imageStatus == 200: + _ = json.NewEncoder(w).Encode(map[string]string{"Id": image}) + case path == "/images/"+image+"/json": + w.WriteHeader(tc.imageStatus) + _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) + default: + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) + } + })) + defer server.Close() + host := server.URL + if tc.unreachable { + host = "unix://" + filepath.Join(t.TempDir(), "missing.sock") + } + c, err := client.New(client.WithHost(host), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if got := sandbox.NodeDiagnostic(dockerProbe(c, image, sandbox.Resources{CPUs: 2, MemoryMiB: 1024})(t.Context())); got != tc.want { + t.Fatalf("diagnostic = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/services/core/internal/sandbox/docker/selection.go b/services/core/internal/sandbox/docker/selection.go index 426cf6005..3a7ec247a 100644 --- a/services/core/internal/sandbox/docker/selection.go +++ b/services/core/internal/sandbox/docker/selection.go @@ -4,7 +4,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Runtime: true} } +func Policy() sandbox.DeploymentPolicy { + return sandbox.DeploymentPolicy{Runtime: true, DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} +} func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("docker", Policy()) } func ValidateSpecification(s sandbox.DeploymentSpec) error { diff --git a/services/core/internal/sandbox/providers/capacity_linux.go b/services/core/internal/sandbox/microsandbox/capacity_linux.go similarity index 73% rename from services/core/internal/sandbox/providers/capacity_linux.go rename to services/core/internal/sandbox/microsandbox/capacity_linux.go index fbadd8abb..3edc4b4a4 100644 --- a/services/core/internal/sandbox/providers/capacity_linux.go +++ b/services/core/internal/sandbox/microsandbox/capacity_linux.go @@ -1,6 +1,6 @@ //go:build linux -package providers +package microsandbox import ( "fmt" @@ -15,5 +15,5 @@ func hostCapacity(r sandbox.Resources) error { if err := syscall.Sysinfo(&info); err != nil { return fmt.Errorf("cannot verify node memory capacity") } - return checkCapacity(r, runtime.NumCPU(), uint64(info.Totalram)*uint64(info.Unit)) + return sandbox.CheckCapacity(r, runtime.NumCPU(), uint64(info.Totalram)*uint64(info.Unit)) } diff --git a/services/core/internal/sandbox/providers/capacity_other.go b/services/core/internal/sandbox/microsandbox/capacity_other.go similarity index 90% rename from services/core/internal/sandbox/providers/capacity_other.go rename to services/core/internal/sandbox/microsandbox/capacity_other.go index 6fe10fc56..68f15f265 100644 --- a/services/core/internal/sandbox/providers/capacity_other.go +++ b/services/core/internal/sandbox/microsandbox/capacity_other.go @@ -1,6 +1,6 @@ //go:build !linux -package providers +package microsandbox import ( "errors" diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go index a5063e214..a97091d9d 100644 --- a/services/core/internal/sandbox/microsandbox/deployment.go +++ b/services/core/internal/sandbox/microsandbox/deployment.go @@ -4,7 +4,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Disk: true, Runtime: true} } +func Policy() sandbox.DeploymentPolicy { + return sandbox.DeploymentPolicy{Disk: true, Runtime: true, + DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}} +} func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } func ValidateSpecification(s sandbox.DeploymentSpec) error { diff --git a/services/core/internal/sandbox/providers/generation_probe_test.go b/services/core/internal/sandbox/microsandbox/generation_probe_test.go similarity index 84% rename from services/core/internal/sandbox/providers/generation_probe_test.go rename to services/core/internal/sandbox/microsandbox/generation_probe_test.go index 83e9dfe28..951cfd85a 100644 --- a/services/core/internal/sandbox/providers/generation_probe_test.go +++ b/services/core/internal/sandbox/microsandbox/generation_probe_test.go @@ -1,4 +1,4 @@ -package providers +package microsandbox import ( "context" @@ -14,7 +14,7 @@ import ( func TestMicrosandboxGenerationImageProbe(t *testing.T) { dir := t.TempDir() imageDigest := "sha256:" + strings.Repeat("d", 64) - entry := Microsandbox{RuntimePath: filepath.Join(dir, "msb"), RuntimeHome: dir, FirmwarePath: filepath.Join(dir, "firmware"), Image: "oac-runtime@" + imageDigest} + entry := Config{RuntimePath: filepath.Join(dir, "msb"), RuntimeHome: dir, FirmwarePath: filepath.Join(dir, "firmware"), Image: "oac-runtime@" + imageDigest} // Check the native argument/environment boundary, including ambient isolation. script := `#!/bin/sh [ "$#" = 5 ] && [ "$1" = image ] && [ "$2" = inspect ] && [ "$3" = 'oac-runtime@` + imageDigest + `' ] && [ "$4" = --format ] && [ "$5" = json ] || exit 1 @@ -73,8 +73,8 @@ cat "$MSB_HOME/output" } func TestMicrosandboxGenerationProbeRetainsEarlierFailures(t *testing.T) { - for _, failure := range []error{context.Canceled, sandbox.ErrKVMUnavailable, sandbox.ErrCapacityInsufficient, sandbox.ErrMicrosandboxArtifactsUnavailable, errors.New("microsandbox state directory is unavailable")} { - probe := microsandboxGenerationProbe(Microsandbox{RuntimePath: "/missing"}, func(context.Context) error { return failure }) + for _, failure := range []error{context.Canceled, sandbox.ErrHostUnsupported, sandbox.ErrCapacityInsufficient, sandbox.ErrArtifactsUnavailable, errors.New("microsandbox state directory is unavailable")} { + probe := microsandboxGenerationProbe(Config{RuntimePath: "/missing"}, func(context.Context) error { return failure }) if got := probe(t.Context()); got != failure { t.Fatalf("earlier failure changed: got %v, want %v", got, failure) } diff --git a/services/core/internal/sandbox/microsandbox/node.go b/services/core/internal/sandbox/microsandbox/node.go new file mode 100644 index 000000000..69b331df2 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/node.go @@ -0,0 +1,100 @@ +package microsandbox + +import ( + "errors" + "os" + "path/filepath" + "strconv" + + "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +// NodeArtifacts are the native files a microsandbox node installs beside the +// shared node payload. +var NodeArtifacts = []providerassets.Artifact{ + {Path: "native/bin/oac-microsandbox-provider", Suffix: "microsandbox-provider", Role: "runtime"}, + {Path: "native/microsandbox/msb", Suffix: "msb", Role: "runtime"}, + {Path: "native/microsandbox/libkrunfw.so.5.6.1", Suffix: "libkrunfw.so.5.6.1", Role: "runtime"}, +} + +// Native is the node configuration's native object for microsandbox: the +// helper, Runtime and firmware paths, the private store and the network policy. +// The helper owns local paths; no ambient backend is selected. Resources, the +// image and the artifact hashes come from the deployment specification. +type Native struct { + HelperPath string `json:"helper_path"` + RuntimeHome string `json:"runtime_home"` + RuntimePath string `json:"runtime_path"` + FirmwarePath string `json:"firmware_path"` + Network Network `json:"network"` +} + +type Network struct { + DefaultEgress string `json:"default_egress"` + DefaultIngress string `json:"default_ingress"` + Rules []Rule `json:"rules"` +} + +type Rule struct { + Action string `json:"action"` + Direction string `json:"direction"` + Destination string `json:"destination"` + Protocol string `json:"protocol"` + Port string `json:"port"` +} + +func configureMicrosandbox(entry Native, spec sandbox.DeploymentSpec, caller *ProcessCaller, result *sandbox.Built) (Config, error) { + if !filepath.IsAbs(entry.RuntimeHome) || filepath.Clean(entry.RuntimeHome) != entry.RuntimeHome { + return Config{}, errors.New("managed microsandbox runtime_home must be a canonical absolute path") + } + network := NetworkPolicy{DefaultEgress: entry.Network.DefaultEgress, DefaultIngress: entry.Network.DefaultIngress} + for _, rule := range entry.Network.Rules { + network.Rules = append(network.Rules, NetworkRule{Action: rule.Action, Direction: rule.Direction, Destination: rule.Destination, Protocol: rule.Protocol, Port: rule.Port}) + } + release, resources := spec.Runtime, spec.Resources + config := Config{ + InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath, + RuntimeSHA256: release.RuntimeSHA256, FirmwareSHA256: release.FirmwareSHA256, Image: release.MicrosandboxRef, + MemoryMiB: resources.MemoryMiB, CPUs: uint8(resources.CPUs), RootDiskMiB: resources.RootDiskMiB, EnvironmentDiskMiB: resources.EnvironmentDiskMiB, Network: network, + } + provider, err := NewWithCaller(config, caller) + if err != nil { + return Config{}, errors.New("invalid managed microsandbox provider configuration") + } + result.Provider = provider + result.Probe = microsandboxProbe(config, resources) + result.Quiescent = caller.Quiescent + result.BackendFingerprint = sandbox.BackendFingerprint("microsandbox", entry.RuntimeHome) + return config, nil +} + +// BuildNode constructs the node-local microsandbox adapter from a validated node configuration. +func BuildNode(c sandbox.NodeConfig, options sandbox.LocalOptions, result *sandbox.Built) (func(), error) { + closeProvider := func() {} + var entry Native + if sandbox.DecodeConfigurationObject(c.Native, &entry, "helper_path", "runtime_home", "runtime_path", "firmware_path", "network") != nil { + return closeProvider, errors.New("invalid managed microsandbox node configuration") + } + caller := &ProcessCaller{} + if options.GenerationStateDirectory != "" { + directory := filepath.Join(options.GenerationStateDirectory, "generations") + if err := os.MkdirAll(directory, 0700); err != nil { + return closeProvider, err + } + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + return closeProvider, sandbox.ErrOwnership + } + caller.LeasePath = filepath.Join(directory, strconv.FormatUint(c.Generation, 10)+".lease") + caller.LeaseIdentity = LeaseIdentity{InstallationID: result.InstallationID, Generation: c.Generation, SpecificationDigest: result.SpecificationDigest} + } + config, err := configureMicrosandbox(entry, c.Specification, caller, result) + if err != nil { + return closeProvider, err + } + if options.GenerationStateDirectory != "" { + result.Probe = microsandboxGenerationProbe(config, result.Probe) + } + return closeProvider, nil +} diff --git a/services/core/internal/sandbox/microsandbox/node_test.go b/services/core/internal/sandbox/microsandbox/node_test.go new file mode 100644 index 000000000..082bfd5a7 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/node_test.go @@ -0,0 +1,53 @@ +package microsandbox + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("microsandbox requires Linux") + } + useKVM(t, true) + dir := t.TempDir() + native := Native{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), Network: Network{DefaultEgress: "allow", DefaultIngress: "deny"}} + raw, err := json.Marshal(native) + if err != nil { + t.Fatal(err) + } + script := []byte("#!/bin/sh\nprintf '%s' '{}'\n") + digest := sha256.Sum256(script) + release := sandbox.RuntimeRelease{MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:])} + config := sandbox.NodeConfig{Provider: "microsandbox", Generation: 9, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}, Runtime: &release}, Native: raw} + for _, path := range []string{native.RuntimePath, native.FirmwarePath, native.HelperPath} { + if err := os.WriteFile(path, script, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(native.RuntimeHome, 0700); err != nil { + t.Fatal(err) + } + for _, options := range []sandbox.LocalOptions{{Standalone: true}, {GenerationStateDirectory: t.TempDir()}} { + built := &sandbox.Built{InstallationID: uuid.NewString(), SpecificationDigest: config.Specification.Digest(config.Provider)} + closeProvider, err := BuildNode(config, options, built) + if err != nil { + t.Fatal(err) + } + err = built.Probe(t.Context()) + closeProvider() + if options.Standalone && err != nil || !options.Standalone && !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { + t.Fatalf("readiness for %+v: %v", options, err) + } + } +} diff --git a/services/core/internal/sandbox/providers/probe.go b/services/core/internal/sandbox/microsandbox/probe.go similarity index 59% rename from services/core/internal/sandbox/providers/probe.go rename to services/core/internal/sandbox/microsandbox/probe.go index 4ead40c4b..4e7c5772f 100644 --- a/services/core/internal/sandbox/providers/probe.go +++ b/services/core/internal/sandbox/microsandbox/probe.go @@ -1,4 +1,4 @@ -package providers +package microsandbox import ( "context" @@ -15,52 +15,21 @@ import ( "sync" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - "github.com/containerd/errdefs" - "github.com/moby/moby/client" ) -// Probes report the first failed check. Precedence runs from the provider -// platform (Docker daemon or KVM), through Docker limit support and host -// capacity for one sandbox of the deployment specification, to the installed -// Runtime content (image or microsandbox artifacts). Unclassified failures stay -// provider_unavailable. The returned text is local; only its code is reported. +// The probe reports its first failed check as its readiness class: KVM, then +// host capacity for one sandbox of the deployment specification, then the +// installed Runtime artifacts. Unclassified failures stay provider_unavailable. +// The returned text is local; only its class is reported. // kvmDevice is replaceable only by tests. var kvmDevice = "/dev/kvm" -func dockerProbe(c *client.Client, image string, resources sandbox.Resources) func(context.Context) error { - return func(ctx context.Context) error { - if _, err := c.Ping(ctx, client.PingOptions{}); err != nil { - return sandbox.ErrDockerUnavailable - } - host, err := c.Info(ctx, client.InfoOptions{}) - if err != nil { - return fmt.Errorf("%w: cannot inspect Docker host resource support", sandbox.ErrDockerUnavailable) - } - if !host.Info.MemoryLimit || !host.Info.CPUCfsQuota { - return sandbox.ErrDockerLimitsUnsupported - } - if host.Info.MemTotal <= 0 { - return errors.New("Docker host memory capacity is unavailable") - } - if err := checkCapacity(resources, host.Info.NCPU, uint64(host.Info.MemTotal)); err != nil { - return err - } - if _, err = c.ImageInspect(ctx, image); errdefs.IsNotFound(err) { - return sandbox.ErrRuntimeImageUnavailable - } else if err != nil { - // The daemon did not answer; the image may still be present. - return fmt.Errorf("%w: cannot inspect the pinned Runtime image", sandbox.ErrDockerUnavailable) - } - return nil - } -} - // A successful Runtime integrity check is cached for this immutable // configuration. A failure is checked again on the next heartbeat, so repaired // artifacts recover without a restart. Lifecycle calls still verify the exact // artifact themselves. -func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(context.Context) error { +func microsandboxProbe(entry Config, resources sandbox.Resources) func(context.Context) error { var integrity sync.Mutex verified := false return func(ctx context.Context) error { @@ -68,11 +37,11 @@ func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(con return err } if runtime.GOOS != "linux" { - return fmt.Errorf("%w: microsandbox requires a Linux KVM node", sandbox.ErrKVMUnavailable) + return fmt.Errorf("%w: microsandbox requires a Linux KVM node", sandbox.ErrHostUnsupported) } kvm, err := os.OpenFile(kvmDevice, os.O_RDWR, 0) if err != nil { - return sandbox.ErrKVMUnavailable + return fmt.Errorf("%w: KVM is unavailable to sandbox node", sandbox.ErrHostUnsupported) } _ = kvm.Close() if err := hostCapacity(resources); err != nil { @@ -89,7 +58,7 @@ func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(con integrity.Unlock() helper, err := os.Stat(entry.HelperPath) if err != nil || !helper.Mode().IsRegular() || helper.Mode().Perm()&0111 == 0 { - return fmt.Errorf("%w: microsandbox helper is unavailable", sandbox.ErrMicrosandboxArtifactsUnavailable) + return fmt.Errorf("%w: microsandbox helper is unavailable", sandbox.ErrArtifactsUnavailable) } home, err := os.Lstat(entry.RuntimeHome) if err != nil || !home.IsDir() || home.Mode().Perm() != 0700 { @@ -99,17 +68,17 @@ func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(con } } -func verifyMicrosandboxArtifacts(entry Microsandbox) error { +func verifyMicrosandboxArtifacts(entry Config) error { for _, artifact := range []struct{ path, hash string }{{entry.RuntimePath, entry.RuntimeSHA256}, {entry.FirmwarePath, entry.FirmwareSHA256}} { f, err := os.Open(artifact.path) if err != nil { - return sandbox.ErrMicrosandboxArtifactsUnavailable + return fmt.Errorf("%w: microsandbox Runtime or firmware is missing", sandbox.ErrArtifactsUnavailable) } h := sha256.New() _, err = io.Copy(h, f) _ = f.Close() if err != nil || hex.EncodeToString(h.Sum(nil)) != artifact.hash { - return fmt.Errorf("%w: artifact integrity check failed", sandbox.ErrMicrosandboxArtifactsUnavailable) + return fmt.Errorf("%w: microsandbox artifact integrity check failed", sandbox.ErrArtifactsUnavailable) } } return nil @@ -117,7 +86,7 @@ func verifyMicrosandboxArtifacts(entry Microsandbox) error { // Image availability is checked on every retained-generation probe, after the // platform, capacity and local artifact checks. -func microsandboxGenerationProbe(entry Microsandbox, probe func(context.Context) error) func(context.Context) error { +func microsandboxGenerationProbe(entry Config, probe func(context.Context) error) func(context.Context) error { return func(ctx context.Context) error { if err := probe(ctx); err != nil { return err diff --git a/services/core/internal/sandbox/providers/probe_test.go b/services/core/internal/sandbox/microsandbox/probe_test.go similarity index 55% rename from services/core/internal/sandbox/providers/probe_test.go rename to services/core/internal/sandbox/microsandbox/probe_test.go index e8c25f02e..53b767e42 100644 --- a/services/core/internal/sandbox/providers/probe_test.go +++ b/services/core/internal/sandbox/microsandbox/probe_test.go @@ -1,11 +1,8 @@ -package providers +package microsandbox import ( "crypto/sha256" "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" "os" "path/filepath" "runtime" @@ -13,7 +10,6 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - "github.com/moby/moby/client" ) var smallSandbox = sandbox.Resources{CPUs: 1, MemoryMiB: 512} @@ -72,7 +68,7 @@ func TestMicrosandboxProbeRequiresPrivateRuntimeDirectory(t *testing.T) { } home = link } - probe := microsandboxProbe(Microsandbox{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) + probe := microsandboxProbe(Config{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) err := probe(t.Context()) if tc.rejected { // An unclassified cause keeps the generic readiness code. @@ -112,7 +108,7 @@ func TestMicrosandboxProbeDiagnostics(t *testing.T) { t.Skip("microsandbox requires Linux") } dir := t.TempDir() - missing := Microsandbox{HelperPath: filepath.Join(dir, "helper"), RuntimePath: filepath.Join(dir, "runtime"), FirmwarePath: filepath.Join(dir, "firmware"), RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), RuntimeHome: dir} + missing := Config{HelperPath: filepath.Join(dir, "helper"), RuntimePath: filepath.Join(dir, "runtime"), FirmwarePath: filepath.Join(dir, "firmware"), RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), RuntimeHome: dir} for _, tc := range []struct { name string kvm bool @@ -120,9 +116,9 @@ func TestMicrosandboxProbeDiagnostics(t *testing.T) { want string }{ // KVM is reported before capacity and missing artifacts. - {name: "kvm", resources: sandbox.Resources{CPUs: 255, MemoryMiB: 1048576}, want: "kvm_unavailable"}, + {name: "kvm", resources: sandbox.Resources{CPUs: 255, MemoryMiB: 1048576}, want: "host_unsupported"}, {name: "capacity", kvm: true, resources: sandbox.Resources{CPUs: 255, MemoryMiB: 1048576}, want: "capacity_insufficient"}, - {name: "artifacts", kvm: true, resources: smallSandbox, want: "microsandbox_artifacts_unavailable"}, + {name: "artifacts", kvm: true, resources: smallSandbox, want: "artifacts_unavailable"}, } { t.Run(tc.name, func(t *testing.T) { useKVM(t, tc.kvm) @@ -133,60 +129,6 @@ func TestMicrosandboxProbeDiagnostics(t *testing.T) { } } -func TestDockerProbeDiagnostics(t *testing.T) { - image := "sha256:" + strings.Repeat("c", 64) - for _, tc := range []struct { - name string - limits bool - cpus int - imageStatus int - want string - unreachable, infoFails bool - }{ - {name: "unreachable", unreachable: true, want: "docker_unavailable"}, - {name: "info", infoFails: true, want: "docker_unavailable"}, - // The pinned image is also missing below; earlier checks take precedence. - {name: "limits", cpus: 8, imageStatus: 404, want: "docker_limits_unsupported"}, - {name: "capacity", limits: true, cpus: 1, imageStatus: 404, want: "capacity_insufficient"}, - {name: "image", limits: true, cpus: 8, imageStatus: 404, want: "runtime_image_unavailable"}, - {name: "image_inspect_fails", limits: true, cpus: 8, imageStatus: 500, want: "docker_unavailable"}, - {name: "ready", limits: true, cpus: 8, imageStatus: 200}, - } { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch path := strings.TrimPrefix(r.URL.Path, "/v1.52"); { - case path == "/_ping": - _, _ = w.Write([]byte("OK")) - case path == "/info" && !tc.infoFails: - _ = json.NewEncoder(w).Encode(map[string]any{"MemoryLimit": tc.limits, "CpuCfsQuota": tc.limits, "NCPU": tc.cpus, "MemTotal": int64(64) << 30}) - case path == "/images/"+image+"/json" && tc.imageStatus == 200: - _ = json.NewEncoder(w).Encode(map[string]string{"Id": image}) - case path == "/images/"+image+"/json": - w.WriteHeader(tc.imageStatus) - _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) - default: - w.WriteHeader(500) - _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) - } - })) - defer server.Close() - host := server.URL - if tc.unreachable { - host = "unix://" + filepath.Join(t.TempDir(), "missing.sock") - } - c, err := client.New(client.WithHost(host), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - defer c.Close() - if got := sandbox.NodeDiagnostic(dockerProbe(c, image, sandbox.Resources{CPUs: 2, MemoryMiB: 1024})(t.Context())); got != tc.want { - t.Fatalf("diagnostic = %q, want %q", got, tc.want) - } - }) - } -} - // A failed integrity check is repeated, so repaired artifacts recover without a restart. func TestMicrosandboxProbeRecoversRepairedArtifacts(t *testing.T) { if runtime.GOOS != "linux" { @@ -200,8 +142,8 @@ func TestMicrosandboxProbeRecoversRepairedArtifacts(t *testing.T) { if err := os.Mkdir(home, 0700); err != nil { t.Fatal(err) } - probe := microsandboxProbe(Microsandbox{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) - if got := sandbox.NodeDiagnostic(probe(t.Context())); got != "microsandbox_artifacts_unavailable" { + probe := microsandboxProbe(Config{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) + if got := sandbox.NodeDiagnostic(probe(t.Context())); got != "artifacts_unavailable" { t.Fatalf("missing artifact diagnostic = %q", got) } if err := os.WriteFile(artifact, content, 0700); err != nil { diff --git a/services/core/internal/sandbox/node/generations.go b/services/core/internal/sandbox/node/generations.go index 047bbe4e1..f538f0643 100644 --- a/services/core/internal/sandbox/node/generations.go +++ b/services/core/internal/sandbox/node/generations.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) @@ -363,18 +364,26 @@ func (m *GenerationManager) probeLoop() { cancel() m.mu.Lock() g.refs-- + changed := false if !errors.Is(err, context.Canceled) { + state, diagnostic := g.state, g.diagnostic g.state = "ready" g.diagnostic = "" if err != nil { g.state = "failed" g.diagnostic = sandbox.NodeDiagnostic(err) - if errors.Is(err, sandbox.ErrRuntimeImageUnavailable) || errors.Is(err, sandbox.ErrMicrosandboxArtifactsUnavailable) { + if errors.Is(err, sandbox.ErrRuntimeImageUnavailable) || errors.Is(err, sandbox.ErrArtifactsUnavailable) { g.repairing = true } } + changed = g.state != state || g.diagnostic != diagnostic } + generation, diagnostic := g.value.Generation, g.diagnostic m.mu.Unlock() + if err != nil && changed { + // The local error stays in this host's journal; it may name host paths. + log.Ctx(m.ctx).Warn("sandbox node generation provider unavailable; check local runtime configuration and permissions", "generation", generation, "diagnostic", diagnostic, "error", err) + } } } diff --git a/services/core/internal/sandbox/node/generations_test.go b/services/core/internal/sandbox/node/generations_test.go index 8831d5321..bce4d88cd 100644 --- a/services/core/internal/sandbox/node/generations_test.go +++ b/services/core/internal/sandbox/node/generations_test.go @@ -364,7 +364,7 @@ func TestInterruptedCollectionNeverPreparesOrServesAfterRestart(t *testing.T) { } func TestPreparationDiagnosticPreservesTypedCause(t *testing.T) { - for _, cause := range []error{sandbox.ErrRuntimeDownloadFailed, sandbox.ErrDockerUnavailable, sandbox.ErrKVMUnavailable, sandbox.ErrOwnership, context.Canceled, errors.New("raw secret provider text")} { + for _, cause := range []error{sandbox.ErrRuntimeDownloadFailed, sandbox.ErrProviderUnavailable, sandbox.ErrHostUnsupported, sandbox.ErrOwnership, context.Canceled, errors.New("raw secret provider text")} { t.Run(sandbox.NodeDiagnostic(cause)+cause.Error(), func(t *testing.T) { m, err := NewGenerationManager(t.Context(), GenerationManagerOptions{ Prepare: func(context.Context, uint64, string) (GenerationProvider, error) { return GenerationProvider{}, cause }, diff --git a/services/core/internal/sandbox/node/node_test.go b/services/core/internal/sandbox/node/node_test.go index 9aca8cd5a..946470389 100644 --- a/services/core/internal/sandbox/node/node_test.go +++ b/services/core/internal/sandbox/node/node_test.go @@ -302,7 +302,7 @@ func TestHealthSendsOnlyFixedDiagnosticCode(t *testing.T) { err error want string }{ - {fmt.Errorf("%w: dial unix /home/operator/private/docker.sock", sandbox.ErrDockerUnavailable), "docker_unavailable"}, + {fmt.Errorf("%w: open /home/operator/private/kvm", sandbox.ErrHostUnsupported), "host_unsupported"}, {errors.New("open /home/operator/private/runtime: permission denied"), "provider_unavailable"}, {nil, ""}, } { diff --git a/services/core/internal/sandbox/node_diagnostic.go b/services/core/internal/sandbox/node_diagnostic.go index 00b1ed898..efcdedc58 100644 --- a/services/core/internal/sandbox/node_diagnostic.go +++ b/services/core/internal/sandbox/node_diagnostic.go @@ -1,39 +1,54 @@ package sandbox -import "errors" +import ( + "errors" + "fmt" +) -// Node readiness failures. A node probe returns or wraps the one matching its -// first failed check. Only the fixed code crosses the node transport; the error -// text and any wrapped detail, such as host paths or daemon messages, stay local. +// Node readiness classes. A node probe returns or wraps the class of its first +// failed check and keeps the Provider's detail, such as host paths or daemon +// messages, in the local error text. Only the class code crosses the node +// transport. var ( - ErrRuntimeDownloadFailed = errors.New("Runtime preparation failed") - ErrDockerUnavailable = errors.New("Docker daemon is unavailable") - ErrDockerLimitsUnsupported = errors.New("Docker host does not enforce CPU and memory limits") - ErrRuntimeImageUnavailable = errors.New("pinned Runtime image is unavailable") - ErrKVMUnavailable = errors.New("KVM is unavailable to sandbox node") - ErrMicrosandboxArtifactsUnavailable = errors.New("pinned microsandbox artifacts are unavailable") - ErrCapacityInsufficient = errors.New("node cannot provide one sandbox of the deployment specification") + // The Provider's native service is unreachable or does not answer. + ErrProviderUnavailable = errors.New("sandbox provider unavailable") + // The host lacks a capability the Provider requires. + ErrHostUnsupported = errors.New("host lacks a capability the sandbox provider requires") + // A pinned native artifact is missing or fails its integrity check. + ErrArtifactsUnavailable = errors.New("pinned provider artifacts are unavailable") + // The exact Runtime artifacts could not be transferred or verified. + ErrRuntimeDownloadFailed = errors.New("Runtime preparation failed") + // The pinned Runtime image is not available to the Provider. + ErrRuntimeImageUnavailable = errors.New("pinned Runtime image is unavailable") + // The host cannot hold one sandbox of the deployment specification. + ErrCapacityInsufficient = errors.New("node cannot provide one sandbox of the deployment specification") ) -// NodeProviderUnavailable reports every readiness failure without a fixed cause. +// CheckCapacity reports whether a host can hold one sandbox of the given resources. +func CheckCapacity(r Resources, cpus int, memory uint64) error { + if cpus < int(r.CPUs) || memory < uint64(r.MemoryMiB)*1024*1024 { + return fmt.Errorf("%w: one sandbox requires %d CPUs and %d MiB memory; available host capacity is %d CPUs and %d MiB", ErrCapacityInsufficient, r.CPUs, r.MemoryMiB, cpus, memory/1024/1024) + } + return nil +} + +// NodeProviderUnavailable also reports every readiness failure without a class. const NodeProviderUnavailable = "provider_unavailable" -const NodeRuntimeDownloadFailed = "runtime_download_failed" var nodeDiagnostics = []struct { err error code string }{ - {ErrRuntimeDownloadFailed, NodeRuntimeDownloadFailed}, - {ErrDockerUnavailable, "docker_unavailable"}, - {ErrDockerLimitsUnsupported, "docker_limits_unsupported"}, + {ErrProviderUnavailable, NodeProviderUnavailable}, + {ErrHostUnsupported, "host_unsupported"}, + {ErrArtifactsUnavailable, "artifacts_unavailable"}, + {ErrRuntimeDownloadFailed, "runtime_download_failed"}, {ErrRuntimeImageUnavailable, "runtime_image_unavailable"}, - {ErrKVMUnavailable, "kvm_unavailable"}, - {ErrMicrosandboxArtifactsUnavailable, "microsandbox_artifacts_unavailable"}, {ErrCapacityInsufficient, "capacity_insufficient"}, } -// NodeDiagnostic maps a readiness probe result to its fixed code: empty when -// ready and provider_unavailable when no classified cause is wrapped. +// NodeDiagnostic maps a readiness probe result to its class code: empty when +// ready and provider_unavailable when no class is wrapped. func NodeDiagnostic(err error) string { if err == nil { return "" @@ -49,7 +64,7 @@ func NodeDiagnostic(err error) string { // NormalizeNodeDiagnostic keeps an empty or known code. Any other reported value // becomes provider_unavailable, so Core never stores node-supplied text. func NormalizeNodeDiagnostic(code string) string { - if code == "" || code == NodeProviderUnavailable { + if code == "" { return code } for _, d := range nodeDiagnostics { diff --git a/services/core/internal/sandbox/node_diagnostic_test.go b/services/core/internal/sandbox/node_diagnostic_test.go index e7a9602eb..84b199cd6 100644 --- a/services/core/internal/sandbox/node_diagnostic_test.go +++ b/services/core/internal/sandbox/node_diagnostic_test.go @@ -18,7 +18,7 @@ func TestNodeDiagnosticContract(t *testing.T) { if err := json.Unmarshal(raw, &fixture); err != nil { t.Fatal(err) } - codes := []string{NodeProviderUnavailable} + var codes []string for _, diagnostic := range nodeDiagnostics { codes = append(codes, diagnostic.code) if got := NodeDiagnostic(fmt.Errorf("private probe detail: %w", diagnostic.err)); got != diagnostic.code { diff --git a/services/core/internal/sandbox/providers/capacity.go b/services/core/internal/sandbox/providers/capacity.go deleted file mode 100644 index 1b569c132..000000000 --- a/services/core/internal/sandbox/providers/capacity.go +++ /dev/null @@ -1,14 +0,0 @@ -package providers - -import ( - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" -) - -func checkCapacity(r sandbox.Resources, cpus int, memory uint64) error { - if cpus < int(r.CPUs) || memory < uint64(r.MemoryMiB)*1024*1024 { - return fmt.Errorf("%w: one sandbox requires %d CPUs and %d MiB memory; available host capacity is %d CPUs and %d MiB", sandbox.ErrCapacityInsufficient, r.CPUs, r.MemoryMiB, cpus, memory/1024/1024) - } - return nil -} diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go index 32a3b1f33..07b0359b1 100644 --- a/services/core/internal/sandbox/providers/config.go +++ b/services/core/internal/sandbox/providers/config.go @@ -3,11 +3,9 @@ package providers import ( "bytes" - "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" @@ -16,28 +14,9 @@ import ( "github.com/google/uuid" ) -type Config struct { - Specification sandbox.DeploymentSpec `json:"specification"` - Generation uint64 `json:"generation"` - CoreURL string `json:"core_url"` - Provider string `json:"provider"` - InstallationID string `json:"installation_id"` - Docker *Docker `json:"docker,omitempty"` - Microsandbox *Microsandbox `json:"microsandbox,omitempty"` -} - -type Docker struct { - Host string `json:"host"` - Image string `json:"image"` - Network string `json:"network"` - SeccompFile string `json:"seccomp_file"` - ExtraHosts []string `json:"extra_hosts"` - NestedSandbox bool `json:"nested_sandbox"` -} - -// Load rejects unknown fields, mixed adapters and explicit null configuration. -func Load(file string) (Config, error) { - var config Config +// Load rejects unknown fields. The selected adapter decodes native at Build. +func Load(file string) (sandbox.NodeConfig, error) { + var config sandbox.NodeConfig raw, err := os.ReadFile(file) if err != nil { return config, errors.New("cannot read sandbox configuration") @@ -47,50 +26,32 @@ func Load(file string) (Config, error) { if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF { return config, errors.New("invalid sandbox configuration") } - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { - return config, errors.New("invalid sandbox configuration") - } - _, docker := fields["docker"] - _, micro := fields["microsandbox"] - if docker && micro { - return config, errors.New("only one sandbox provider can be configured") - } return config, nil } -type Built struct { - SpecificationDigest string - Provider sandbox.SandboxProvider - InstallationID, BackendFingerprint string - Probe func(context.Context) error - // Quiescent is nil when no helper can outlive its caller. - Quiescent func() bool -} - -// LocalOptions supplies process-local context without changing persisted configuration. -type LocalOptions struct { - // Standalone selects registration or execution without a generation manager. - Standalone bool - // GenerationStateDirectory is the node state directory when constructing a - // retained generation. It must be canonical and absolute, and Standalone - // must be false. - GenerationStateDirectory string -} - -func (r *Registry) Build(config Config, options LocalOptions) (*Built, func(), error) { +// Build validates the Core-owned part of a node configuration and passes it to +// the registered adapter. Local paths belong to the node; Core owns reservation +// capacity, execution resources and the immutable deployment release it +// enrolled with. +func (r *Registry) Build(config sandbox.NodeConfig, options sandbox.LocalOptions) (*sandbox.Built, func(), error) { closeProvider := func() {} - if err := r.validateSpecification(config); err != nil { + adapter, err := r.Lookup(config.Provider) + if err != nil { + return nil, closeProvider, err + } + if adapter.Mode != "nodes" { + return nil, closeProvider, fmt.Errorf("%w: selected provider does not support node hosting", sandbox.ErrInvalid) + } + if config.Generation == 0 { + return nil, closeProvider, errors.New("node requires a deployment generation; obtain configuration from Core") + } + if err := adapter.ValidateSpecification(config.Specification); err != nil { return nil, closeProvider, err } id, err := uuid.Parse(config.InstallationID) if err != nil || id == uuid.Nil || id.String() != config.InstallationID { return nil, closeProvider, errors.New("sandbox requires a canonical installation_id UUID") } - adapter, err := r.Lookup(config.Provider) - if err != nil || adapter.BuildLocal == nil { - return nil, closeProvider, errors.New("sandbox provider is not node-local") - } if options.Standalone { if options.GenerationStateDirectory != "" { return nil, closeProvider, sandbox.ErrInvalid @@ -98,7 +59,7 @@ func (r *Registry) Build(config Config, options LocalOptions) (*Built, func(), e } else if !filepath.IsAbs(options.GenerationStateDirectory) || filepath.Clean(options.GenerationStateDirectory) != options.GenerationStateDirectory { return nil, closeProvider, sandbox.ErrInvalid } - result := &Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} + result := &sandbox.Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} closeProvider, err = adapter.BuildLocal(config, options, result) if err != nil { return nil, closeProvider, err @@ -109,8 +70,3 @@ func (r *Registry) Build(config Config, options LocalOptions) (*Built, func(), e } return result, closeProvider, nil } - -func BackendFingerprint(kind, namespace string) string { - digest := sha256.Sum256([]byte(kind + "\x00" + namespace)) - return hex.EncodeToString(digest[:]) -} diff --git a/services/core/internal/sandbox/providers/config_test.go b/services/core/internal/sandbox/providers/config_test.go index 0753377ef..33695ac33 100644 --- a/services/core/internal/sandbox/providers/config_test.go +++ b/services/core/internal/sandbox/providers/config_test.go @@ -3,62 +3,34 @@ package providers import ( "os" "path/filepath" - "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" ) func TestNodeRejectsCoreConfigurationAndUnknownProvider(t *testing.T) { - for _, field := range []string{`"nodes":{"local":false}`, `"maintenance":true`} { + for _, field := range []string{`"nodes":{"local":false}`, `"maintenance":true`, `"docker":{}`} { path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(`{"provider":"docker",`+field+`}`), 0600); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { - t.Fatal("accepted retired Core configuration") + t.Fatal("accepted a field outside the node configuration") } } - if BackendFingerprint("docker", "socket") == BackendFingerprint("microsandbox", "socket") { + if sandbox.BackendFingerprint("docker", "socket") == sandbox.BackendFingerprint("microsandbox", "socket") { t.Fatal("provider namespaces collide") } } -func TestNodeSpecificationCannotBeOverridden(t *testing.T) { + +func TestNodeBuildRequiresNodeProviderAndGeneration(t *testing.T) { registry := Builtin() - if err := registry.validateSpecification(Config{Generation: 1, Provider: "e2b", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Docker: &Docker{}}); err == nil { + options := sandbox.LocalOptions{Standalone: true} + if _, _, err := registry.Build(sandbox.NodeConfig{Generation: 1, Provider: "e2b", InstallationID: uuid.NewString(), Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}}, options); err == nil { t.Fatal("accepted a cloud provider on a node") } - release := sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)} - c := Config{Generation: 1, Provider: "docker", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &release}, Docker: &Docker{Image: release.ImageID}} - for _, image := range []string{release.ImageID, release.ImageManifestDigest} { - c.Docker.Image = image - if err := registry.validateSpecification(c); err != nil { - t.Fatal(err) - } - } - c.Docker.Image = "sha256:" + strings.Repeat("a", 64) - if err := registry.validateSpecification(c); err == nil { - t.Fatal("accepted different Runtime") - } - c.Provider = "microsandbox" - c.Docker = nil - c.Specification.Resources.RootDiskMiB = 8192 - c.Specification.Resources.EnvironmentDiskMiB = 8192 - c.Microsandbox = &Microsandbox{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192, Image: release.MicrosandboxRef, RuntimeSHA256: release.RuntimeSHA256, FirmwareSHA256: release.FirmwareSHA256} - if err := registry.validateSpecification(c); err != nil { - t.Fatal(err) - } - for _, change := range []func(*Microsandbox){func(m *Microsandbox) { m.CPUs = 1 }, func(m *Microsandbox) { m.MemoryMiB = 1024 }, func(m *Microsandbox) { m.EnvironmentDiskMiB = 4096 }, func(m *Microsandbox) { m.RootDiskMiB = 4096 }, func(m *Microsandbox) { m.FirmwareSHA256 = strings.Repeat("a", 64) }} { - copy := *c.Microsandbox - change(©) - other := c - other.Microsandbox = © - if err := registry.validateSpecification(other); err == nil { - t.Fatal("accepted local specification override") - } - } - c.Generation = 0 - if err := registry.validateSpecification(c); err == nil { + if _, _, err := registry.Build(sandbox.NodeConfig{Provider: "docker", InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options); err == nil { t.Fatal("accepted unbound node") } } diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index 4070b041b..7226a4047 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -99,7 +99,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { // The deployment reaches the registered configuration only through the // registry it is built with. deployments := func() *deployment.Service { - storage := deploymentpg.New(pgunit.NewPool(pool), nil) + storage := deploymentpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) rules, err := placement.NewRules(registry, "") if err != nil { t.Fatal(err) @@ -116,7 +116,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { t.Fatal(err) } defer lease.Close(context.Background()) - changes, err := deployment.NewExecutionOperations(service, deploymentpg.NewExecution(lease, nil)) + changes, err := deployment.NewExecutionOperations(service, deploymentpg.NewExecution(lease, pgtest.CredentialKey(t))) if err != nil { t.Fatal(err) } @@ -152,7 +152,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { Environments: struct{ api.Environments }{}, EnvironmentsReader: struct{ api.EnvironmentsReader }{}, Admin: struct{ api.Admin }{}, AdminAudit: struct{ api.AdminAudit }{}, WriteAudit: struct{ api.WriteAudit }{}, ExecutorConnections: struct{ api.ExecutorConnections }{}, Metrics: struct{ api.Metrics }{}, RuntimeObservations: struct{ api.RuntimeObservations }{}, RuntimeHistory: struct{ api.RuntimeHistory }{}, - Execution: &api.Execution{ + Execution: api.Execution{ ExecutorURL: "wss://core.example/api/v1/agent-daemon/ws", SessionAdmission: struct{ api.SessionAdmission }{}, InputAdmission: struct{ api.InputAdmission }{}, @@ -160,7 +160,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { Workspaces: struct{ api.EnvironmentWorkspaces }{}, Links: struct{ http.Handler }{}, }, - Sandboxes: &api.Sandboxes{Deployment: service, NodeAllocations: deploymentpg.New(pgunit.NewPool(pool), nil), DeploymentChanges: leaseSetup{t: t, changes: changes, installation: installation}, + Sandboxes: api.Sandboxes{Deployment: service, NodeAllocations: deploymentpg.New(pgunit.NewPool(pool), pgtest.CredentialKey(t)), DeploymentChanges: leaseSetup{t: t, changes: changes, installation: installation}, DeploymentReset: leaseSetup{t: t, changes: changes, installation: installation}, ConfigurationDiscovery: struct{ api.ConfigurationDiscovery }{}}, }) if err != nil { diff --git a/services/core/internal/sandbox/providers/deployment_contract_test.go b/services/core/internal/sandbox/providers/deployment_contract_test.go index ea6477748..2f9d9798e 100644 --- a/services/core/internal/sandbox/providers/deployment_contract_test.go +++ b/services/core/internal/sandbox/providers/deployment_contract_test.go @@ -19,11 +19,11 @@ func TestDeploymentContractProjectionsAreCurrent(t *testing.T) { if err != nil { t.Fatal(err) } - expected, err := registry.PythonDeploymentContract() + expectedPython, expectedTypeScript, err := registry.DeploymentContract() if err != nil { t.Fatal(err) } - if !strings.Contains(string(python), expected) || string(typescript) != sandbox.TypeScriptDeploymentContract() { + if !strings.Contains(string(python), expectedPython) || string(typescript) != expectedTypeScript { t.Fatal("deployment contract projection is stale; regenerate with go run ./services/core/cmd/specification-contract -write") } } diff --git a/services/core/internal/sandbox/providers/docker.go b/services/core/internal/sandbox/providers/docker.go deleted file mode 100644 index c0f186c00..000000000 --- a/services/core/internal/sandbox/providers/docker.go +++ /dev/null @@ -1,46 +0,0 @@ -package providers - -import ( - "encoding/json" - "errors" - "fmt" - "net/url" - "os" - "path/filepath" - - sandboxdocker "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" - "github.com/moby/moby/client" -) - -func buildDocker(config Config, _ LocalOptions, result *Built) (func(), error) { - closeProvider := func() {} - if config.Docker == nil || config.Microsandbox != nil { - return closeProvider, errors.New("managed Docker requires only the docker configuration object") - } - entry := config.Docker - host, err := url.Parse(entry.Host) - if err != nil || host.Scheme != "unix" || host.Host != "" || host.User != nil || host.RawQuery != "" || host.Fragment != "" || host.RawPath != "" || host.Path == "/" || !filepath.IsAbs(host.Path) || filepath.Clean(host.Path) != host.Path || entry.Host != "unix://"+host.Path { - return closeProvider, errors.New("managed Docker host must be an explicit canonical unix socket") - } - seccomp, err := os.ReadFile(entry.SeccompFile) - if err != nil { - return closeProvider, fmt.Errorf("cannot read managed Docker seccomp JSON: %w", err) - } - if !json.Valid(seccomp) { - return closeProvider, errors.New("invalid managed Docker seccomp JSON") - } - c, err := client.New(client.WithHost(entry.Host)) - if err != nil { - return closeProvider, errors.New("invalid managed Docker endpoint") - } - closeProvider = func() { _ = c.Close() } - provider, err := sandboxdocker.New(c, sandboxdocker.Config{InstallationID: config.InstallationID, Image: entry.Image, Network: entry.Network, Seccomp: string(seccomp), ExtraHosts: entry.ExtraHosts, NestedSandbox: entry.NestedSandbox, Resources: &config.Specification.Resources}) - if err != nil { - closeProvider() - return func() {}, errors.New("invalid managed Docker provider configuration") - } - result.Provider = provider - result.Probe = dockerProbe(c, entry.Image, config.Specification.Resources) - result.BackendFingerprint = BackendFingerprint(config.Provider, entry.Host) - return closeProvider, nil -} diff --git a/services/core/internal/sandbox/providers/generation_test.go b/services/core/internal/sandbox/providers/generation_test.go index e9b1c957c..0bacbe40d 100644 --- a/services/core/internal/sandbox/providers/generation_test.go +++ b/services/core/internal/sandbox/providers/generation_test.go @@ -2,13 +2,10 @@ package providers import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "os" "path/filepath" - "runtime" "strconv" "syscall" "testing" @@ -19,16 +16,18 @@ import ( "github.com/google/uuid" ) -func generationConfig(t *testing.T) Config { +func generationConfig(t *testing.T) (sandbox.NodeConfig, sandboxmicro.Native) { t.Helper() dir := t.TempDir() spec := validRegistrationSpec() spec.Resources.RootDiskMiB, spec.Resources.EnvironmentDiskMiB = 8192, 8192 - return Config{Provider: "microsandbox", InstallationID: uuid.NewString(), Generation: 9, Specification: spec, - Microsandbox: &Microsandbox{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), - RuntimeSHA256: spec.Runtime.RuntimeSHA256, FirmwareSHA256: spec.Runtime.FirmwareSHA256, Image: spec.Runtime.MicrosandboxRef, - CPUs: uint8(spec.Resources.CPUs), MemoryMiB: spec.Resources.MemoryMiB, RootDiskMiB: spec.Resources.RootDiskMiB, EnvironmentDiskMiB: spec.Resources.EnvironmentDiskMiB, - Network: Network{DefaultEgress: "allow", DefaultIngress: "deny"}}} + native := sandboxmicro.Native{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), + Network: sandboxmicro.Network{DefaultEgress: "allow", DefaultIngress: "deny"}} + raw, err := json.Marshal(native) + if err != nil { + t.Fatal(err) + } + return sandbox.NodeConfig{Provider: "microsandbox", InstallationID: uuid.NewString(), Generation: 9, Specification: spec, Native: raw}, native } func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { @@ -54,7 +53,8 @@ func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { t.Fatal(err) } } - built, closeProvider, err := registry.Build(generationConfig(t), LocalOptions{GenerationStateDirectory: state}) + config, _ := generationConfig(t) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state}) closeProvider() if mode == "private" { info, statErr := os.Lstat(directory) @@ -74,9 +74,9 @@ func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { // lease to this generation, installation and specification before any helper runs. func TestMicrosandboxGenerationBindsLeaseIdentity(t *testing.T) { registry := Builtin() - config := generationConfig(t) + config, native := generationConfig(t) state := t.TempDir() - built, closeProvider, err := registry.Build(config, LocalOptions{GenerationStateDirectory: state}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state}) if err != nil { t.Fatal(err) } @@ -89,7 +89,7 @@ func TestMicrosandboxGenerationBindsLeaseIdentity(t *testing.T) { t.Fatal(err) } script := "#!/bin/sh\n[ \"$OAC_NODE_GENERATION_LEASE_FD\" = 3 ] || exit 1\ncat >/dev/null\nprintf '%s' '" + string(response) + "'\n" - if err := os.WriteFile(config.Microsandbox.HelperPath, []byte(script), 0700); err != nil { + if err := os.WriteFile(native.HelperPath, []byte(script), 0700); err != nil { t.Fatal(err) } base := filepath.Join(state, "generations", strconv.FormatUint(config.Generation, 10)) @@ -152,10 +152,9 @@ func TestMicrosandboxGenerationRejectsUnpinnedImage(t *testing.T) { registry := Builtin() for _, image := range []string{"latest", "oac-runtime@sha256:bad", "oac-runtime@sha256:"} { t.Run(image, func(t *testing.T) { - config := generationConfig(t) - config.Microsandbox.Image = image + config, _ := generationConfig(t) config.Specification.Runtime.MicrosandboxRef = image - built, closeProvider, err := registry.Build(config, LocalOptions{GenerationStateDirectory: t.TempDir()}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: t.TempDir()}) closeProvider() if err == nil || built != nil { t.Fatalf("accepted image %q", image) @@ -167,55 +166,22 @@ func TestMicrosandboxGenerationRejectsUnpinnedImage(t *testing.T) { func TestLocalConstructionRequiresExplicitContext(t *testing.T) { registry := Builtin() state := t.TempDir() - for _, options := range []LocalOptions{ + config, _ := generationConfig(t) + for _, options := range []sandbox.LocalOptions{ {}, {Standalone: true, GenerationStateDirectory: state}, {GenerationStateDirectory: "relative"}, {GenerationStateDirectory: state + "/../node"}, } { - built, closeProvider, err := registry.Build(generationConfig(t), options) + built, closeProvider, err := registry.Build(config, options) closeProvider() if !errors.Is(err, sandbox.ErrInvalid) || built != nil { t.Fatalf("accepted construction context %+v: %v", options, err) } } - built, closeProvider, err := registry.Build(generationConfig(t), LocalOptions{Standalone: true}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{Standalone: true}) closeProvider() if err != nil || built == nil { t.Fatalf("standalone construction: %v", err) } } - -func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) { - registry := Builtin() - if runtime.GOOS != "linux" { - t.Skip("microsandbox requires Linux") - } - useKVM(t, true) - config := generationConfig(t) - script := []byte("#!/bin/sh\nprintf '%s' '{}'\n") - digest := sha256.Sum256(script) - config.Microsandbox.RuntimeSHA256 = hex.EncodeToString(digest[:]) - config.Microsandbox.FirmwareSHA256 = hex.EncodeToString(digest[:]) - config.Specification.Runtime.RuntimeSHA256 = config.Microsandbox.RuntimeSHA256 - config.Specification.Runtime.FirmwareSHA256 = config.Microsandbox.FirmwareSHA256 - for _, path := range []string{config.Microsandbox.RuntimePath, config.Microsandbox.FirmwarePath, config.Microsandbox.HelperPath} { - if err := os.WriteFile(path, script, 0700); err != nil { - t.Fatal(err) - } - } - if err := os.Chmod(config.Microsandbox.RuntimeHome, 0700); err != nil { - t.Fatal(err) - } - for _, options := range []LocalOptions{{Standalone: true}, {GenerationStateDirectory: t.TempDir()}} { - built, closeProvider, err := registry.Build(config, options) - if err != nil { - t.Fatal(err) - } - err = built.Probe(t.Context()) - closeProvider() - if options.Standalone && err != nil || !options.Standalone && !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { - t.Fatalf("readiness for %+v: %v", options, err) - } - } -} diff --git a/services/core/internal/sandbox/providers/microsandbox.go b/services/core/internal/sandbox/providers/microsandbox.go deleted file mode 100644 index 58fbcb8cf..000000000 --- a/services/core/internal/sandbox/providers/microsandbox.go +++ /dev/null @@ -1,92 +0,0 @@ -package providers - -import ( - "errors" - "os" - "path/filepath" - "strconv" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - sandboxmicro "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" -) - -// Single-host providers pin the helper, runtime, firmware, image and resource -// limits explicitly. The helper owns local paths; no ambient backend is selected. -type Microsandbox struct { - HelperPath string `json:"helper_path"` - RuntimeHome string `json:"runtime_home"` - RuntimePath string `json:"runtime_path"` - FirmwarePath string `json:"firmware_path"` - RuntimeSHA256 string `json:"runtime_sha256"` - FirmwareSHA256 string `json:"firmware_sha256"` - Image string `json:"image"` - MemoryMiB uint32 `json:"memory_mib"` - CPUs uint8 `json:"cpus"` - RootDiskMiB uint32 `json:"root_disk_mib"` - EnvironmentDiskMiB uint32 `json:"environment_disk_mib"` - Network Network `json:"network"` -} - -type Network struct { - DefaultEgress string `json:"default_egress"` - DefaultIngress string `json:"default_ingress"` - Rules []Rule `json:"rules"` -} - -type Rule struct { - Action string `json:"action"` - Direction string `json:"direction"` - Destination string `json:"destination"` - Protocol string `json:"protocol"` - Port string `json:"port"` -} - -func configureMicrosandbox(entry Microsandbox, resources sandbox.Resources, caller *sandboxmicro.ProcessCaller, result *Built) error { - if !filepath.IsAbs(entry.RuntimeHome) || filepath.Clean(entry.RuntimeHome) != entry.RuntimeHome { - return errors.New("managed microsandbox runtime_home must be a canonical absolute path") - } - network := sandboxmicro.NetworkPolicy{DefaultEgress: entry.Network.DefaultEgress, DefaultIngress: entry.Network.DefaultIngress} - for _, rule := range entry.Network.Rules { - network.Rules = append(network.Rules, sandboxmicro.NetworkRule{Action: rule.Action, Direction: rule.Direction, Destination: rule.Destination, Protocol: rule.Protocol, Port: rule.Port}) - } - provider, err := sandboxmicro.NewWithCaller(sandboxmicro.Config{ - InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath, - RuntimeSHA256: entry.RuntimeSHA256, FirmwareSHA256: entry.FirmwareSHA256, Image: entry.Image, - MemoryMiB: entry.MemoryMiB, CPUs: entry.CPUs, RootDiskMiB: entry.RootDiskMiB, EnvironmentDiskMiB: entry.EnvironmentDiskMiB, Network: network, - }, caller) - if err != nil { - return errors.New("invalid managed microsandbox provider configuration") - } - result.Provider = provider - result.Probe = microsandboxProbe(entry, resources) - result.Quiescent = caller.Quiescent - result.BackendFingerprint = BackendFingerprint("microsandbox", entry.RuntimeHome) - return nil -} - -func buildMicrosandbox(c Config, options LocalOptions, result *Built) (func(), error) { - closeProvider := func() {} - if c.Microsandbox == nil || c.Docker != nil { - return closeProvider, errors.New("managed microsandbox requires only the microsandbox configuration object") - } - caller := &sandboxmicro.ProcessCaller{} - if options.GenerationStateDirectory != "" { - directory := filepath.Join(options.GenerationStateDirectory, "generations") - if err := os.MkdirAll(directory, 0700); err != nil { - return closeProvider, err - } - info, err := os.Lstat(directory) - if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - return closeProvider, sandbox.ErrOwnership - } - caller.LeasePath = filepath.Join(directory, strconv.FormatUint(c.Generation, 10)+".lease") - caller.LeaseIdentity = sandboxmicro.LeaseIdentity{InstallationID: result.InstallationID, Generation: c.Generation, SpecificationDigest: result.SpecificationDigest} - } - if err := configureMicrosandbox(*c.Microsandbox, c.Specification.Resources, caller, result); err != nil { - return closeProvider, err - } - if options.GenerationStateDirectory != "" { - result.Probe = microsandboxGenerationProbe(*c.Microsandbox, result.Probe) - } - return closeProvider, nil -} diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 712283e55..2cf638f56 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -2,14 +2,14 @@ package providers import ( "fmt" - "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) // ValidateRegistration checks wiring before configuration parsing or construction. -// Only configuration requirements and operation declarations are read. +// Only configuration requirements and operation declarations are read, and the +// resource validator only for a declared default size, after every other check. func ValidateRegistration(a Adapter) error { invalid := func(field string) error { return fmt.Errorf("%w: invalid registration %s", providercontract.ErrContract, field) @@ -51,16 +51,13 @@ func ValidateRegistration(a Adapter) error { if err := sandbox.ValidateOperations(operations); err != nil { return err } - // The current common lifecycle admits checkpoint suspension only on nodes, - // and creates its policy whenever checkpoint support is declared. - if operations["Initial"].State == providercontract.Supported { - const maximumSeconds = int64((1<<63 - 1) / time.Second) - if a.Mode != "nodes" || a.IdleSeconds < 1 || a.RetentionSeconds < 1 || - a.IdleSeconds > maximumSeconds || a.RetentionSeconds > maximumSeconds { - return invalid("checkpoint policy") - } - } else if a.IdleSeconds != 0 || a.RetentionSeconds != 0 { - return invalid("non-checkpoint policy") + // The common lifecycle suspends only node allocations, so only a nodes + // registration may declare checkpoint support. + if operations["Initial"].State == providercontract.Supported && a.Mode != "nodes" { + return invalid("checkpoint support outside nodes mode") + } + if a.Policy.DefaultResources != nil && a.ValidateResources(*a.Policy.DefaultResources) != nil { + return invalid("default resources") } return nil } diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index f53df2c05..6809eb350 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -59,7 +59,7 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { a := registry.adapters["docker"] - a.BuildLocal = func(Config, LocalOptions, *Built) (func(), error) { + a.BuildLocal = func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) { t.Fatal("called local constructor") return nil, nil } @@ -90,14 +90,14 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { {"resolve change", func() error { _, err := registry.ResolveChange(selection, selection); return err }}, {"credential", func() error { _, err := registry.WithCredential(selection, selection); return err }}, {"local build", func() error { - _, _, err := registry.Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: selection.DeploymentSpec}, LocalOptions{Standalone: true}) + _, _, err := registry.Build(sandbox.NodeConfig{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: selection.DeploymentSpec}, sandbox.LocalOptions{Standalone: true}) return err }}, {"direct build", func() error { _, err := registry.BuildDirect(sandbox.DirectConfig{Selection: selection}); return err }}, {"binding", func() error { return ValidateBinding(a, &docker.Provider{}) }}, {"projection", func() error { - text, err := registry.PythonDeploymentContract() - if text != "" { + python, typescript, err := registry.DeploymentContract() + if python != "" || typescript != "" { t.Fatal("partial invalid projection") } return err @@ -123,9 +123,9 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { } const kind = "new-test-provider" calls, closes := 0, 0 - options := LocalOptions{GenerationStateDirectory: t.TempDir()} + options := sandbox.LocalOptions{GenerationStateDirectory: t.TempDir()} a := registry.adapters["docker"] - a.BuildLocal = func(_ Config, got LocalOptions, built *Built) (func(), error) { + a.BuildLocal = func(_ sandbox.NodeConfig, got sandbox.LocalOptions, built *sandbox.Built) (func(), error) { calls++ if got != options { t.Fatalf("construction options = %+v, want %+v", got, options) @@ -134,7 +134,7 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { return func() { closes++ }, nil } registry.adapters[kind] = a - built, closeProvider, err := registry.Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options) + built, closeProvider, err := registry.Build(sandbox.NodeConfig{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options) if err != nil || built.Provider == nil || calls != 1 { t.Fatalf("node build: %v calls=%d", err, calls) } @@ -154,40 +154,25 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { if err != nil || p == nil || calls != 2 { t.Fatalf("credential-free direct build: %v calls=%d", err, calls) } - if _, err := registry.PythonDeploymentContract(); err != nil { + if _, _, err := registry.DeploymentContract(); err != nil { t.Fatal(err) } } -// Idle time is measured before suspension, retention after suspension. Neither -// duration needs to be greater than the other. -func TestRegistrationCheckpointPolicy(t *testing.T) { +func TestRegistrationRejectsInvalidDefaultResources(t *testing.T) { + a := Builtin().adapters["docker"] + a.Policy.DefaultResources = &sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 1024} + if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) { + t.Fatal(err) + } +} + +// Only a nodes registration may declare checkpoint support. +func TestRegistrationCheckpointRequiresNodes(t *testing.T) { registry := Builtin() - for _, tc := range []struct { - name string - kind string - idle, retention int64 - direct, valid bool - }{ - {"negative idle", "microsandbox", -1, 20, false, false}, - {"missing idle", "microsandbox", 0, 20, false, false}, - {"missing retention", "microsandbox", 20, 0, false, false}, - {"overflow", "microsandbox", 1<<63 - 1, 20, false, false}, - {"direct suspension", "microsandbox", 20, 20, true, false}, - {"unsupported suspension", "docker", 20, 20, false, false}, - {"independent durations", "microsandbox", 300, 30, false, true}, - {"no suspension", "docker", 0, 0, false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - a := registry.adapters[tc.kind] - a.IdleSeconds, a.RetentionSeconds = tc.idle, tc.retention - if tc.direct { - a.Mode, a.BuildLocal, a.BuildDirect = "direct", nil, registry.adapters["e2b"].BuildDirect - } - err := ValidateRegistration(a) - if (err == nil) != tc.valid || err != nil && !errors.Is(err, providercontract.ErrContract) { - t.Fatal(err) - } - }) + a := registry.adapters["microsandbox"] + a.Mode, a.BuildLocal, a.NodeArtifacts, a.BuildDirect = "direct", nil, nil, registry.adapters["e2b"].BuildDirect + if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) || !strings.Contains(err.Error(), "checkpoint") { + t.Fatal("direct checkpoint registration accepted", err) } } diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index 29bb86b4f..fe936c38a 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -17,16 +17,15 @@ import ( // Adapter describes configuration and transport independently of compute operations. // Native operation support comes from the adapter-owned complete declaration. type Adapter struct { - NodeArtifacts []providerassets.Artifact - Policy sandbox.DeploymentPolicy - Configuration sandbox.ConfigurationAdapter - BuildLocal func(Config, LocalOptions, *Built) (func(), error) - BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) - Mode string - Operations func() providercontract.Operations - IdleSeconds, RetentionSeconds int64 - ValidateSpecification func(sandbox.DeploymentSpec) error - ValidateResources func(sandbox.Resources) error + NodeArtifacts []providerassets.Artifact + Policy sandbox.DeploymentPolicy + Configuration sandbox.ConfigurationAdapter + BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) + BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) + Mode string + Operations func() providercontract.Operations + ValidateSpecification func(sandbox.DeploymentSpec) error + ValidateResources func(sandbox.Resources) error } // Registry holds the registered adapters. Core and the node program each build @@ -41,17 +40,13 @@ func Builtin() *Registry { return &Registry{adapters: map[string]Adapter{ "docker": { NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, - Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: buildDocker, + Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: docker.BuildNode, ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, Configuration: nodeConfigurationAdapter{docker.ValidateSpecification}, }, "microsandbox": { - NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy, - {Path: "native/bin/oac-microsandbox-provider", Suffix: "microsandbox-provider", Role: "runtime"}, - {Path: "native/microsandbox/msb", Suffix: "msb", Role: "runtime"}, - {Path: "native/microsandbox/libkrunfw.so.5.6.1", Suffix: "libkrunfw.so.5.6.1", Role: "runtime"}}, - Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: buildMicrosandbox, - IdleSeconds: 300, RetentionSeconds: 86400, + NodeArtifacts: append([]providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, microsandbox.NodeArtifacts...), + Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: microsandbox.BuildNode, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, }, @@ -141,19 +136,21 @@ func (r *Registry) Describe(kind, installation string) (sandbox.Description, err if a.Mode == "direct" { namespace = kind } - return sandbox.Description{Mode: a.Mode, BackendFingerprint: BackendFingerprint(kind, namespace+":"+installation), IdleSeconds: a.IdleSeconds, RetentionSeconds: a.RetentionSeconds}, nil + return sandbox.Description{Mode: a.Mode, BackendFingerprint: sandbox.BackendFingerprint(kind, namespace+":"+installation)}, nil } -// PythonDeploymentContract projects the same registered adapter policies into -// the node installer; no second provider list exists in another language. -func (r *Registry) PythonDeploymentContract() (string, error) { - policies := make(map[string]sandbox.DeploymentPolicy, len(r.adapters)) +// DeploymentContract projects the registered modes and policies into the node +// installer and the TypeScript client; no second provider list exists in +// another language. +func (r *Registry) DeploymentContract() (python, typescript string, err error) { + providers := make(map[string]sandbox.ProviderProjection, len(r.adapters)) for kind := range r.adapters { a, err := r.Lookup(kind) if err != nil { - return "", err + return "", "", err } - policies[kind] = a.Policy + providers[kind] = sandbox.ProviderProjection{Mode: a.Mode, DeploymentPolicy: a.Policy} } - return sandbox.PythonDeploymentContract(policies), nil + python, typescript = sandbox.DeploymentContract(providers) + return python, typescript, nil } diff --git a/services/core/internal/sandbox/providers/registry_test.go b/services/core/internal/sandbox/providers/registry_test.go index 6b3078902..bd6d1ac4f 100644 --- a/services/core/internal/sandbox/providers/registry_test.go +++ b/services/core/internal/sandbox/providers/registry_test.go @@ -14,17 +14,16 @@ func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { installation := uuid.NewString() for _, tc := range []struct { kind, mode, namespace string - idle, retention int64 checkpoint bool }{ - {"docker", "nodes", "nodes", 0, 0, false}, - {"microsandbox", "nodes", "nodes", 300, 86400, true}, - {"e2b", "direct", "e2b", 0, 0, false}, + {"docker", "nodes", "nodes", false}, + {"microsandbox", "nodes", "nodes", true}, + {"e2b", "direct", "e2b", false}, } { t.Run(tc.kind, func(t *testing.T) { d, err := registry.Describe(tc.kind, installation) - if err != nil || d.Mode != tc.mode || d.IdleSeconds != tc.idle || d.RetentionSeconds != tc.retention || d.BackendFingerprint != BackendFingerprint(tc.kind, tc.namespace+":"+installation) { - t.Fatalf("wrong namespace or defaults: %+v %v", d, err) + if err != nil || d.Mode != tc.mode || d.BackendFingerprint != sandbox.BackendFingerprint(tc.kind, tc.namespace+":"+installation) { + t.Fatalf("wrong mode or namespace: %+v %v", d, err) } a, err := registry.Lookup(tc.kind) checkpoint, checkpointErr := registry.SupportsCheckpoint(tc.kind) @@ -75,7 +74,7 @@ func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { t.Fatal("new entry did not follow shared boundary", err, checkpointErr) } d, err := registry.Describe(kind, uuid.NewString()) - if err != nil || d.Mode != "nodes" || d.IdleSeconds != 0 { + if err != nil || d.Mode != "nodes" { t.Fatal(d, err) } if _, err := registry.Normalize(sandbox.Selection{Provider: kind, Configuration: &e2b.DeploymentConfiguration{APIKey: "wrong-provider"}}); !errors.Is(err, sandbox.ErrInvalid) { diff --git a/services/core/internal/sandbox/providers/specification.go b/services/core/internal/sandbox/providers/specification.go deleted file mode 100644 index 72b1540b2..000000000 --- a/services/core/internal/sandbox/providers/specification.go +++ /dev/null @@ -1,42 +0,0 @@ -package providers - -import ( - "errors" - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" -) - -// Local paths belong to the node. Core owns reservation capacity, execution -// resources and the immutable deployment release it enrolled with. -func (r *Registry) validateSpecification(c Config) error { - adapter, err := r.Lookup(c.Provider) - if err != nil { - return err - } - if adapter.Mode != "nodes" { - return fmt.Errorf("%w: selected provider does not support node hosting", sandbox.ErrInvalid) - } - if c.Generation == 0 { - return errors.New("node requires a deployment generation; obtain configuration from Core") - } - if err := r.ValidateSpecification(c.Provider, c.Specification); err != nil { - return err - } - release := c.Specification.Runtime - if d := c.Docker; d != nil { - if d.Image != release.ImageID && d.Image != release.ImageManifestDigest { - return errors.New("Docker Runtime image differs from the deployment release") - } - } - if m := c.Microsandbox; m != nil { - s := c.Specification.Resources - if uint32(m.CPUs) != s.CPUs || m.MemoryMiB != s.MemoryMiB || m.RootDiskMiB != s.RootDiskMiB || m.EnvironmentDiskMiB != s.EnvironmentDiskMiB { - return errors.New("microsandbox CPU, memory or disk limits differ from the deployment specification") - } - if m.Image != release.MicrosandboxRef || m.RuntimeSHA256 != release.RuntimeSHA256 || m.FirmwareSHA256 != release.FirmwareSHA256 { - return errors.New("microsandbox Runtime or firmware differs from the deployment release") - } - } - return nil -} diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 68c91e3cf..bb7487ed7 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -271,6 +271,17 @@ type ConfigurationRequirements struct { CredentialVerification providercontract.Support } +// DeploymentPolicy is the deployment declaration. Disk declares independent +// disk limits; Runtime requires a pinned Runtime release, and RuntimeError is +// the fixed reason for rejecting one otherwise. DefaultResources is the size +// setup proposes, or nil when the Provider's configuration selects it. +type DeploymentPolicy struct { + RuntimeError string `json:"-"` + Disk bool `json:"disk"` + Runtime bool `json:"runtime"` + DefaultResources *Resources `json:"default_resources"` +} + // Configuration is an adapter-owned typed value, never a request or response DTO. // Implementations must exclude secrets from JSON and safe diagnostic output. type Configuration interface { @@ -312,6 +323,40 @@ type DirectConfig struct { Fence *CallFence } +// NodeConfig is a node's configuration for one deployment generation. Native +// holds only the selected adapter's node-local settings, such as host paths; +// that adapter alone decodes it, strictly. Resources and the Runtime release +// are read from Specification, never copied into Native. +type NodeConfig struct { + Specification DeploymentSpec `json:"specification"` + Generation uint64 `json:"generation"` + CoreURL string `json:"core_url"` + Provider string `json:"provider"` + InstallationID string `json:"installation_id"` + Native json.RawMessage `json:"native"` +} + +// LocalOptions supplies process-local context without changing persisted configuration. +type LocalOptions struct { + // Standalone selects registration or execution without a generation manager. + Standalone bool + // GenerationStateDirectory is the node state directory when constructing a + // retained generation. It must be canonical and absolute, and Standalone + // must be false. + GenerationStateDirectory string +} + +// Built is a constructed node adapter. Construction fills InstallationID and +// SpecificationDigest; the adapter fills the rest. +type Built struct { + SpecificationDigest string + Provider SandboxProvider + InstallationID, BackendFingerprint string + Probe func(context.Context) error + // Quiescent is nil when no helper can outlive its caller. + Quiescent func() bool +} + // ConfigurationDiscoveryInput is a transient read-only request. Query is typed // and validated by the adapter; it cannot select a compute mutation. type ConfigurationDiscoveryInput struct { diff --git a/services/core/internal/sandbox/testdata/node-diagnostics.json b/services/core/internal/sandbox/testdata/node-diagnostics.json index 109529c5f..4c885eec2 100644 --- a/services/core/internal/sandbox/testdata/node-diagnostics.json +++ b/services/core/internal/sandbox/testdata/node-diagnostics.json @@ -1,10 +1,8 @@ [ "provider_unavailable", - "docker_unavailable", - "docker_limits_unsupported", + "host_unsupported", + "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", - "kvm_unavailable", - "microsandbox_artifacts_unavailable", "capacity_insufficient" ] diff --git a/services/core/internal/sessions/creation.go b/services/core/internal/sessions/creation.go index 50b75eceb..b6cb3ffa5 100644 --- a/services/core/internal/sessions/creation.go +++ b/services/core/internal/sessions/creation.go @@ -15,7 +15,6 @@ import ( "github.com/google/uuid" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" @@ -29,7 +28,6 @@ import ( type CreationStorage interface { // FingerprintProviderKey returns the keyed fingerprint of a model // provider key, so retry identities tell keys apart without hashing a key. - // Without a credential key it is credentialcrypto.ErrUnavailable. FingerprintProviderKey(secret string) (string, error) // WithCreation runs apply in one pooled transaction and commits only when // apply returns nil. A malformed tenant is ErrInvalidInput. @@ -516,12 +514,6 @@ func (s *Service) FindSessionCreation(ctx context.Context, tenant, key string, r return Creation{}, ErrInvalidInput } hash, err := intentHash(request, s.storage.FingerprintProviderKey) - if errors.Is(err, credentialcrypto.ErrUnavailable) { - // Without the credential key no Session with a provider bundle can - // have been committed or can be created; creation reports the missing - // key after request validation. - return Creation{}, ErrNotFound - } if err != nil { return Creation{}, err } diff --git a/services/core/internal/sessions/creation_test.go b/services/core/internal/sessions/creation_test.go index e1cc49beb..64af55d88 100644 --- a/services/core/internal/sessions/creation_test.go +++ b/services/core/internal/sessions/creation_test.go @@ -14,7 +14,6 @@ import ( "github.com/google/uuid" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" @@ -141,7 +140,10 @@ func (s *fakeStorage) FindCreation(_ context.Context, tenant, key string) (Creat // fingerprints is a fake FingerprintProviderKey that keeps keys apart. func fingerprints(secret string) (string, error) { return "fp-" + secret, nil } -func unavailable(string) (string, error) { return "", credentialcrypto.ErrUnavailable } +var errFingerprint = errors.New("fingerprint failed") + +// failing is a fake FingerprintProviderKey that fails. +func failing(string) (string, error) { return "", errFingerprint } // declarations accept every provider and specification. type declarations struct{} @@ -200,8 +202,8 @@ func TestPrepareCreation(t *testing.T) { if prepare(t, withProvider("self_hosted", "one", "session"), fingerprints).RequestHash == prepare(t, withProvider("self_hosted", "two", "session"), fingerprints).RequestHash { t.Fatal("caller keys share an identity") } - deployed := prepare(t, withProvider("none", "deployment-key", v1.ModelProviderSourceDeployment), unavailable) - plain := prepare(t, func(input *CreateSession) { input.Configuration = creationInput("none").Configuration }, unavailable) + deployed := prepare(t, withProvider("none", "deployment-key", v1.ModelProviderSourceDeployment), failing) + plain := prepare(t, func(input *CreateSession) { input.Configuration = creationInput("none").Configuration }, failing) if deployed.RequestHash != plain.RequestHash { t.Fatalf("the deployment default joined the identity: %s", deployed.Configuration) } @@ -223,7 +225,7 @@ func TestPrepareCreation(t *testing.T) { }, fingerprints, ErrInvalidInput}, "configuration array": {func(input *CreateSession) { input.Configuration = json.RawMessage(`[]`) }, fingerprints, ErrInvalidInput}, "cancel initial input": {func(input *CreateSession) { input.InitialInputs = []Input{cancelInput} }, fingerprints, ErrInvalidInput}, - "no credential key": {withProvider("self_hosted", "key", "session"), unavailable, credentialcrypto.ErrUnavailable}, + "fingerprint failure": {withProvider("self_hosted", "key", "session"), failing, errFingerprint}, "unreadable intent": {func(input *CreateSession) { input.CreationRequest = json.RawMessage(`[]`) }, fingerprints, ErrInvalidInput}, } { t.Run(name, func(t *testing.T) { @@ -261,10 +263,10 @@ func TestIntentHash(t *testing.T) { "over 16 MiB": {`"` + strings.Repeat("x", 16<<20) + `"`, fingerprints, ErrInvalidInput}, "extension not object": {`{"x_agents_core":[]}`, fingerprints, ErrInvalidInput}, "provider unreadable": {`{"x_agents_core":{"model_provider":[]}}`, fingerprints, ErrInvalidInput}, - "provider no key": {`{"x_agents_core":{"model_provider":{"api_key":"k"}}}`, unavailable, credentialcrypto.ErrUnavailable}, - "null extension": {`{"x_agents_core":null}`, unavailable, nil}, - "null provider": {`{"x_agents_core":{"model_provider":null}}`, unavailable, nil}, - "intent without key": {`{"agent_id":"a"}`, unavailable, nil}, + "provider fingerprint": {`{"x_agents_core":{"model_provider":{"api_key":"k"}}}`, failing, errFingerprint}, + "null extension": {`{"x_agents_core":null}`, failing, nil}, + "null provider": {`{"x_agents_core":{"model_provider":null}}`, failing, nil}, + "intent without key": {`{"agent_id":"a"}`, failing, nil}, } { if _, err := hash(test.raw, test.fingerprint); test.want == nil && err != nil || test.want != nil && !errors.Is(err, test.want) { t.Errorf("%s: got %v, want %v", name, err, test.want) @@ -535,7 +537,6 @@ func TestFindSessionCreation(t *testing.T) { {"recorded intent", "create", request, creator, fingerprints, found, nil, []string{"FindCreation tenant create"}}, {"missing creation", "create", request, creator, fingerprints, func() (CreationRecord, error) { return CreationRecord{}, ErrNotFound }, ErrNotFound, []string{"FindCreation tenant create"}}, {"another creator", "create", request, identity.Subject{Kind: "user", ID: "stranger"}, fingerprints, found, ErrIdempotencyConflict, []string{"FindCreation tenant create"}}, - {"no credential key", "create", json.RawMessage(`{"x_agents_core":{"model_provider":{"api_key":"k"}}}`), creator, unavailable, nil, ErrNotFound, []string{"FingerprintProviderKey"}}, {"no intent", "create", nil, creator, nil, nil, ErrInvalidInput, nil}, {"invalid key", " ", request, creator, nil, nil, ErrInvalidInput, nil}, {"invalid creator", "create", request, identity.Subject{}, nil, nil, ErrInvalidInput, nil}, diff --git a/services/core/internal/sessions/environment.go b/services/core/internal/sessions/environment.go index 14f3edd3d..ee02013a3 100644 --- a/services/core/internal/sessions/environment.go +++ b/services/core/internal/sessions/environment.go @@ -27,14 +27,12 @@ type EnvironmentReader interface { // pending or running. ListEnvironmentInitializations(ctx context.Context, after string) ([]EnvironmentInitialization, error) // ReadEnvironmentSetup opens the setup frozen for the Session's - // Environment. A missing Session is ErrNotFound and a missing credential - // key credentialcrypto.ErrUnavailable; frozen data that does not open or - // validate is an internal error. + // Environment. A missing Session is ErrNotFound; frozen data that does not + // open or validate is an internal error. ReadEnvironmentSetup(ctx context.Context, tenant, session string) (environmentconfig.Setup, error) // ReadInitialEnvironmentFile opens the initial file frozen at position for // the Session's Environment, so an installation holds one file at a time. - // A missing file is ErrNotFound and a missing credential key - // credentialcrypto.ErrUnavailable; a file that does not open or match its + // A missing file is ErrNotFound; a file that does not open or match its // recorded size is an internal error. ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (environmentconfig.InitialFileMetadata, []byte, error) } diff --git a/services/core/internal/sessions/executor_credentials.go b/services/core/internal/sessions/executor_credentials.go index 7f857ea1d..bfb38d8ff 100644 --- a/services/core/internal/sessions/executor_credentials.go +++ b/services/core/internal/sessions/executor_credentials.go @@ -88,13 +88,11 @@ type ExecutorCredentialStorage interface { // principal or Project, or an unknown one, is ErrNotFound. LoadExecutorCredentialRestriction(ctx context.Context, principal identity.Principal, key string) (string, error) // SignInstallation returns the signature of an installation authorization - // payload under the credential key. Without that key it is - // credentialcrypto.ErrUnavailable. + // payload under the credential key. SignInstallation(ctx context.Context, payload string) (string, error) // VerifyInstallation checks the signature of an installation // authorization payload: another signature is - // ErrInstallationAuthorization, and a service without the credential key - // credentialcrypto.ErrUnavailable. + // ErrInstallationAuthorization. VerifyInstallation(ctx context.Context, payload, signature string) error // WithExecutorCredentials runs apply in a transaction of the tenant. WithExecutorCredentials(ctx context.Context, tenant string, apply func(context.Context, ExecutorCredentialTx) error) error diff --git a/services/core/internal/sessions/model_execution.go b/services/core/internal/sessions/model_execution.go index 632ac47bb..9cf88a726 100644 --- a/services/core/internal/sessions/model_execution.go +++ b/services/core/internal/sessions/model_execution.go @@ -9,8 +9,7 @@ import ( // ModelExecutionReader reads the model execution a Session froze at creation. type ModelExecutionReader interface { // SessionModelExecution opens the model provider the tenant's Session - // froze at creation. A Session without one is ErrNotFound, and a service - // without the credential key credentialcrypto.ErrUnavailable; a frozen + // froze at creation. A Session without one is ErrNotFound; a frozen // provider that does not open, decode or validate is an internal error. SessionModelExecution(ctx context.Context, tenant, session string) (*v1.ModelProviderInput, error) } diff --git a/services/core/internal/vaults/service.go b/services/core/internal/vaults/service.go index d314066f4..6143432ea 100644 --- a/services/core/internal/vaults/service.go +++ b/services/core/internal/vaults/service.go @@ -18,8 +18,7 @@ import ( const oauthRefreshTimeout = 20 * time.Second // Service runs the Vault and Credential operations. Storage seals and opens -// the secrets; without a credential key, operations that need a secret return -// credentialcrypto.ErrUnavailable and the others keep working. +// the secrets. type Service struct { storage Storage refresher oauthrefresh.Refresher diff --git a/services/core/internal/vaults/service_test.go b/services/core/internal/vaults/service_test.go index 4b0e49d97..28f5d952f 100644 --- a/services/core/internal/vaults/service_test.go +++ b/services/core/internal/vaults/service_test.go @@ -10,7 +10,6 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" ) @@ -163,6 +162,9 @@ func (f refreshFunc) Refresh(ctx context.Context, request oauthrefresh.Request) return f(ctx, request) } +// errStorage is a storage failure the Service returns unchanged. +var errStorage = errors.New("storage failed") + func unexpectedRefresh(t testing.TB) refreshFunc { return func(context.Context, oauthrefresh.Request) (oauthrefresh.Token, error) { t.Fatal("unexpected call to Refresh") @@ -236,9 +238,9 @@ func TestCredentialCreationValidationOrder(t *testing.T) { if credential.VaultID != "not-a-vault" { t.Fatalf("%s creation changed the Vault ID %q", kind, credential.VaultID) } - return Credential{}, credentialcrypto.ErrUnavailable + return Credential{}, errStorage }}, unexpectedRefresh(t)) - if err := run(service, "valid", "not-a-vault"); !errors.Is(err, credentialcrypto.ErrUnavailable) { + if err := run(service, "valid", "not-a-vault"); !errors.Is(err, errStorage) { t.Fatalf("%s creation: got %v", kind, err) } } @@ -295,9 +297,9 @@ func TestUpdateStaticCredential(t *testing.T) { if replacement != (StaticTokenReplacement{CredentialKey: CredentialKey{tenant, vault, id}, MCPServerURL: url, Token: "replacement"}) { t.Fatalf("unexpected replacement %+v", replacement) } - return Credential{}, credentialcrypto.ErrUnavailable + return Credential{}, errStorage }}, unexpectedRefresh(t)) - if _, err := service.UpdateStaticCredential(t.Context(), command); !errors.Is(err, credentialcrypto.ErrUnavailable) { + if _, err := service.UpdateStaticCredential(t.Context(), command); !errors.Is(err, errStorage) { t.Fatal("the storage result was not returned", err) } } @@ -348,8 +350,8 @@ func TestStaticBearerToken(t *testing.T) { if token, err := testService(t, &fakeStorage{staticToken: lookup("private-token", nil)}, unexpectedRefresh(t)).MCPBearerToken(t.Context(), command); err != nil || token != "private-token" { t.Fatal("static token was not returned", err) } - if token, err := testService(t, &fakeStorage{staticToken: lookup("", credentialcrypto.ErrUnavailable)}, unexpectedRefresh(t)).MCPBearerToken(t.Context(), command); !errors.Is(err, credentialcrypto.ErrUnavailable) || token != "" { - t.Fatal("a keyless lookup returned a token", err) + if token, err := testService(t, &fakeStorage{staticToken: lookup("", errStorage)}, unexpectedRefresh(t)).MCPBearerToken(t.Context(), command); !errors.Is(err, errStorage) || token != "" { + t.Fatal("a failed lookup returned a token", err) } unscoped := command unscoped.Binding.CredentialID = "not-a-credential" @@ -468,10 +470,10 @@ func TestOAuthBearerTokenFailuresReturnNoToken(t *testing.T) { storage := scenario.service.storage.(*fakeStorage) storage.withOAuthCredential = func(ctx context.Context, _ CredentialKey, apply func(OAuthTx) error) error { return apply(&fakeOAuthTx{t: t, load: func(context.Context, string) (OAuthGrant, error) { - return OAuthGrant{}, credentialcrypto.ErrUnavailable + return OAuthGrant{}, errStorage }}) } - if token, err := scenario.service.MCPBearerToken(t.Context(), scenario.command); !errors.Is(err, credentialcrypto.ErrUnavailable) || token != "" { + if token, err := scenario.service.MCPBearerToken(t.Context(), scenario.command); !errors.Is(err, errStorage) || token != "" { t.Fatal("a failed load was not returned unchanged", err) } }) diff --git a/services/core/internal/vaults/storage.go b/services/core/internal/vaults/storage.go index 1598bd12e..af97af86c 100644 --- a/services/core/internal/vaults/storage.go +++ b/services/core/internal/vaults/storage.go @@ -28,12 +28,10 @@ type Storage interface { // DeleteVault deletes a Vault with all of its Credentials and returns its ID. DeleteVault(ctx context.Context, tenantID, vaultID string) (string, error) // CreateCredential seals the Credential's secret and stores it in a Vault - // of the tenant. A missing credential key is - // credentialcrypto.ErrUnavailable, checked after the new Credential ID and - // before the Vault. + // of the tenant. CreateCredential(ctx context.Context, credential NewCredential) (Credential, error) // ReplaceStaticToken seals and replaces a static_bearer Credential's - // token. Without a credential key it is credentialcrypto.ErrUnavailable. + // token. ReplaceStaticToken(ctx context.Context, replacement StaticTokenReplacement) (Credential, error) // DeleteCredential deletes a Credential and its sealed secret and returns its ID. DeleteCredential(ctx context.Context, key CredentialKey) (string, error) @@ -48,8 +46,7 @@ type Storage interface { // Vaults that the query selects, ordered by ID. FindMCPCredentials(ctx context.Context, query MCPCredentialQuery) ([]MCPCredentialMatch, error) // StaticToken opens a static_bearer Credential's token when the complete - // frozen scope still names it. A scope that names none is ErrNotFound, - // then a missing credential key is credentialcrypto.ErrUnavailable. + // frozen scope still names it. A scope that names none is ErrNotFound. StaticToken(ctx context.Context, query StaticTokenQuery) (string, error) } @@ -59,9 +56,8 @@ type OAuthTx interface { // LoadOAuthGrant locks the Credential until the transaction ends, so // competing refreshes, replacements and deletions, including the parent // Vault's, wait. A non-empty destination must match the stored one, or - // the Credential is ErrNotFound. Only then is the grant opened: a missing - // credential key is credentialcrypto.ErrUnavailable, and stored metadata - // that differs from the sealed copy fails authentication. + // the Credential is ErrNotFound. Only then is the grant opened: stored + // metadata that differs from the sealed copy fails authentication. LoadOAuthGrant(ctx context.Context, destination string) (OAuthGrant, error) // ApplyOAuthRefresh seals and stores a refreshed grant for the loaded // Credential. Execution refreshes are not caller writes and record no diff --git a/services/core/migrations/000094_suspension_from_declaration.sql b/services/core/migrations/000094_suspension_from_declaration.sql new file mode 100644 index 000000000..67bd968aa --- /dev/null +++ b/services/core/migrations/000094_suspension_from_declaration.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Core derives the idle suspension policy from the Provider's checkpoint +-- declaration, so the deployment no longer stores it. +ALTER TABLE runtime_deployment + DROP CONSTRAINT runtime_deployment_setup_check, + DROP COLUMN idle_seconds, + DROP COLUMN retention_seconds, + ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + installation_id IS NULL OR + (provider_kind = '' AND mode = '' AND generation >= 0) OR + (provider_kind <> '' AND mode IN ('nodes','direct') AND generation > 0) + ); + +-- +goose Down +ALTER TABLE runtime_deployment + DROP CONSTRAINT runtime_deployment_setup_check, + ADD COLUMN idle_seconds bigint NOT NULL DEFAULT 0 CHECK (idle_seconds >= 0), + ADD COLUMN retention_seconds bigint NOT NULL DEFAULT 0 CHECK (retention_seconds >= 0); +UPDATE runtime_deployment SET idle_seconds = 300, retention_seconds = 86400 WHERE provider_kind = 'microsandbox'; +ALTER TABLE runtime_deployment + ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + installation_id IS NULL OR + (provider_kind = '' AND mode = '' AND generation >= 0 AND idle_seconds = 0 AND retention_seconds = 0) OR + (provider_kind <> '' AND mode IN ('nodes','direct') AND generation > 0 AND + ((idle_seconds = 0 AND retention_seconds = 0) OR (idle_seconds > 0 AND retention_seconds > 0))) + ); diff --git a/services/core/migrations/000095_node_readiness_classes.sql b/services/core/migrations/000095_node_readiness_classes.sql new file mode 100644 index 000000000..36cc27afb --- /dev/null +++ b/services/core/migrations/000095_node_readiness_classes.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- Node readiness diagnostics are Provider-neutral classes. An offline node keeps +-- its last report, so rewrite every stored vendor code to its class. +UPDATE runtime_nodes SET health = jsonb_set(health, '{diagnostic}', to_jsonb(CASE health->>'diagnostic' + WHEN 'docker_unavailable' THEN 'provider_unavailable' + WHEN 'microsandbox_artifacts_unavailable' THEN 'artifacts_unavailable' + ELSE 'host_unsupported' END::text)) +WHERE health->>'diagnostic' IN ('docker_unavailable', 'docker_limits_unsupported', 'kvm_unavailable', 'microsandbox_artifacts_unavailable'); +UPDATE runtime_node_generation_status SET diagnostic = CASE diagnostic + WHEN 'docker_unavailable' THEN 'provider_unavailable' + WHEN 'microsandbox_artifacts_unavailable' THEN 'artifacts_unavailable' + ELSE 'host_unsupported' END +WHERE diagnostic IN ('docker_unavailable', 'docker_limits_unsupported', 'kvm_unavailable', 'microsandbox_artifacts_unavailable'); + +-- +goose Down +UPDATE runtime_nodes SET health = jsonb_set(health, '{diagnostic}', '"provider_unavailable"') +WHERE health->>'diagnostic' IN ('host_unsupported', 'artifacts_unavailable'); +UPDATE runtime_node_generation_status SET diagnostic = 'provider_unavailable' +WHERE diagnostic IN ('host_unsupported', 'artifacts_unavailable'); diff --git a/services/core/migrations/000094_session_runtime_assignments.sql b/services/core/migrations/000096_session_runtime_assignments.sql similarity index 100% rename from services/core/migrations/000094_session_runtime_assignments.sql rename to services/core/migrations/000096_session_runtime_assignments.sql diff --git a/services/core/migrations/000095_sandbox_link_authority.sql b/services/core/migrations/000097_sandbox_link_authority.sql similarity index 100% rename from services/core/migrations/000095_sandbox_link_authority.sql rename to services/core/migrations/000097_sandbox_link_authority.sql diff --git a/services/core/tests/container_server.py b/services/core/tests/container_server.py index 1649d4ce2..b7b21dbac 100755 --- a/services/core/tests/container_server.py +++ b/services/core/tests/container_server.py @@ -3,28 +3,21 @@ import os -# Match ownership of the suite's private Core key digest file without granting root access. +# Match ownership of the suite's private installation files without granting root access. # The image's default UID is separately exercised by deployment acceptance. assert os.getuid() != 0, "Run container acceptance as an unprivileged host user" -core_key_digests = os.environ["OAC_CORE_KEY_DIGESTS_FILE"] args = [ "docker", "run", "--rm", "--read-only", "--network=host", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", f"{os.getuid()}:{os.getgid()}", - "--mount", f"type=bind,source={core_key_digests},target=/run/core-key-digests.json,readonly", - "--env", "OAC_CORE_KEY_DIGESTS_FILE=/run/core-key-digests.json", ] -credential_key = os.environ.get("OAC_CREDENTIAL_KEY_FILE") -if credential_key: +for name, target in (("OAC_CORE_KEY_DIGESTS_FILE", "/run/core-key-digests.json"), + ("OAC_CREDENTIAL_KEY_FILE", "/run/credential.key"), + ("OAC_INSTALLATION_ID_FILE", "/run/installation.id"), + ("OAC_AGENT_HOST_IDENTITY_FILE", "/run/agent-host/identity.json")): args.extend([ - "--mount", f"type=bind,source={credential_key},target=/run/credential.key,readonly", - "--env", "OAC_CREDENTIAL_KEY_FILE=/run/credential.key", - ]) -agent_host_identity = os.environ.get("OAC_AGENT_HOST_IDENTITY_FILE") -if agent_host_identity: - args.extend([ - "--mount", f"type=bind,source={agent_host_identity},target=/run/agent-host/identity.json,readonly", - "--env", "OAC_AGENT_HOST_IDENTITY_FILE=/run/agent-host/identity.json", + "--mount", f"type=bind,source={os.environ[name]},target={target},readonly", + "--env", f"{name}={target}", ]) for name in ("OAC_DATABASE_URL", "OAC_ADDR", "OAC_DEFAULT_HARNESS", "OAC_PUBLIC_URL"): args.extend(["--env", name]) diff --git a/services/core/tests/integration/agent_execution_defaults_http_test.go b/services/core/tests/integration/agent_execution_defaults_http_test.go index d65f1ba26..86977e3e0 100644 --- a/services/core/tests/integration/agent_execution_defaults_http_test.go +++ b/services/core/tests/integration/agent_execution_defaults_http_test.go @@ -23,7 +23,7 @@ func TestAgentExecutionDefaultsPublicSnapshotAndPrecedence(t *testing.T) { auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "defaults-test", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) deployment := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: "deployment-canary"} defaultsCalls := 0 - handler, err := publicHandler(t, st, auth, "codex", storeExecution(t, st), managedSandboxes(t, st), withHarnesses([]string{"codex", "claude_sdk", "mcode"}), modelProviderDefaults(func(context.Context, string) (*modelconfiguration.Snapshot, error) { + handler, err := publicHandler(t, st, auth, "codex", withHarnesses([]string{"codex", "claude_sdk", "mcode"}), modelProviderDefaults(func(context.Context, string) (*modelconfiguration.Snapshot, error) { defaultsCalls++ copy := *deployment return &modelconfiguration.Snapshot{Model: "fixture", Provider: ©, Revision: uuid.New()}, nil diff --git a/services/core/tests/integration/agents_delete_public_test.go b/services/core/tests/integration/agents_delete_public_test.go index 0db4233c0..be794ad2a 100644 --- a/services/core/tests/integration/agents_delete_public_test.go +++ b/services/core/tests/integration/agents_delete_public_test.go @@ -17,20 +17,20 @@ func TestAgentDeletionOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) token, foreign := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: uuid.NewString()}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } server := httptest.NewServer(h) defer server.Close() - recoveredStore := NewWithCredentialCipher(s.pool, fixtureCipher) - h, err = publicHandler(t, recoveredStore, auth, "codex", storeExecution(t, recoveredStore), fixtureDeploymentProvider()) + recoveredStore := New(t, s.pool) + h, err = publicHandler(t, recoveredStore, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/agents_update_public_test.go b/services/core/tests/integration/agents_update_public_test.go index d0f5fc0f7..dc50786d3 100644 --- a/services/core/tests/integration/agents_update_public_test.go +++ b/services/core/tests/integration/agents_update_public_test.go @@ -17,20 +17,20 @@ func TestAgentUpdateOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) token, foreign := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: uuid.NewString()}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } server := httptest.NewServer(h) defer server.Close() - recoveredStore := NewWithCredentialCipher(s.pool, fixtureCipher) - h, err = publicHandler(t, recoveredStore, auth, "codex", storeExecution(t, recoveredStore), fixtureDeploymentProvider()) + recoveredStore := New(t, s.pool) + h, err = publicHandler(t, recoveredStore, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/command_output_test.go b/services/core/tests/integration/command_output_test.go index 647d05650..df69ac1fe 100644 --- a/services/core/tests/integration/command_output_test.go +++ b/services/core/tests/integration/command_output_test.go @@ -73,7 +73,7 @@ func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { t.Fatal(err) } // Reopening the Store recovers committed Items without creating events. - reopened := New(pool) + reopened := New(t, pool) before, _ = sessionAdapter(s).SessionEventCursor(ctx, tenant, session.ID) page, err = sessionAdapter(s).ListItems(ctx, tenant, session.ID, "", 100, true) if err != nil || len(page.Items) != 3 { diff --git a/services/core/tests/integration/configuration_validation_public_test.go b/services/core/tests/integration/configuration_validation_public_test.go index f68c27c23..ae0c8d41d 100644 --- a/services/core/tests/integration/configuration_validation_public_test.go +++ b/services/core/tests/integration/configuration_validation_public_test.go @@ -30,7 +30,7 @@ func TestAgentConfigurationValidationRejectsWithoutWritesPostgres(t *testing.T) {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "config-owner", TokenSHA256: runtimedevice.HashCredential(owner), TenantID: ownerTenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "config-foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/creation_stream_settlement_public_test.go b/services/core/tests/integration/creation_stream_settlement_public_test.go index 954300f88..bfa7d7f30 100644 --- a/services/core/tests/integration/creation_stream_settlement_public_test.go +++ b/services/core/tests/integration/creation_stream_settlement_public_test.go @@ -113,10 +113,10 @@ func (s sseLines) open(t *testing.T) { // creation stream whose initial reservation is cancelled without a Session event // ends through the committed projection, while GET stays open. func TestCreationStreamPublicLifetimes(t *testing.T) { - s, _ := NewModelTestStore(t) + s, _ := testStore(t) tenant, token := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) - handler, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), executorURL("https://offline-executor.example")) + handler, err := publicHandler(t, s, auth, "codex", executorURL("https://offline-executor.example")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/credential_matrix_http_test.go b/services/core/tests/integration/credential_matrix_http_test.go index 9b6e14610..74b49cfdc 100644 --- a/services/core/tests/integration/credential_matrix_http_test.go +++ b/services/core/tests/integration/credential_matrix_http_test.go @@ -36,7 +36,7 @@ func TestCredentialNamespaceMatrix(t *testing.T) { if err != nil { t.Fatal(err) } - handler, err := publicHandler(t, s, nil, "codex", storeKeys(s), storeExecution(t, s), managedSandboxes(t, s), withCoreKeys(admin)) + handler, err := publicHandler(t, s, nil, "codex", storeKeys(s), withCoreKeys(admin)) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/deployment_model_providers_http_test.go b/services/core/tests/integration/deployment_model_providers_http_test.go index 9d208653e..aa0260f38 100644 --- a/services/core/tests/integration/deployment_model_providers_http_test.go +++ b/services/core/tests/integration/deployment_model_providers_http_test.go @@ -36,7 +36,7 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { if err != nil { t.Fatal(err) } - handler, err := publicHandler(t, st, auth, "codex", storeExecution(t, st), managedSandboxes(t, st), withCoreKeys(admin), withHarnesses([]string{"codex", "mcode"})) + handler, err := publicHandler(t, st, auth, "codex", withCoreKeys(admin), withHarnesses([]string{"codex", "mcode"})) if err != nil { t.Fatal(err) } @@ -174,8 +174,8 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { t.Fatal(err) } incompatible := call("POST", "/v1/agents/sessions", projectKey, hosted, 400) - if !strings.Contains(string(incompatible["error"]), "does not support this model provider protocol") || strings.Contains(string(incompatible["error"]), "credential_storage_unavailable") { - t.Fatal("unsupported stored protocol was reported as a credential failure") + if !strings.Contains(string(incompatible["error"]), "does not support this model provider protocol") { + t.Fatal("unsupported stored protocol was not reported as such") } if text(providerView(call("GET", path, coreKey, "", 200))["protocol"]) != "anthropic" { t.Fatal("unsupported default was rewritten") @@ -242,13 +242,13 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { // first: a same-key retry returns the committed Session after the default was // replaced or removed. func TestSessionRetryAfterDeploymentDefaultChanges(t *testing.T) { - st, _ := NewModelTestStore(t) + st, _ := testStore(t) if _, err := st.pool.Exec(t.Context(), "DELETE FROM deployment_model_providers"); err != nil { t.Fatal(err) } tenant, token := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "none-retry", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) - handler, err := publicHandler(t, st, auth, "codex", storeExecution(t, st)) + handler, err := publicHandler(t, st, auth, "codex") if err != nil { t.Fatal(err) } @@ -330,7 +330,7 @@ func TestDeploymentProviderResolutionPairsRevisionDuringReplacement(t *testing.T _, err = defaults.Replace(admin, modelconfiguration.Replacement{Harness: harness, Configuration: v1.ModelConfigurationInput{ModelProvider: replacement, Model: "fixture"}}) return snapshot, err } - handler, err := publicHandler(t, st, auth, "codex", storeExecution(t, st), modelProviderDefaults(resolver)) + handler, err := publicHandler(t, st, auth, "codex", modelProviderDefaults(resolver)) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/dispatch_test.go b/services/core/tests/integration/dispatch_test.go index af575f019..3fd4d30c1 100644 --- a/services/core/tests/integration/dispatch_test.go +++ b/services/core/tests/integration/dispatch_test.go @@ -52,7 +52,7 @@ func newDispatchHarness(t *testing.T) *dispatchHarness { func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool) *dispatchHarness { t.Helper() - s, _ := NewModelTestStore(t) + s, _ := testStore(t) h := &dispatchHarness{t: t, s: s, tenant: uuid.NewString(), environments: map[string]*dispatchHarness{}} ctx := context.Background() var err error @@ -76,7 +76,7 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool if getErr != nil { t.Fatal(getErr) } - h.device, err = FixtureEnvironmentDevice(ctx, s.pool, h.tenant, environment.ID, "local runtime", runtimedevice.HashCredential(secret)) + h.device, err = FixtureEnvironmentDevice(t, ctx, s.pool, h.tenant, environment.ID, "local runtime", runtimedevice.HashCredential(secret)) } else { h.device, err = sessionService(t, s).CreateDevice(ctx, h.tenant, "isolated executor", runtimedevice.HashCredential(secret)) } diff --git a/services/core/tests/integration/environment_file_writes_test.go b/services/core/tests/integration/environment_file_writes_test.go index 2c4f598d3..276e346ad 100644 --- a/services/core/tests/integration/environment_file_writes_test.go +++ b/services/core/tests/integration/environment_file_writes_test.go @@ -28,7 +28,7 @@ func newFileWriteFixture(t *testing.T) fileWriteFixture { lease := executionWriter(t, s).lease tenant := uuid.NewString() session, env := localEnvironment(t, s, tenant) - host, err := FixtureEnvironmentDevice(t.Context(), pool, tenant, env.ID, "file owner", runtimedevice.HashCredential(uuid.NewString())) + host, err := FixtureEnvironmentDevice(t, t.Context(), pool, tenant, env.ID, "file owner", runtimedevice.HashCredential(uuid.NewString())) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/environment_initial_public_test.go b/services/core/tests/integration/environment_initial_public_test.go index 30559672c..a034328e6 100644 --- a/services/core/tests/integration/environment_initial_public_test.go +++ b/services/core/tests/integration/environment_initial_public_test.go @@ -44,7 +44,7 @@ func TestEnvironmentInitialFailureOfficialClient(t *testing.T) { t.Error(err) } }) - handler, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), executorURL("https://executor.example")) + handler, err := publicHandler(t, s, auth, "codex", executorURL("https://executor.example")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/environment_retrieve_public_test.go b/services/core/tests/integration/environment_retrieve_public_test.go index 2169efcef..79e87ba7a 100644 --- a/services/core/tests/integration/environment_retrieve_public_test.go +++ b/services/core/tests/integration/environment_retrieve_public_test.go @@ -21,7 +21,7 @@ func TestEnvironmentRetrievalOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) tenant, foreignTenant := uuid.NewString(), uuid.NewString() principal := FixtureExecutorPrincipal(t, s, tenant) token, peer, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() @@ -47,7 +47,7 @@ func TestEnvironmentRetrievalOfficialClient(t *testing.T) { } } }() - handler, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), executorURL("https://private-registry.example")) + handler, err := publicHandler(t, s, auth, "codex", executorURL("https://private-registry.example")) if err != nil { t.Fatal(err) } @@ -85,7 +85,7 @@ func TestEnvironmentRetrievalOfficialClient(t *testing.T) { revoked = true server.Close() s.pool.Close() - reopened, _ := NewModelTestStore(t) + reopened, _ := testStore(t) handler, err = publicHandler(t, reopened, auth, "codex") if err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/executor_principals_migration_test.go b/services/core/tests/integration/executor_principals_migration_test.go index bc6769d4e..bb7835ad1 100644 --- a/services/core/tests/integration/executor_principals_migration_test.go +++ b/services/core/tests/integration/executor_principals_migration_test.go @@ -103,7 +103,7 @@ func TestExecutorPrincipalMigrationRetiresUnknownAuthority(t *testing.T) { t.Fatal(err) } t.Cleanup(migrated.Close) - s := New(migrated) + s := New(t, migrated) p := FixtureExecutorPrincipal(t, s, tenant) credentials := sessionService(t, s) if _, err := sessionAdapter(s).AuthenticateEnvironmentExecutor(ctx, environment, digest); !errors.Is(err, sessions.ErrNotFound) { diff --git a/services/core/tests/integration/fixtures_test.go b/services/core/tests/integration/fixtures_test.go index ecf898f09..b78317374 100644 --- a/services/core/tests/integration/fixtures_test.go +++ b/services/core/tests/integration/fixtures_test.go @@ -1,7 +1,6 @@ package integration import ( - "bytes" "context" "encoding/json" "errors" @@ -9,7 +8,6 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" @@ -20,15 +18,6 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -var fixtureCipher, _ = credentialcrypto.New(bytes.Repeat([]byte{61}, 32)) - -// NewModelTestStore is testStore with a fixture credential key, so Sessions -// can freeze a model provider. -func NewModelTestStore(t *testing.T) (*Store, *pgxpool.Pool) { - _, pool := testStore(t) - return NewWithCredentialCipher(pool, fixtureCipher), pool -} - // FixtureModelProvider is a valid bundle for the harness. No Session runs // without one, so fixtures supply it instead of relaxing that check. func FixtureModelProvider(harness string) *v1.ModelProviderInput { @@ -39,7 +28,7 @@ func FixtureModelProvider(harness string) *v1.ModelProviderInput { } // WithFixtureModelProvider adds the fixture provider, as a Session-supplied -// bundle, to a creation that has none. The store must have a credential key. +// bundle, to a creation that has none. func WithFixtureModelProvider(input sessions.CreateSession) sessions.CreateSession { if input.ModelProvider != nil { return input diff --git a/services/core/tests/integration/function_inputs_public_test.go b/services/core/tests/integration/function_inputs_public_test.go index 5b512cffe..842e27263 100644 --- a/services/core/tests/integration/function_inputs_public_test.go +++ b/services/core/tests/integration/function_inputs_public_test.go @@ -47,7 +47,7 @@ func TestFunctionInputsOfficialClientAtomicAdmission(t *testing.T) { } } auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}}) - handler, err := publicHandler(t, s, auth, "codex", storeExecution(t, s)) + handler, err := publicHandler(t, s, auth, "codex") if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/initial_files_http_test.go b/services/core/tests/integration/initial_files_http_test.go index 9fee427b4..25d67b80a 100644 --- a/services/core/tests/integration/initial_files_http_test.go +++ b/services/core/tests/integration/initial_files_http_test.go @@ -23,7 +23,7 @@ func TestInitialFilesHTTPInlineLimitsAndRetry(t *testing.T) { tenant, token := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) // Exercise HTTP parsing and durable storage without starting a Runtime. - handler, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), managedSandboxes(t, s), fixtureDeploymentProvider()) + handler, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/input_conflicts_public_test.go b/services/core/tests/integration/input_conflicts_public_test.go index 3583f7e9c..7bb3df592 100644 --- a/services/core/tests/integration/input_conflicts_public_test.go +++ b/services/core/tests/integration/input_conflicts_public_test.go @@ -44,7 +44,7 @@ func TestSessionInputConflictsAndResultTargetsPostgres(t *testing.T) { {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "conflict-owner", TokenSHA256: runtimedevice.HashCredential(owner), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "conflict-foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), executorURL("https://executor.example"), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", executorURL("https://executor.example"), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/item_order_migration_test.go b/services/core/tests/integration/item_order_migration_test.go index 31b3abf22..f47deb1d8 100644 --- a/services/core/tests/integration/item_order_migration_test.go +++ b/services/core/tests/integration/item_order_migration_test.go @@ -148,11 +148,11 @@ func TestItemOrderMigrationPreservesIndexedHistory(t *testing.T) { t.Fatal(err) } t.Cleanup(migratedPool.Close) - s := New(migratedPool) + s := New(t, migratedPool) if _, err = transitionTurn(ctx, s, tenant, session, turn, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } - if err = executionOwner(t, New(migratedPool)).Sessions.AppendTurnEvents(ctx, tenant, session, turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-upgrade","delta":"continued"}`)}}); err != nil { + if err = executionOwner(t, New(t, migratedPool)).Sessions.AppendTurnEvents(ctx, tenant, session, turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-upgrade","delta":"continued"}`)}}); err != nil { t.Fatal(err) } addedID := items.Identity(turn, "message:after-upgrade") diff --git a/services/core/tests/integration/link_authority_test.go b/services/core/tests/integration/link_authority_test.go index c25e1c117..df0f83bc7 100644 --- a/services/core/tests/integration/link_authority_test.go +++ b/services/core/tests/integration/link_authority_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -96,7 +97,7 @@ func runWorker(t *testing.T, w *execution.Worker) { func startLinkRoute(t *testing.T, s *Store) *sandboxlinktest.Server { t.Helper() rl := relay.New(runtimegateway.NewLinkAuthority(sessionAdapter(s))) - handler, err := publicHandler(t, s, fixtureKeyResolver{}, "codex", storeExecution(t, s), func(d *api.Dependencies) { d.Execution.Links = rl }) + handler, err := publicHandler(t, s, fixtureKeyResolver{}, "codex", func(d *api.Dependencies) { d.Execution.Links = rl }) if err != nil { t.Fatal(err) } @@ -378,7 +379,7 @@ func TestLinkAuthorityReleaseRevokesBeforeSend(t *testing.T) { // TestLinkAuthorityEnrollment serves a self_hosted enrollment with its // executor key until the key is revoked. func TestLinkAuthorityEnrollment(t *testing.T) { - s, _ := NewModelTestStore(t) + s, _ := testStore(t) principal := FixtureExecutorPrincipal(t, s, uuid.NewString()) session, err := s.CreateSession(t.Context(), principal.TenantID, sessions.CreateSession{ Creator: principal.Subject(), Engine: "codex", IdempotencyKey: uuid.NewString(), @@ -494,7 +495,12 @@ func TestRegisteredAgentHostAuthenticates(t *testing.T) { dir := t.TempDir() runtime, credential := uuid.NewString(), uuid.NewString() identity, _ := json.Marshal(map[string]string{"runtime_id": runtime, "credential": credential}) - for name, content := range map[string][]byte{"identity.json": identity, "digests.json": []byte(`["` + strings.Repeat("ab", 32) + `"]`)} { + for name, content := range map[string][]byte{ + "identity.json": identity, + "digests.json": []byte(`["` + strings.Repeat("ab", 32) + `"]`), + "installation.id": []byte(uuid.NewString()), + "credential.key": []byte(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x91}, 32))), + } { if err := os.WriteFile(filepath.Join(dir, name), content, 0o600); err != nil { t.Fatal(err) } @@ -502,6 +508,8 @@ func TestRegisteredAgentHostAuthenticates(t *testing.T) { t.Setenv("OAC_DATABASE_URL", "postgres://core@database/core") t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", filepath.Join(dir, "digests.json")) t.Setenv("OAC_PUBLIC_URL", "https://core.example") + t.Setenv("OAC_INSTALLATION_ID_FILE", filepath.Join(dir, "installation.id")) + t.Setenv("OAC_CREDENTIAL_KEY_FILE", filepath.Join(dir, "credential.key")) t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", filepath.Join(dir, "identity.json")) config, err := processconfig.Load() if err != nil { diff --git a/services/core/tests/integration/list_cursor_public_test.go b/services/core/tests/integration/list_cursor_public_test.go index 2378b7229..0586fc74a 100644 --- a/services/core/tests/integration/list_cursor_public_test.go +++ b/services/core/tests/integration/list_cursor_public_test.go @@ -233,7 +233,7 @@ func TestListCursorErrorsPostgres(t *testing.T) { {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "cursor-owner", TokenSHA256: runtimedevice.HashCredential(owner), TenantID: ownerTenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "cursor-foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: foreignTenant}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/list_query_public_test.go b/services/core/tests/integration/list_query_public_test.go index 8a67221f8..d576f0fe8 100644 --- a/services/core/tests/integration/list_query_public_test.go +++ b/services/core/tests/integration/list_query_public_test.go @@ -41,7 +41,7 @@ func TestListQueryOfficialClientPostgres(t *testing.T) { t.Error(err) } }) - handler, err := publicHandler(t, s, auth, "codex", workerExecution(t, worker)) + handler, err := publicHandler(t, s, auth, "codex", workerExecution(t, worker), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/local_environment_devices_test.go b/services/core/tests/integration/local_environment_devices_test.go index aa479f8b7..27e3a22f5 100644 --- a/services/core/tests/integration/local_environment_devices_test.go +++ b/services/core/tests/integration/local_environment_devices_test.go @@ -34,10 +34,10 @@ func TestEnvironmentDeviceAuthorityAndLifecycle(t *testing.T) { sibling, _ := localEnvironment(t, s, tenant) foreign, _ := localEnvironment(t, s, foreignTenant) digest := runtimedevice.HashCredential(uuid.NewString()) - if _, err := FixtureEnvironmentDevice(t.Context(), pool, foreignTenant, environment.ID, "foreign", digest); !errors.Is(err, sessions.ErrNotFound) { + if _, err := FixtureEnvironmentDevice(t, t.Context(), pool, foreignTenant, environment.ID, "foreign", digest); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("foreign provisioning: %v", err) } - bound, err := FixtureEnvironmentDevice(t.Context(), pool, tenant, environment.ID, "dedicated", digest) + bound, err := FixtureEnvironmentDevice(t, t.Context(), pool, tenant, environment.ID, "dedicated", digest) if err != nil || bound.EnvironmentID != environment.ID { t.Fatalf("provision: %+v %v", bound, err) } @@ -81,7 +81,7 @@ func TestEnvironmentDeviceProvisioningHasOneWinner(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, err := FixtureEnvironmentDevice(t.Context(), pool, tenant, environment.ID, "runtime", runtimedevice.HashCredential(uuid.NewString())) + _, err := FixtureEnvironmentDevice(t, t.Context(), pool, tenant, environment.ID, "runtime", runtimedevice.HashCredential(uuid.NewString())) results <- err }() } @@ -105,7 +105,7 @@ func TestEnvironmentDeviceProvisioningHasOneWinner(t *testing.T) { if err := sessionService(t, s).RevokeDevice(t.Context(), tenant, bound.ID); err != nil { t.Fatal(err) } - if _, err := FixtureEnvironmentDevice(t.Context(), pool, tenant, environment.ID, "replacement", runtimedevice.HashCredential(uuid.NewString())); !errors.Is(err, sessions.ErrDeviceBindingConflict) { + if _, err := FixtureEnvironmentDevice(t, t.Context(), pool, tenant, environment.ID, "replacement", runtimedevice.HashCredential(uuid.NewString())); !errors.Is(err, sessions.ErrDeviceBindingConflict) { t.Fatalf("silent placement replacement: %v", err) } } diff --git a/services/core/tests/integration/managed_fixture_test.go b/services/core/tests/integration/managed_fixture_test.go index 27cac8f77..9248f7d2b 100644 --- a/services/core/tests/integration/managed_fixture_test.go +++ b/services/core/tests/integration/managed_fixture_test.go @@ -13,6 +13,5 @@ import ( func newManagedTestStore(t *testing.T) (*Store, *pgxpool.Pool) { t.Helper() pool := pgtest.OpenIsolated(t, nil) - // Hosted Sessions freeze a model provider, which needs a credential key. - return NewWithCredentialCipher(pool, fixtureCipher), pool + return New(t, pool), pool } diff --git a/services/core/tests/integration/mcp_credential_selection_public_test.go b/services/core/tests/integration/mcp_credential_selection_public_test.go index 20a6b372e..0537b74ba 100644 --- a/services/core/tests/integration/mcp_credential_selection_public_test.go +++ b/services/core/tests/integration/mcp_credential_selection_public_test.go @@ -36,7 +36,7 @@ func TestMCPCredentialSelectionPublicPostgres(t *testing.T) { {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "selection-a", TokenSHA256: runtimedevice.HashCredential(tokenA), TenantID: tenantA}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "selection-b", TokenSHA256: runtimedevice.HashCredential(tokenB), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/model_provider_fixture_test.go b/services/core/tests/integration/model_provider_fixture_test.go index 5dbbf8d7e..6b7e59073 100644 --- a/services/core/tests/integration/model_provider_fixture_test.go +++ b/services/core/tests/integration/model_provider_fixture_test.go @@ -11,8 +11,7 @@ import ( ) // Every Session must freeze a model provider. HTTP fixtures supply one here -// instead of relaxing that check; the store needs a credential key -// (NewModelTestStore). +// instead of relaxing that check. // fixtureDeploymentProvider configures a deployment default for every harness. func fixtureDeploymentProvider() func(*api.Dependencies) { diff --git a/services/core/tests/integration/path_id_semantics_public_test.go b/services/core/tests/integration/path_id_semantics_public_test.go index b2ddb9dc3..72b90c8b9 100644 --- a/services/core/tests/integration/path_id_semantics_public_test.go +++ b/services/core/tests/integration/path_id_semantics_public_test.go @@ -98,7 +98,7 @@ func TestMalformedPathIDsMatchMissingPostgres(t *testing.T) { {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "path-owner", TokenSHA256: runtimedevice.HashCredential(owner), TenantID: ownerTenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "path-foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } @@ -359,28 +359,6 @@ func TestMalformedPathIDsMatchMissingPostgres(t *testing.T) { t.Fatalf("route matrix checked only %d cases", checked) } - // Storage availability checks also run before the lookup of a missing identifier. - h, err = publicHandler(t, New(pool), auth, "codex") - if err != nil { - t.Fatal(err) - } - unconfigured := httptest.NewServer(h) - defer unconfigured.Close() - keyless := pathIDClient{t: t, server: unconfigured} - for _, r := range []route{ - {method: "POST", body: `{"name":"path","auth":{"type":"static_bearer","mcp_server_url":"https://mcp.example/mcp","token":"t"}}`, segments: []string{"/v1/vaults/", vault, "/credentials"}}, - {method: "POST", body: `{"auth":{"type":"static_bearer","token":"t"}}`, segments: []string{"/v1/vaults/", vault, "/credentials/", credential}}, - {method: "POST", body: `{"env":{"PATH_ID":"value"}}`, segments: []string{"/v1/agents/environments/templates/", template}}, - } { - for index := 1; index < len(r.segments); index += 2 { - wantStatus, wantBody := keyless.do(owner, r.method, path(r.segments, index, uuid.NewString()), "application/json", []byte(r.body)) - status, body := keyless.do(owner, r.method, path(r.segments, index, "not-a-uuid"), "application/json", []byte(r.body)) - if status != wantStatus || body != wantBody { - t.Errorf("keyless %s %s: malformed %d %s; missing %d %s", r.method, path(r.segments, index, "{id}"), status, body, wantStatus, wantBody) - } - } - } - // A malformed list cursor answers like any other unresolved cursor of that // list: 404 on lookup-family lists and the family's 400 elsewhere (see // list_cursor_public_test.go). A malformed parent still answers first. diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index 642c96d4a..8ff08a722 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -36,9 +36,11 @@ const testExecutorURL = "wss://core.example/api/v1/agent-daemon/ws" // publicHandler serves s through api.NewHandler, with every area built on s's // database, credential key and placement rules as cmd/server builds it. keys -// authenticate as Project keys and "admin" as the Core key. Metrics, Runtime -// observation and history, and executor connections are strict stand-ins. -// Execution and Sandboxes stay disabled unless configure sets them. +// authenticate as Project keys and "admin" as the Core key. Execution admits +// Sessions and inputs through the Session service without a Worker, so nothing +// runs them. Metrics, Runtime observation and history, executor connections, +// Session archive, workspaces, the Link relay, and deployment changes, reset +// and discovery are strict stand-ins. configure replaces any of them. func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine string, configure ...func(*api.Dependencies)) (http.Handler, error) { t.Helper() admin, err := api.NewDeploymentAuthenticator([]string{runtimedevice.HashCredential("admin")}) @@ -74,8 +76,9 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin if err != nil { return nil, err } + deployments := deploymentService(t, s) deps := api.Dependencies{ - Engine: engine, CoreKeys: admin, InstallationBindings: deploymentService(t, s), + Engine: engine, CoreKeys: admin, InstallationBindings: deployments, Projects: projectService, ProjectsReader: fixtureProjectsReader{Reader: projectStore, keys: keys}, ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore, Vaults: vaultService, VaultsReader: vaultStore, @@ -94,6 +97,8 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin ArtifactsReader: sessionStore, SessionAdmin: sessionStore, Environments: service, EnvironmentsReader: sessionStore, Admin: sessionStore, AdminAudit: audit, WriteAudit: audit, ExecutorConnections: strict, Metrics: strict, RuntimeObservations: strict, RuntimeHistory: strict, + Execution: api.Execution{ExecutorURL: testExecutorURL, SessionAdmission: service, InputAdmission: service, SessionArchive: strict, Workspaces: strict, Links: strict}, + Sandboxes: api.Sandboxes{Deployment: deployments, NodeAllocations: deploymentStore(s), DeploymentChanges: strict, DeploymentReset: strict, ConfigurationDiscovery: strict}, } for _, c := range configure { c(&deps) @@ -138,27 +143,11 @@ func withHarnesses(kinds []string) func(*api.Dependencies) { return func(d *api.Dependencies) { d.Harnesses = kinds } } -// storeExecution admits Sessions and inputs through the Session service on s -// without a Worker, so nothing runs them. -func storeExecution(t testing.TB, s *Store) func(*api.Dependencies) { - return func(d *api.Dependencies) { - service := sessionService(t, s) - d.Execution = &api.Execution{ - ExecutorURL: testExecutorURL, - SessionAdmission: service, - InputAdmission: service, - SessionArchive: strictStandIn{t}, - Workspaces: strictStandIn{t}, - Links: strictStandIn{t}, - } - } -} - // workerExecution runs Sessions through worker. It wires no archive; an // archive request fails the test. func workerExecution(t testing.TB, worker *execution.Worker) func(*api.Dependencies) { return func(d *api.Dependencies) { - d.Execution = &api.Execution{ + d.Execution = api.Execution{ ExecutorURL: testExecutorURL, SessionAdmission: worker, InputAdmission: worker, @@ -170,27 +159,11 @@ func workerExecution(t testing.TB, worker *execution.Worker) func(*api.Dependenc } // executorURL replaces the daemon URL self-hosted Sessions report. It follows -// the option that enables Execution. +// any option that replaces Execution. func executorURL(url string) func(*api.Dependencies) { return func(d *api.Dependencies) { d.Execution.ExecutorURL = url } } -// managedSandboxes enables the managed sandbox deployment on s: its -// administration and node routes and openai_hosted Environments. Deployment -// changes, reset and discovery need the Worker and are strict stand-ins. It -// follows the option that enables Execution. -func managedSandboxes(t testing.TB, s *Store) func(*api.Dependencies) { - return func(d *api.Dependencies) { - d.Sandboxes = &api.Sandboxes{ - Deployment: deploymentService(t, s), - NodeAllocations: deploymentStore(s), - DeploymentChanges: strictStandIn{t}, - DeploymentReset: strictStandIn{t}, - ConfigurationDiscovery: strictStandIn{t}, - } - } -} - // modelProviderDefaults resolves deployment model provider defaults with // resolve instead of the stored deployment configuration. func modelProviderDefaults(resolve func(context.Context, string) (*modelconfiguration.Snapshot, error)) func(*api.Dependencies) { diff --git a/services/core/tests/integration/remote_mcp_credentials_test.go b/services/core/tests/integration/remote_mcp_credentials_test.go index 915d97774..457685cd5 100644 --- a/services/core/tests/integration/remote_mcp_credentials_test.go +++ b/services/core/tests/integration/remote_mcp_credentials_test.go @@ -16,7 +16,7 @@ func TestSelfHostedServiceMCPRejectionDoesNotRequireCredentialDecryption(t *test s, tenant, vault, credential := selfHostedMCPAdmissionFixture(t) switch mode { case "missing key": - s = New(s.pool) + s = New(t, s.pool) case "deleted": _, service, err := fixtureVaults(s) if err != nil { diff --git a/services/core/tests/integration/remote_mcp_test.go b/services/core/tests/integration/remote_mcp_test.go index 7dfa1f7df..a16e39fd3 100644 --- a/services/core/tests/integration/remote_mcp_test.go +++ b/services/core/tests/integration/remote_mcp_test.go @@ -99,7 +99,7 @@ func selfHostedMCPAdmissionFixture(t *testing.T) (*Store, string, vaults.Vault, func selfHostedMCPAdmissionHandler(t *testing.T, s *Store, tenant string) http.Handler { t.Helper() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test", TenantID: tenant, TokenSHA256: runtimedevice.HashCredential("test-token")}}) - handler, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), executorURL("https://executor.example")) + handler, err := publicHandler(t, s, auth, "codex", executorURL("https://executor.example")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/request_body_public_test.go b/services/core/tests/integration/request_body_public_test.go index b61d39393..8fd114e6c 100644 --- a/services/core/tests/integration/request_body_public_test.go +++ b/services/core/tests/integration/request_body_public_test.go @@ -37,7 +37,7 @@ func TestRequestBodyGateRejectsWithoutWritesPostgres(t *testing.T) { }) // No Runtime is connected, so a file write that passes the gate is unavailable. unavailable := func(d *api.Dependencies) { d.Execution.Workspaces = unavailableWorkspaces{strictStandIn{t}} } - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), unavailable, acceptUnavailable(t), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", unavailable, acceptUnavailable(t), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } @@ -173,7 +173,7 @@ func TestRequestBodyGateExcludedRoutesPostgres(t *testing.T) { s := NewWithCredentialCipher(pool, cipher) token, tenant := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "excluded-owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s)) + h, err := publicHandler(t, s, auth, "codex") if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/root_fixture_test.go b/services/core/tests/integration/root_fixture_test.go index 5a2896eec..7af88b538 100644 --- a/services/core/tests/integration/root_fixture_test.go +++ b/services/core/tests/integration/root_fixture_test.go @@ -3,6 +3,7 @@ package integration import ( "context" "fmt" + "testing" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -10,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" @@ -34,18 +36,16 @@ type Store struct { // providers and an HTTPS public URL. var defaultPlacement, _ = placement.NewRules(providers.Builtin(), "https://core.example") -// New is the fixture on pool without a credential key, under defaultPlacement. -func New(pool *pgxpool.Pool) *Store { - pooled := pgunit.NewPool(pool) - return &Store{queries: sqlc.New(pool), pool: pool, pooled: pooled, writer: pooled, placement: defaultPlacement} +// New is the fixture on pool under the shared test credential key and +// defaultPlacement. +func New(t testing.TB, pool *pgxpool.Pool) *Store { + return NewWithCredentialCipher(pool, pgtest.CredentialKey(t)) } -// NewWithCredentialCipher is New with a credential key, so Sessions can freeze -// sealed resources. +// NewWithCredentialCipher is New under another credential key. func NewWithCredentialCipher(pool *pgxpool.Pool, cipher *credentialcrypto.Cipher) *Store { - s := New(pool) - s.credentialCipher = cipher - return s + pooled := pgunit.NewPool(pool) + return &Store{queries: sqlc.New(pool), pool: pool, pooled: pooled, writer: pooled, credentialCipher: cipher, placement: defaultPlacement} } // NewExecution is s with its Session transactions on lease. diff --git a/services/core/tests/integration/sandbox_node_auth_order_http_test.go b/services/core/tests/integration/sandbox_node_auth_order_http_test.go index 4e152b8a3..338390099 100644 --- a/services/core/tests/integration/sandbox_node_auth_order_http_test.go +++ b/services/core/tests/integration/sandbox_node_auth_order_http_test.go @@ -22,7 +22,7 @@ func TestSandboxNodeRoutesAuthenticateBeforeDeploymentState(t *testing.T) { if err != nil { t.Fatal(err) } - handler, err := publicHandler(t, s, nil, "codex", storeKeys(s), storeExecution(t, s), managedSandboxes(t, s), withCoreKeys(admin)) + handler, err := publicHandler(t, s, nil, "codex", storeKeys(s), withCoreKeys(admin)) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/saved_web_search_public_test.go b/services/core/tests/integration/saved_web_search_public_test.go index 9f358f797..48fdcf393 100644 --- a/services/core/tests/integration/saved_web_search_public_test.go +++ b/services/core/tests/integration/saved_web_search_public_test.go @@ -25,7 +25,7 @@ func TestSavedWebSearchPostgres(t *testing.T) { {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "search-owner", TokenSHA256: runtimedevice.HashCredential(owner), TenantID: ownerTenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "search-foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/self_hosted_cancel_public_test.go b/services/core/tests/integration/self_hosted_cancel_public_test.go index bf9551100..d8fb260a2 100644 --- a/services/core/tests/integration/self_hosted_cancel_public_test.go +++ b/services/core/tests/integration/self_hosted_cancel_public_test.go @@ -23,7 +23,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) tenant, foreignTenant := uuid.NewString(), uuid.NewString() token, foreign := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ @@ -178,7 +178,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { awaitRelease() server.Close() s.pool.Close() - s, _ = NewModelTestStore(t) + s, _ = testStore(t) server, stop = serve() settings["base"] = server.URL } diff --git a/services/core/tests/integration/self_hosted_initial_public_test.go b/services/core/tests/integration/self_hosted_initial_public_test.go index 138ef52ac..5eb46ca07 100644 --- a/services/core/tests/integration/self_hosted_initial_public_test.go +++ b/services/core/tests/integration/self_hosted_initial_public_test.go @@ -30,7 +30,7 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) tenant, foreignTenant := uuid.NewString(), uuid.NewString() token, peer, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ @@ -47,7 +47,7 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { } else { // Without a Worker, Core keeps its executor URL but admits nothing. enabled = append(enabled, func(d *api.Dependencies) { - d.Execution = &api.Execution{ + d.Execution = api.Execution{ ExecutorURL: origin, SessionAdmission: unavailableAdmission{}, InputAdmission: unavailableAdmission{}, @@ -156,7 +156,7 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { awaitRelease() server.Close() s.pool.Close() - reopened, _ := NewModelTestStore(t) + reopened, _ := testStore(t) worker, stop = publicInitialWorker(t, reopened) server = serve(reopened, worker) settings["base"], settings["accepted"] = server.URL, accepted diff --git a/services/core/tests/integration/session_agent_filter_public_test.go b/services/core/tests/integration/session_agent_filter_public_test.go index 63dcbfe98..889c58ada 100644 --- a/services/core/tests/integration/session_agent_filter_public_test.go +++ b/services/core/tests/integration/session_agent_filter_public_test.go @@ -17,20 +17,20 @@ func TestSessionAgentFilterOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) token, foreign := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: uuid.NewString()}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } server := httptest.NewServer(h) defer server.Close() - recoveredStore := NewWithCredentialCipher(s.pool, fixtureCipher) - h, err = publicHandler(t, recoveredStore, auth, "codex", storeExecution(t, recoveredStore), fixtureDeploymentProvider()) + recoveredStore := New(t, s.pool) + h, err = publicHandler(t, recoveredStore, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/session_artifacts_test.go b/services/core/tests/integration/session_artifacts_test.go index 6db473180..a998c8da7 100644 --- a/services/core/tests/integration/session_artifacts_test.go +++ b/services/core/tests/integration/session_artifacts_test.go @@ -156,7 +156,7 @@ func testSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T, kind strin t.Fatal(err) } for _, a := range page.Artifacts { - if err := sessionAdapter(New(pool)).ReadSessionArtifact(t.Context(), tenant, session, a.ID, func(meta sessions.Artifact, r io.Reader) error { + if err := sessionAdapter(New(t, pool)).ReadSessionArtifact(t.Context(), tenant, session, a.ID, func(meta sessions.Artifact, r io.Reader) error { if err := sessionAdapter(s).DeleteSessionArtifact(t.Context(), tenant, session, a.ID); err != nil { return err } diff --git a/services/core/tests/integration/session_deletion_lifecycle_public_test.go b/services/core/tests/integration/session_deletion_lifecycle_public_test.go index 440acbe68..112f33ff9 100644 --- a/services/core/tests/integration/session_deletion_lifecycle_public_test.go +++ b/services/core/tests/integration/session_deletion_lifecycle_public_test.go @@ -31,7 +31,7 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "deletion-owner", TokenSHA256: runtimedevice.HashCredential(owner), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "deletion-foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), executorURL("https://executor.example")) + h, err := publicHandler(t, s, auth, "codex", executorURL("https://executor.example")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/session_deletion_public_test.go b/services/core/tests/integration/session_deletion_public_test.go index 4856f2ea8..4ecc9cabc 100644 --- a/services/core/tests/integration/session_deletion_public_test.go +++ b/services/core/tests/integration/session_deletion_public_test.go @@ -17,20 +17,20 @@ func TestSessionDeletionOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) token, foreign := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: uuid.NewString()}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } server := httptest.NewServer(h) defer server.Close() - recoveredStore := NewWithCredentialCipher(s.pool, fixtureCipher) - h, err = publicHandler(t, recoveredStore, auth, "codex", storeExecution(t, recoveredStore), fixtureDeploymentProvider()) + recoveredStore := New(t, s.pool) + h, err = publicHandler(t, recoveredStore, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/session_deletion_test.go b/services/core/tests/integration/session_deletion_test.go index 724ad4555..f23b20593 100644 --- a/services/core/tests/integration/session_deletion_test.go +++ b/services/core/tests/integration/session_deletion_test.go @@ -116,7 +116,7 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { t.Fatal(err) } marker := sessionDeletedAt(t, pool, session.ID) - fresh := New(pool) + fresh := New(t, pool) // The owner's repeated deletion confirms again without another write. for _, repeat := range []*Store{s, fresh} { if err := sessionService(t, repeat).DeleteSession(ctx, sessions.DeleteSessionCommand{TenantID: tenant, SessionID: session.ID}); err != nil { @@ -282,7 +282,7 @@ func TestSessionDeletionRacesAdmissionUnderSessionLock(t *testing.T) { t.Fatal(err) } t.Cleanup(instrumented.Close) - return New(instrumented) + return New(t, instrumented) } for _, kind := range admissions { t.Run(kind.name+"/admission-first", func(t *testing.T) { @@ -339,7 +339,7 @@ func TestSessionDeletionRacesAdmissionUnderSessionLock(t *testing.T) { t.Run(kind.name+"/concurrent", func(t *testing.T) { for range 8 { tenant, session := kind.setup(t, plain) - other := New(pool) + other := New(t, pool) start := make(chan struct{}) results := make(chan error, 2) go func() { diff --git a/services/core/tests/integration/session_events_test.go b/services/core/tests/integration/session_events_test.go index 210b755ab..5a14b6aa1 100644 --- a/services/core/tests/integration/session_events_test.go +++ b/services/core/tests/integration/session_events_test.go @@ -102,7 +102,7 @@ func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { var all []sessions.SessionChange cursor := int64(0) for { - page, err := sessionAdapter(New(pool)).ListSessionEvents(ctx, tenant, session.ID, cursor) + page, err := sessionAdapter(New(t, pool)).ListSessionEvents(ctx, tenant, session.ID, cursor) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/session_execution_configuration_test.go b/services/core/tests/integration/session_execution_configuration_test.go index 7a5179958..183641905 100644 --- a/services/core/tests/integration/session_execution_configuration_test.go +++ b/services/core/tests/integration/session_execution_configuration_test.go @@ -60,7 +60,7 @@ func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testi t.Fatal(err) } // A reader without the encryption key can use the safe snapshot after restart. - reader := New(pool) + reader := New(t, pool) frozen, err := sessionAdapter(reader).GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) if err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/session_initial_public_test.go b/services/core/tests/integration/session_initial_public_test.go index 3b8162891..bdf78a78a 100644 --- a/services/core/tests/integration/session_initial_public_test.go +++ b/services/core/tests/integration/session_initial_public_test.go @@ -29,7 +29,7 @@ func TestInitialSessionInputOfficialClient(t *testing.T) { t.Error(err) } }) - handler, err := publicHandler(t, s, auth, "codex", workerExecution(t, worker)) + handler, err := publicHandler(t, s, auth, "codex", workerExecution(t, worker), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/session_model_execution_http_test.go b/services/core/tests/integration/session_model_execution_http_test.go index cdbfe3794..1a88e08f9 100644 --- a/services/core/tests/integration/session_model_execution_http_test.go +++ b/services/core/tests/integration/session_model_execution_http_test.go @@ -18,7 +18,7 @@ func TestModelExecutionHTTPWriteOnlyAndStrictAdmission(t *testing.T) { st := NewWithCredentialCipher(pool, cipher) tenant, token := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "catalog-test", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) - handler, err := publicHandler(t, st, auth, "codex", storeExecution(t, st), managedSandboxes(t, st)) + handler, err := publicHandler(t, st, auth, "codex") if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/session_reads_fixture_test.go b/services/core/tests/integration/session_reads_fixture_test.go index 6dbc04a1b..3aa8e060e 100644 --- a/services/core/tests/integration/session_reads_fixture_test.go +++ b/services/core/tests/integration/session_reads_fixture_test.go @@ -2,6 +2,7 @@ package integration import ( "context" + "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -14,8 +15,8 @@ import ( // FixtureEnvironmentDevice provisions the dedicated Runtime device of the // tenant's hosted Environment on pool through the procedure the managed // Runtime allocation runs, without the allocation. -func FixtureEnvironmentDevice(ctx context.Context, pool *pgxpool.Pool, tenant, environment, name, credentialHash string) (sessions.ExecutionDevice, error) { - s := New(pool) +func FixtureEnvironmentDevice(t testing.TB, ctx context.Context, pool *pgxpool.Pool, tenant, environment, name, credentialHash string) (sessions.ExecutionDevice, error) { + s := New(t, pool) current, err := sessionAdapter(s).GetEnvironment(ctx, tenant, environment) if err != nil { return sessions.ExecutionDevice{}, err diff --git a/services/core/tests/integration/session_reference_retry_public_test.go b/services/core/tests/integration/session_reference_retry_public_test.go index 1f4f6c44e..a6497e8e6 100644 --- a/services/core/tests/integration/session_reference_retry_public_test.go +++ b/services/core/tests/integration/session_reference_retry_public_test.go @@ -20,7 +20,7 @@ func TestSavedReferenceRetryOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, _ := NewModelTestStore(t) + s, _ := testStore(t) tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}}) worker := startWorker(t, t.Context(), s, &execution.Dispatcher{}) @@ -37,7 +37,7 @@ func TestSavedReferenceRetryOfficialClient(t *testing.T) { } server := httptest.NewServer(handler) defer server.Close() - recovered, err := publicHandler(t, NewWithCredentialCipher(s.pool, fixtureCipher), auth, "codex", fixtureDeploymentProvider()) + recovered, err := publicHandler(t, New(t, s.pool), auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/sessions_test.go b/services/core/tests/integration/sessions_test.go index 9e945672c..d222712f4 100644 --- a/services/core/tests/integration/sessions_test.go +++ b/services/core/tests/integration/sessions_test.go @@ -19,7 +19,7 @@ import ( func testStore(t *testing.T) (*Store, *pgxpool.Pool) { t.Helper() pool := pgtest.Open(t) - return New(pool), pool + return New(t, pool), pool } // sessionAdapter is the Session adapter on s's database with s's credential diff --git a/services/core/tests/integration/stream_authority_http_test.go b/services/core/tests/integration/stream_authority_http_test.go index 3ca7984c7..6c7ebde70 100644 --- a/services/core/tests/integration/stream_authority_http_test.go +++ b/services/core/tests/integration/stream_authority_http_test.go @@ -42,7 +42,7 @@ func TestLiveStreamClosesAfterKeyRevocationOrProjectArchive(t *testing.T) { if err != nil { t.Fatal(err) } - h, err := publicHandler(t, s, nil, "codex", storeKeys(s), storeExecution(t, s)) + h, err := publicHandler(t, s, nil, "codex", storeKeys(s)) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/subagent_resources_test.go b/services/core/tests/integration/subagent_resources_test.go index 474f4cb6e..1cfbf33c8 100644 --- a/services/core/tests/integration/subagent_resources_test.go +++ b/services/core/tests/integration/subagent_resources_test.go @@ -146,7 +146,7 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { t.Fatal(value, err) } appendFacts(subagentFact(proto.TypeSubagentLifecycle, resumed), subagentFact(proto.TypeSubagentLifecycle, resumed)) - reopened := sessionAdapter(New(pool)) + reopened := sessionAdapter(New(t, pool)) value, err = reopened.GetSubagent(ctx, tenant, session.ID, child.ID) if err != nil || value.Status != "active" || value.ClosedAt != nil || value.OpenedAt != 100 { t.Fatal(value, err) diff --git a/services/core/tests/integration/subagent_visibility_public_test.go b/services/core/tests/integration/subagent_visibility_public_test.go index 2cec8f53b..26cfe2d29 100644 --- a/services/core/tests/integration/subagent_visibility_public_test.go +++ b/services/core/tests/integration/subagent_visibility_public_test.go @@ -70,13 +70,13 @@ func subagentFixture(kind string, value any) sessions.ExecutionEvent { // routes with the Session's Agent ID, Subagent lists use the common envelope and // child Item lists clamp their limit. Tenant B sees none of it. func TestSubagentVisibilityPublic(t *testing.T) { - s, _ := NewModelTestStore(t) + s, _ := testStore(t) tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{ {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - handler, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + handler, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/template_composition_public_test.go b/services/core/tests/integration/template_composition_public_test.go index a958e47ac..1a483d6fb 100644 --- a/services/core/tests/integration/template_composition_public_test.go +++ b/services/core/tests/integration/template_composition_public_test.go @@ -39,7 +39,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { serve := func(current *Store) *httptest.Server { t.Helper() // Hosted admission and freezing use the real Store; no Runtime or model runs. - h, err := publicHandler(t, current, auth, "codex", storeExecution(t, current), managedSandboxes(t, current), fixtureDeploymentProvider()) + h, err := publicHandler(t, current, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/template_null_selection_public_test.go b/services/core/tests/integration/template_null_selection_public_test.go index 368e7f5e5..f35cfecfd 100644 --- a/services/core/tests/integration/template_null_selection_public_test.go +++ b/services/core/tests/integration/template_null_selection_public_test.go @@ -40,7 +40,7 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { }) serve := func(current *Store) *httptest.Server { t.Helper() - h, err := publicHandler(t, current, auth, "codex", storeExecution(t, current), managedSandboxes(t, current), fixtureDeploymentProvider()) + h, err := publicHandler(t, current, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/token_usage_integration_test.go b/services/core/tests/integration/token_usage_integration_test.go index 6946e3899..db633f7b3 100644 --- a/services/core/tests/integration/token_usage_integration_test.go +++ b/services/core/tests/integration/token_usage_integration_test.go @@ -83,7 +83,7 @@ func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { t.Fatal(err) } defer restored.Close() - fresh := New(restored) + fresh := New(t, restored) got, err := sessionAdapter(fresh).GetSession(ctx, tenant, session.ID) if err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/unified_model_configuration_http_test.go b/services/core/tests/integration/unified_model_configuration_http_test.go index 69237eaed..7836effd1 100644 --- a/services/core/tests/integration/unified_model_configuration_http_test.go +++ b/services/core/tests/integration/unified_model_configuration_http_test.go @@ -21,7 +21,7 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { if err != nil { t.Fatal(err) } - handler, err := publicHandler(t, st, auth, "codex", storeExecution(t, st), managedSandboxes(t, st), withCoreKeys(admin), withHarnesses([]string{"codex", "claude_sdk"})) + handler, err := publicHandler(t, st, auth, "codex", withCoreKeys(admin), withHarnesses([]string{"codex", "claude_sdk"})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/unstorable_text_public_test.go b/services/core/tests/integration/unstorable_text_public_test.go index 56d4b895a..f937bd8b9 100644 --- a/services/core/tests/integration/unstorable_text_public_test.go +++ b/services/core/tests/integration/unstorable_text_public_test.go @@ -27,7 +27,7 @@ func TestUnstorableTextRejectsWithoutWritesPostgres(t *testing.T) { s := NewWithCredentialCipher(pool, cipher) token := uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "nul-owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: uuid.NewString()}}) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/vaults_fixture_test.go b/services/core/tests/integration/vaults_fixture_test.go index 5249ca3ea..f547fea5a 100644 --- a/services/core/tests/integration/vaults_fixture_test.go +++ b/services/core/tests/integration/vaults_fixture_test.go @@ -8,7 +8,6 @@ import ( ) // fixtureVaults builds the Vault adapter and service on s, as cmd/server does. -// A keyless s leaves the operations that need no credential key available. func fixtureVaults(s *Store) (*vaultpg.Store, *vaults.Service, error) { refresher, err := oauthrefresh.NewClient(nil) if err != nil { diff --git a/services/core/tests/integration/whitespace_input_public_test.go b/services/core/tests/integration/whitespace_input_public_test.go index df4e51c89..1cecadcf9 100644 --- a/services/core/tests/integration/whitespace_input_public_test.go +++ b/services/core/tests/integration/whitespace_input_public_test.go @@ -22,7 +22,7 @@ func TestWhitespaceInputStoredVerbatimPostgres(t *testing.T) { s, _ := newManagedTestStore(t) token := uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "whitespace-owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: uuid.NewString()}}) - h, err := publicHandler(t, s, auth, "codex", storeExecution(t, s), fixtureDeploymentProvider()) + h, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/worker_input_race_test.go b/services/core/tests/integration/worker_input_race_test.go index 0b68bc067..b6d926ab5 100644 --- a/services/core/tests/integration/worker_input_race_test.go +++ b/services/core/tests/integration/worker_input_race_test.go @@ -59,7 +59,7 @@ func TestWorkerInputReadSkipsConcurrentlyCancelledCandidate(t *testing.T) { defer instrumented.Close() ctx, cancel := context.WithCancel(t.Context()) defer cancel() - worker := startWorker(t, ctx, NewWithCredentialCipher(instrumented, fixtureCipher), h.d) + worker := startWorker(t, ctx, New(t, instrumented), h.d) done := make(chan error, 1) go func() { done <- worker.Run(ctx) }() defer func() { diff --git a/services/core/tests/integration/worker_wakeup_test.go b/services/core/tests/integration/worker_wakeup_test.go index 3f4543fb6..5ab16df8d 100644 --- a/services/core/tests/integration/worker_wakeup_test.go +++ b/services/core/tests/integration/worker_wakeup_test.go @@ -45,7 +45,7 @@ func TestWorkerSchedulerCommittedAdmissionWakesBeforeMaintenance(t *testing.T) { defer instrumented.Close() ctx, cancel := context.WithCancel(t.Context()) defer cancel() - worker := startWorker(t, ctx, NewWithCredentialCipher(instrumented, fixtureCipher), h.d) + worker := startWorker(t, ctx, New(t, instrumented), h.d) done := make(chan error, 1) started := false defer func() { diff --git a/services/core/tests/official_client.py b/services/core/tests/official_client.py index 3b40c231d..0d98aad42 100644 --- a/services/core/tests/official_client.py +++ b/services/core/tests/official_client.py @@ -23,12 +23,12 @@ from official_agents import verify_agents from official_vaults import verify_vaults, verify_vault_recovery from official_vault_list import verify_vault_list, verify_vault_list_recovery -from official_credentials import verify_credentials, verify_credential_recovery, verify_credential_storage_disabled +from official_credentials import verify_credentials, verify_credential_recovery, create_old_key_credential, verify_old_key_credential from official_credential_list import verify_credential_list, verify_credential_list_recovery from official_mcp_credentials import verify_mcp_credentials, verify_mcp_credential_recovery from official_credential_rotation import verify_credential_rotation, verify_rotation_recovery -from official_credential_delete import verify_credential_deletion, verify_credential_deletion_recovery, verify_keyless_credential_deletion -from official_vault_delete import verify_vault_deletion, verify_vault_deletion_recovery, verify_keyless_vault_deletion +from official_credential_delete import verify_credential_deletion, verify_credential_deletion_recovery, verify_key_lost_credential_deletion +from official_vault_delete import verify_vault_deletion, verify_vault_deletion_recovery, verify_key_lost_vault_deletion from official_agent_list import verify_agent_list from official_http_routing import verify_http_routing from official_agent_references import verify_agent_references @@ -80,12 +80,16 @@ def project_bindings(directory): core_key_digests.write_text(json.dumps([hashlib.sha256(admin_token.encode()).hexdigest()])) credential_key = Path(directory) / "credential-key.txt" credential_key.touch(mode=0o600) - credential_key_value = base64.b64encode(secrets.token_bytes(32)).decode() - credential_key.write_text(credential_key_value + "\n") + credential_key_values = [base64.b64encode(secrets.token_bytes(32)).decode()] + credential_key.write_text(credential_key_values[0] + "\n") + # The database records the first installation ID it sees, so every run uses this one. + installation_id = Path(directory) / "installation.id" + installation_id.touch(mode=0o600) + installation_id.write_text("3f8e2c71-5b0d-4e6a-9c47-1d2a8b6f0e53\n") env = dict(os.environ, OAC_DATABASE_URL=dsn, OAC_CORE_KEY_DIGESTS_FILE=str(core_key_digests), OAC_ADDR=f"127.0.0.1:{port}", OAC_DEFAULT_HARNESS="codex") env["OAC_CREDENTIAL_KEY_FILE"] = str(credential_key) - # Enable the real Worker/gateway admission path without connecting a daemon. - # Synthetic fixture inputs remain queued; this is not live model acceptance. + env["OAC_INSTALLATION_ID_FILE"] = str(installation_id) + # No daemon connects: synthetic fixture inputs remain queued; this is not live model acceptance. env["OAC_PUBLIC_URL"] = f"http://127.0.0.1:{port}" agent_host_identity = Path(directory) / "agent-host.json" agent_host_identity.touch(mode=0o600) @@ -338,24 +342,27 @@ def issue_key(project_id, name): process = start() with client(tokens[0]) as a: verify_mcp_credential_recovery(a, claude_credentials) + old_key_credential = create_old_key_credential(a, credential_canary) process.terminate() process.wait(timeout=15) env["OAC_DEFAULT_HARNESS"] = "codex" - env.pop("OAC_CREDENTIAL_KEY_FILE") + # The operator lost the key: Core restarts under a new one. + credential_key_values.append(base64.b64encode(secrets.token_bytes(32)).decode()) + credential_key.write_text(credential_key_values[-1] + "\n") process = start() - with client(tokens[0]) as without_key, client(tokens[1]) as other, client(peer_key) as peer: - verify_credential_storage_disabled(without_key, saved_credentials[0][0], credential_canary, expect_error) - verify_keyless_credential_deletion(without_key, credential_deletion, expect_error) - verify_keyless_vault_deletion(without_key, vault_deletion, expect_error) - verify_credential_list_recovery(without_key, other, peer, listed_credentials, - credential_canary, phase="restart without the storage key") + with client(tokens[0]) as a, client(tokens[1]) as other, client(peer_key) as peer: + verify_old_key_credential(a, old_key_credential, credential_canary, expect_error) + verify_key_lost_credential_deletion(a, credential_deletion, expect_error) + verify_key_lost_vault_deletion(a, vault_deletion, expect_error) + verify_credential_list_recovery(a, other, peer, listed_credentials, + credential_canary, phase="restart under a replaced credential key") print("Caller principal: SDK/raw HTTP scope checks, shared Project access and persistent scope recovery passed.") print("Official Turn client: lifecycle, Agent identity, safe errors, restart recovery, pagination and tenant/Session isolation passed.") print("Official Go client: creation/retries, retrieval, bidirectional pagination and tenant isolation passed.") print("Official client: upstream and generated response schemas, persistence/restart, retries, pagination, tenant isolation and explicit unsupported options passed.") finally: finish_server(process, log, [admin_token, *tokens, credential_canary, - credential_key_value], sys.exc_info()[1]) + *credential_key_values], sys.exc_info()[1]) if __name__ == "__main__": diff --git a/services/core/tests/official_credential_delete.py b/services/core/tests/official_credential_delete.py index aef43b2d5..2d7a01b4d 100644 --- a/services/core/tests/official_credential_delete.py +++ b/services/core/tests/official_credential_delete.py @@ -12,7 +12,7 @@ def verify_credential_deletion(client, other, invalid, peer, canary, expect_erro credentials = client.beta.agents.vaults.credentials auth = {"type": "static_bearer", "mcp_server_url": "https://example.invalid/delete", "token": canary} values = [credentials.create(vault.id, name=name, auth=auth) - for name in ["SDK target", "HTTP target", "Keyless target", "Retained sibling"]] + for name in ["SDK target", "HTTP target", "Key-lost target", "Retained sibling"]] foreign = other.beta.agents.vaults.credentials.create(foreign_vault.id, name="Foreign", auth=auth) headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"} endpoint = str(client.base_url).rstrip("/") + "/vaults/" + vault.id + "/credentials" @@ -69,7 +69,7 @@ def verify_credential_deletion_recovery(client, other, saved, expect_error): print("Credential deletion: absent resources and unaffected siblings survived API restart.") -def verify_keyless_credential_deletion(client, saved, expect_error): +def verify_key_lost_credential_deletion(client, saved, expect_error): values, _ = saved target, sibling = values[2:] credentials = client.beta.agents.vaults.credentials @@ -78,4 +78,4 @@ def verify_keyless_credential_deletion(client, saved, expect_error): expect_error(NotFoundError, lambda: credentials.retrieve(target.id, vault_id=target.vault_id)) assert credentials.retrieve(sibling.id, vault_id=sibling.vault_id) == sibling assert list(credentials.list(target.vault_id)) == [sibling] - print("Credential deletion: no storage key required; safe sibling metadata remains available.") + print("Credential deletion: works under a replaced key; safe sibling metadata remains available.") diff --git a/services/core/tests/official_credentials.py b/services/core/tests/official_credentials.py index a3f1efc47..7fadff761 100644 --- a/services/core/tests/official_credentials.py +++ b/services/core/tests/official_credentials.py @@ -147,27 +147,22 @@ def verify_credential_recovery(client, other, peer, saved): print("Static credentials: exact SDK/raw metadata and shared-project reads survived the service restart.") -def verify_credential_storage_disabled(client, value, canary, expect_error): +def create_old_key_credential(client, canary): + vault = client.beta.agents.vaults.create(name="Sealed under the old key") + return client.beta.agents.vaults.credentials.create(vault.id, name="Old key", auth={ + "type": "mcp_oauth", "mcp_server_url": "https://example.invalid/old-key", "access_token": canary}) + + +def verify_old_key_credential(client, value, canary, expect_error): credentials = client.beta.agents.vaults.credentials assert credentials.retrieve(value.id, vault_id=value.vault_id) == value - request = {"name": "Disabled storage", "auth": {**value.auth.to_dict(), "token": canary}} - error = expect_error(InternalServerError, lambda: credentials.create(value.vault_id, **request)) - assert error.status_code == 503 and error.body["code"] == "credential_storage_unavailable" + error = expect_error(InternalServerError, lambda: credentials.update( + value.id, vault_id=value.vault_id, auth={"type": "mcp_oauth", "access_token": canary + "replacement"})) + assert error.status_code == 500 and error.body["code"] == "internal_error" assert canary not in error.response.text - with httpx2.Client(trust_env=False, timeout=10) as raw: - response = raw.post(str(client.base_url).rstrip("/") + "/vaults/" + value.vault_id + "/credentials", - headers={"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"}, - json=request) - assert response.status_code == 503 and canary not in response.text - replacement = {"auth": {"type": "static_bearer", "token": canary + "replacement"}} - error = expect_error(InternalServerError, lambda: credentials.update(value.id, vault_id=value.vault_id, **replacement)) - assert error.status_code == 503 and error.body["code"] == "credential_storage_unavailable" - assert canary not in error.response.text - response = raw.post(str(client.base_url).rstrip("/") + "/vaults/" + value.vault_id + "/credentials/" + value.id, - headers={"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"}, - json=replacement) - assert response.status_code == 503 and canary not in response.text - assert credentials.retrieve(value.id, vault_id=value.vault_id) == value - vault = client.beta.agents.vaults.create(name="Non-secret resource without credential key") - assert client.beta.agents.vaults.retrieve(vault.id) == vault - print("Static credentials: absent key rejects SDK/raw writes while safe reads and Vault creation remain available.") + assert credentials.retrieve(value.id, vault_id=value.vault_id) == value + created = credentials.create(value.vault_id, name="New key", auth={ + "type": "static_bearer", "mcp_server_url": "https://example.invalid/new-key", "token": canary}) + assert canary not in created.model_dump_json() + assert credentials.delete(created.id, vault_id=created.vault_id).deleted + print("Credential key replacement: an old secret fails closed without leaking while new secrets are stored.") diff --git a/services/core/tests/official_vault_delete.py b/services/core/tests/official_vault_delete.py index b69154cfe..301a7d478 100644 --- a/services/core/tests/official_vault_delete.py +++ b/services/core/tests/official_vault_delete.py @@ -1,4 +1,4 @@ -"""Pinned Vault deletion, child removal, scoped retries and keyless recovery.""" +"""Pinned Vault deletion, child removal, scoped retries and deletion under a replaced key.""" import uuid @@ -8,11 +8,11 @@ def verify_vault_deletion(client, other, invalid, peer, canary, expect_error): vaults = client.beta.agents.vaults - values = [vaults.create(name=name) for name in ["Cascade target", "Empty HTTP target", "Keyless target", "Retained Vault"]] + values = [vaults.create(name=name) for name in ["Cascade target", "Empty HTTP target", "Key-lost target", "Retained Vault"]] foreign = other.beta.agents.vaults.create(name="Foreign Vault deletion") auth = {"type": "static_bearer", "mcp_server_url": "https://example.invalid/vault-delete", "token": canary} children = [vaults.credentials.create(values[0].id, name=str(i), auth=auth) for i in range(3)] - keyless = vaults.credentials.create(values[2].id, name="Keyless child", auth=auth) + key_lost = vaults.credentials.create(values[2].id, name="Key-lost child", auth=auth) retained = vaults.credentials.create(values[3].id, name="Retained child", auth=auth) foreign_child = other.beta.agents.vaults.credentials.create(foreign.id, name="Foreign child", auth=auth) spec = {"input": "Verify vault delete fixture admission.", "agent": {"model": "requested-model"}, "environment": {"type": "none"}, "vault_ids": [values[0].id, values[3].id]} @@ -73,11 +73,11 @@ def verify_vault_deletion(client, other, invalid, peer, canary, expect_error): assert other.beta.agents.vaults.retrieve(foreign.id) == foreign assert other.beta.agents.vaults.credentials.retrieve(foreign_child.id, vault_id=foreign.id) == foreign_child print("Vault deletion: SDK/raw confirmation, cascade visibility, scope and retained Session identity passed.") - return values, keyless, retained, foreign, foreign_child, spec, headers, session + return values, key_lost, retained, foreign, foreign_child, spec, headers, session def verify_vault_deletion_recovery(client, other, saved, expect_error): - values, keyless, retained, foreign, foreign_child, spec, headers, session = saved + values, key_lost, retained, foreign, foreign_child, spec, headers, session = saved vaults = client.beta.agents.vaults for target in values[:2]: expect_error(NotFoundError, lambda: vaults.retrieve(target.id)) @@ -85,7 +85,7 @@ def verify_vault_deletion_recovery(client, other, saved, expect_error): expect_error(NotFoundError, lambda: vaults.credentials.list(target.id)) for value in values[2:]: assert vaults.retrieve(value.id) == value - for child in [keyless, retained]: + for child in [key_lost, retained]: assert vaults.credentials.retrieve(child.id, vault_id=child.vault_id) == child assert other.beta.agents.vaults.retrieve(foreign.id) == foreign assert other.beta.agents.vaults.credentials.retrieve(foreign_child.id, vault_id=foreign.id) == foreign_child @@ -95,7 +95,7 @@ def verify_vault_deletion_recovery(client, other, saved, expect_error): print("Vault deletion: absent parents/children, unaffected resources and Session retry survived API restart.") -def verify_keyless_vault_deletion(client, saved, expect_error): +def verify_key_lost_vault_deletion(client, saved, expect_error): values, child, retained, *_ = saved vaults, target = client.beta.agents.vaults, values[2] assert vaults.delete(target.id).to_dict() == {"id": target.id, "deleted": True, "object": "vault.deleted"} @@ -104,4 +104,4 @@ def verify_keyless_vault_deletion(client, saved, expect_error): expect_error(NotFoundError, lambda: vaults.credentials.list(target.id)) assert vaults.retrieve(values[3].id) == values[3] assert vaults.credentials.retrieve(retained.id, vault_id=retained.vault_id) == retained - print("Vault deletion: keyless cascade removed stored children while another Vault remained usable.") + print("Vault deletion: the cascade under a replaced key removed stored children while another Vault remained usable.")