diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 86940ae04..5bb36771a 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -408,7 +408,7 @@ Signing in and the console tour share one frame: a dark stage on the left (alway The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open System; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open System brings the Default model provider section to the top of the page body and focuses the default harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in System, which leads to System. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. +Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (reported by Core's installation read) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in System, which leads to System. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B) and the E2B template. Core selects the matching Runtime release on save. A change keeps the saved size while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Configuration generations A single rollout row opens a details dialog for Core's target generation, previous-generation sandboxes and rollout counts. Poll rapidly only while Core reports preparing, or while the independent reset is active. Settled is preparation state, not proof that all nodes are ready or all older Sessions have ended. Retained old resources alone must not keep rapid polling alive. Render failed, update-required and unknown target states distinctly. Keep offline/live-provider status separate from a node's durable serving-generation pin; the pin alone never means the node is online or ready. Node detail shows the serving generation and target preparation; allocation detail shows the owned configuration generation. Do not calculate rollout completion from these rows or promise immediate placement on the target. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index b4ee933a1..6eab10e62 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -24,18 +24,18 @@ The console runs beside the administrator's own Core, with execution, files and - Paired console (`services/web`): the administrator signs in with the deployment's [Core key](../../docs/getting-started/operations.md#core-key). Sign-in shows a copyable Docker Compose command to read the key on the Core host, with a reminder to substitute a custom installation directory. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards the Web API (`/core/v1/**`, including sandbox administration under `/core/v1/sandbox/**`). The console never calls `/v1`. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. -- `/console/config` reports the node installer (`node_installer`, `node_installer_sha256`), offered only with a 64-hex digest. Native self-hosted installation does not depend on this endpoint. It also lists the providers it has node files for (`node_artifacts`); without the deployment's provider, Add node says so and issues no command. Signing in grants administration, sandbox administration included. +- Core's installation read reports the matching node installer digest and available provider releases. Add node uses that snapshot's revision and digest; without the deployment's provider files it says so and issues no command. Signing in grants administration, sandbox administration included. - Chinese and English UI; light and dark themes; reduced motion honored. ## Information Architecture - **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. +- **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 and size, then asks for a deliberate save; own-machine setup continues to Add node. - 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. +- **Online sandbox configuration** changes the same backend's resources or E2B template, with Core selecting the matching Runtime release without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. - **E2B deployments** have no machines: Nodes offers a link to System's sandbox configuration. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no node column or Add node action; a sandbox's dialog adds its disk use. - **microsandbox** suspends idle sandboxes into snapshots, so its nodes show how many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox metrics and Overview; a node's allocations show how long each has been suspended and about when Core reclaims it. Docker never suspends and shows none of it. diff --git a/apps/web/README.md b/apps/web/README.md index d81b9c0ac..9fc25d438 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -23,7 +23,7 @@ OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:18092 pnpm dev:web Open `http://127.0.0.1:4173` and sign in with the fixture-only key `fixture-core-key-3f9a2c71`. -`pnpm dev:web` runs Vite on `127.0.0.1:4173` and proxies `/console`, `/node-install` and `/core/v1` to `OAC_WEB_DEV_PROXY_TARGET` (default `http://127.0.0.1:8091`). Vite reads the setting from the environment or the repository's `.env` file; it never reaches browser code. The target must serve the console routes. `apps/web/e2e/fixture-console.mjs` is a synthetic console service with deterministic data; `AGENTS_FIXTURE_PORT` changes its port (default 18092). +`pnpm dev:web` runs Vite on `127.0.0.1:4173` and proxies `/console`, `/api/v1` and `/core/v1` to `OAC_WEB_DEV_PROXY_TARGET` (default `http://127.0.0.1:8091`). Vite reads the setting from the environment or the repository's `.env` file; it never reaches browser code. The target must serve the console routes. `apps/web/e2e/fixture-console.mjs` is a synthetic console service with deterministic data; `AGENTS_FIXTURE_PORT` changes its port (default 18092). ## Checks diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 1a25f2350..d79241d25 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -12,9 +12,8 @@ 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), 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. + * with a node enrolled with an earlier address), and `installers: "none"` a Core without its node installation payload. `nodeArtifacts` lists the providers Core has + * complete node files for, both by default. */ export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 889a15cf6..7d039bc2b 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -50,6 +50,7 @@ function installation() { // As Core: api_base_url is always public_url followed by /v1; local_only marks a loopback public_url. object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`, local_only: local, source_commit: release.source_commit, + node_installation: state.installers ? { installer_sha256: "a".repeat(64), runtime_releases: Object.fromEntries(state.nodeArtifacts.map((provider) => [provider, provider === "docker" ? release : { source_commit: release.source_commit, artifacts: { microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox.runtime_sha256, firmware_sha256: manifest.microsandbox.firmware_sha256 } }])) } : null, configuration: { settings: [ setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]), @@ -123,7 +124,7 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d deployment: null, // Whether the console has its node installation payload, and so serves both installers. installers, - // The providers whose node files the console serves (/console/config node_artifacts). + // The providers whose releases Core can serve from its installation distribution. nodeArtifacts: artifacts.split(",").filter(Boolean), }; state.deployment = sandbox === "none" ? unconfiguredDeployment() : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(); @@ -138,11 +139,11 @@ function send(response, status, body, headers = {}) { response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers }); response.end(JSON.stringify(body)); } -function error(response, status, message, code = null) { +function error(response, status, message, code = null, param = null) { // Derive `type` as Core's writeError does (services/core/internal/api/errors.go). const type = status >= 500 ? "server_error" : status === 409 ? "conflict_error" : code === "not_found_error" || code === "invalid_beta" ? code : "invalid_request_error"; - send(response, status, { error: { message, type, code, param: null } }); + send(response, status, { error: { message, type, code, param } }); } async function body(request) { const chunks = []; @@ -202,14 +203,6 @@ async function consoleRoute(request, response, url) { if (auth.mode !== "authenticated" || !request.headers.cookie?.includes(SESSION_COOKIE)) return error(response, 401, "Sign in to the console."); return domainRoute(request, response, state, { send, error, body }); } - if (url.pathname === "/console/config") { - // Console assets cover node enrollment only; native self-hosted installation is independent. - const served = state.installers; - return send(response, 200, { - node_installer: served, node_installer_sha256: served ? "a".repeat(64) : "", - node_artifacts: served ? state.nodeArtifacts : [], - }); - } return error(response, 404, "Not found."); } @@ -376,6 +369,7 @@ async function sandboxRoute(request, response, path, url) { const input = await body(request); const initialize = request.method === "POST"; // As Core, before any state check: the address is config.json's public_url and read-only. + if ("runtime" in input) return error(response, 400, "runtime cannot be set here.", "invalid_request_error", "runtime"); if ("core_url" in input) return error(response, 400, "core_url is derived from the installation public URL (public_url in config.json, OAC_PUBLIC_URL for Core) and cannot be set here. Remove it.", "invalid_request_error", "core_url"); if (!Number.isInteger(input.expected_generation) || input.expected_generation < 0) return error(response, 400, "expected_generation is required.", "invalid_request_error"); if (input.expected_generation !== state.deployment.generation) return error(response, 409, "The sandbox configuration changed. Refresh before submitting again.", "sandbox_generation_stale"); @@ -384,7 +378,9 @@ async function sandboxRoute(request, response, path, url) { if (initialize && state.deployment.provider) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); if (!initialize && !state.deployment.provider) return error(response, 409, "The sandbox deployment is not configured.", "sandbox_deployment_conflict"); const e2b = input.provider === "e2b"; - if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); + const runtime = installation().node_installation?.runtime_releases[input.provider]; + if (!e2b && !runtime) return error(response, 400, "This Core has no matching installation distribution for the selected provider.", "invalid_sandbox_configuration", "runtime"); + if (!e2b && !input.resources) return error(response, 400, "resources are required.", "invalid_sandbox_configuration"); // As Core (ErrPublicURLUnreachable): sandboxes reach Core from outside its host, which a loopback public_url cannot serve. if (state.installation === "local") return error(response, 409, "Sandboxes reach Core from outside its host. Set an HTTPS public URL that is not loopback (public_url in config.json, OAC_PUBLIC_URL for Core).", "sandbox_configuration_error"); // Synthetic classifier outcomes only; never persist or echo submitted keys. @@ -396,7 +392,7 @@ async function sandboxRoute(request, response, path, url) { // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; const previous = state.deployment; - const specification = { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }; + const specification = { resources, ...(runtime ? { runtime } : {}) }; const explicitKey = e2b && Object.hasOwn(input, "credential"); const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.configuration.template === previous.configuration?.template); // Omission can be a no-op; every explicit key, including identical bytes, @@ -637,7 +633,6 @@ http.createServer(async (request, response) => { try { if (url.pathname.startsWith("/__fixture/")) return await fixtureRoute(request, response, url); // The console service serves its distribution manifest to anyone, as nodes download it. - if (url.pathname === "/node-install/manifest.json") return send(response, 200, manifest); if (request.headers.authorization) state.violations.push(`Authorization header on ${request.method} ${url.pathname}`); if (url.pathname.startsWith("/console/")) return await consoleRoute(request, response, url); const signedIn = state.auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 165dccda5..76af9a5b1 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -36,7 +36,7 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await expect(field).toHaveValue(/^ \(umask 077\n/); await expect(field).toHaveValue(/\| \$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$installer" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /); // It downloads from, and names as its source, the public URL, not the loopback address this browser uses. - await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' /); + await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/api\/v1\/sandbox-node\/install\/releases\/c0ffee0{34}\/node-install\.pyz' /); await expect(field).toHaveValue(/ --source-url 'https:\/\/core\.example\.com' --core-url 'https:\/\/core\.example\.com' /); await expect(add.getByText("If the command is interrupted or the download stalls, run it again: unfinished downloads restart, and verified files are reused.")).toBeVisible(); await expect(add.getByRole("timer")).toHaveText(/^Expires in (10:00|9:\d\d)$/); @@ -134,10 +134,10 @@ test("issues no command before the installation is read, for a loopback public U await openConsole(page, request, "nodes", { nodeArtifacts: [] }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await page.getByRole("button", { name: "Add node" }).click(); - await expect(add.getByRole("status")).toHaveText("This console has no node files for Docker. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh."); + await expect(add.getByRole("status")).toHaveText("This Core has no node files for Docker. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh."); await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); expect(await writes(request)).toEqual([]); - // Rerunning ./install.sh adds them: reopening reads the console again, without a reload. + // Rerunning ./install.sh adds them: reopening reads Core's installation again, without a reload. await add.getByRole("button", { name: "Close dialog" }).click(); await resetFixture(request); await page.getByRole("button", { name: "Add node" }).click(); @@ -153,7 +153,7 @@ test("removes a node after confirmation", async ({ page, request }) => { await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); // Removing the Core record leaves the system service for root/sudo to uninstall on its host. const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); - await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$installer" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/api\/v1\/sandbox-node\/install\/releases\/c0ffee0{34}\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$installer" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); await expect(cleanup.getByText("Installed without sudo?")).toHaveCount(0); await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveCount(0); // Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone. @@ -182,7 +182,7 @@ test("gives the host's uninstall command even when the installation must be read await expect(cleanup.getByRole("alert")).toContainText("The installation couldn't be read, so no command can be issued."); await page.unroute("**/core/v1/installation"); await cleanup.getByRole("button", { name: "Try again" }).click(); - await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/'https:\/\/core\.example\.com\/node-install\/node-install\.pyz'/); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/'https:\/\/core\.example\.com\/api\/v1\/sandbox-node\/install\/releases\/c0ffee0{34}\/node-install\.pyz'/); }); test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { @@ -205,7 +205,7 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist await add.getByRole("button", { name: "Close dialog" }).click(); await page.getByRole("button", { name: "System", exact: true }).click(); await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); - // The saved specification carries the Runtime read from the console's manifest. + // The saved specification carries the Runtime selected by Core. await page.getByRole("button", { name: "Configuration details", exact: true }).click(); await expect(page.locator(".help-tip-popover").getByText("c0ffee000000", { exact: true })).toBeVisible(); }); @@ -238,8 +238,8 @@ test("preselects microsandbox and asks once before switching to Docker", async ( await expect(confirm).toHaveCount(0); await page.getByRole("button", { name: /^Standard/ }).click(); await page.getByRole("button", { name: "Advanced settings", exact: true }).click(); - await expect(page.getByLabel("Image ID", { exact: true })).toHaveValue(`sha256:${"1".repeat(64)}`); - await expect(page.getByLabel("Image manifest digest", { exact: true })).toHaveValue(`sha256:${"2".repeat(64)}`); + await expect(page.getByLabel("Image ID", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Image manifest digest", { exact: true })).toHaveCount(0); await expect(page.getByLabel("Firmware SHA-256", { exact: true })).toHaveCount(0); await expect(page.getByLabel("microsandbox reference", { exact: true })).toHaveCount(0); }); @@ -249,7 +249,7 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, page.on("request", (sent) => { if (sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/deployment")) submitted = sent.postDataJSON() as Record; }); - await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await openConsole(page, request, "system?id=sandbox", { sandbox: "none", installers: "none" }); await page.getByRole("button", { name: "E2B cloud" }).click(); await expect(page.getByLabel("E2B provider")).toHaveValue("sandbase"); await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://sandbox.sandbase.ai"); @@ -305,7 +305,7 @@ test("shows the retained E2B build while a replacement key is checked", async ({ await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled(); }); -test("edits only the saved backend, preserving a custom size and Runtime", async ({ page, request }) => { +test("edits only the saved backend, preserving a custom size and using Core’s Runtime", async ({ page, request }) => { const runtime = { source_commit: "0".repeat(40), artifacts: { image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}` } }; const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime }; let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, @@ -326,7 +326,8 @@ test("edits only the saved backend, preserving a custom size and Runtime", async await expect(page.getByRole("button", { name: "E2B cloud" })).toHaveCount(0); await page.getByRole("button", { name: /^Current/ }).click(); await page.getByRole("button", { name: "Save configuration" }).click(); - await expect.poll(() => submitted).toMatchObject({ provider: "docker", expected_generation: 1, resources: current.resources, runtime }); + await expect.poll(() => submitted).toMatchObject({ provider: "docker", expected_generation: 1, resources: current.resources }); + expect(submitted).not.toHaveProperty("runtime"); expect(submitted).not.toHaveProperty("core_url"); }); @@ -372,3 +373,36 @@ test("renames a node and sets how many sandboxes run on it at once", async ({ pa await expect(page.getByRole("heading", { name: "core-01-large", level: 1 })).toBeVisible(); await expect(capacity).toContainText("5 / 6"); }); + + +test("keeps node setup unavailable until Core has a matching distribution and refreshes without a manual release", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none", installers: "none" }); + await page.getByRole("button", { name: "Own machines" }).click(); + await page.getByRole("button", { name: "microsandbox Recommended" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + await expect(page.getByRole("button", { name: "Save configuration" })).toBeDisabled(); + await expect(page.locator(".wizard-missing")).toContainText("Runtime release needed"); + await resetFixture(request, "authenticated", { sandbox: "none" }); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await expect(page.getByRole("button", { name: "Save configuration" })).toBeEnabled(); + await expect(page.getByText("c0ffee000000", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Advanced settings", exact: true }).click(); + await expect(page.getByLabel("Source commit", { exact: true })).toHaveCount(0); +}); + + +test("refreshes Core distribution after a rejected save and keeps the refusal visible", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); + await page.getByRole("button", { name: "Own machines" }).click(); + await page.getByRole("button", { name: "microsandbox Recommended" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + const save = page.getByRole("button", { name: "Save configuration" }); + await expect(save).toBeEnabled(); + await resetFixture(request, "authenticated", { sandbox: "none", installers: "none" }); + await save.click(); + await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); + await expect(page.getByText("This Core has no matching installation distribution for the selected provider.", { exact: true }).first()).toBeVisible(); + await expect(save).toBeDisabled(); + await expect(page.locator(".wizard-missing")).toContainText("Runtime release needed"); + expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); +}); diff --git a/apps/web/src/components/InstallationNotice.test.tsx b/apps/web/src/components/InstallationNotice.test.tsx index 5545cd40b..840680666 100644 --- a/apps/web/src/components/InstallationNotice.test.tsx +++ b/apps/web/src/components/InstallationNotice.test.tsx @@ -5,7 +5,7 @@ import { InstallationNotice } from "./InstallationNotice"; const installation: CoreInstallation = { 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: [] }, + source_commit: null, node_installation: 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/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx index cc8a41c0f..d0423068f 100644 --- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -11,7 +11,6 @@ import { CommandBlock } from "./node-commands"; export interface NodeCleanup { name: string; installationId: string; - scriptDigest: string; /** The Core address the node enrolled with, when it is no longer the deployment's; else null. */ oldAddress: string | null; } @@ -33,8 +32,9 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle const join = (...sentences: string[]) => sentences.join(i18n.resolvedLanguage?.startsWith("zh") ? "" : " "); const installation = useQuery({ ...installationQuery, enabled: cleanup !== null }); const sourceUrl = installation.data ? nodeSourceUrl(installation.data) : null; - const command = (force = false) => cleanup && sourceUrl - ? nodeUninstallCommand({ sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, force }) : ""; + const distribution = installation.data?.node_installation; + const command = (force = false) => cleanup && sourceUrl && distribution && installation.data?.source_commit + ? nodeUninstallCommand({ sourceUrl, installationId: cleanup.installationId, scriptDigest: distribution.installer_sha256, sourceCommit: installation.data.source_commit, force }) : ""; const stays = cleanup ? t("{{name}} is removed from Core, but its service and files stay on the host.", { name: cleanup.name }) : ""; return {t("Done")}}> {cleanup && !installation.data ?
@@ -43,7 +43,7 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle :

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

}
: 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 ?
+
: cleanup && !distribution ?

{join(stays, t("This Core has no matching node installer."))}

: cleanup ?

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

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

diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 86d0dbe2b..231d8921d 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -14,11 +14,9 @@ import { sandboxProviderLabel, sandboxRequestError } from "../../lib/sandbox-lab import { checklistOpenFor, modelStep, nextStepAfterNode } from "../overview/getting-started"; import { harnessesQuery } from "../system/harness-queries"; import { nodeSourceUrl } from "./core-origin"; -import { nodeFilesAvailable, type SandboxConsoleConfig } from "./console-config"; import { nodeInstallCommand, nodeLogCommand } from "./enrollment-command"; import { CommandBlock, CopyCommand, HostRequirements } from "./node-commands"; import { enrolledNode, enrollmentProgress, formatCountdown, progressSteps, type StepState } from "./node-enrollment"; -import { sandboxConsoleConfigQuery } from "./sandbox-queries"; /** The host requirements open by default until this browser has shown them once. */ const REQUIREMENTS_SEEN = "oac-web.node-requirements-seen"; @@ -44,10 +42,10 @@ const DEFAULT_RETAINED = "8"; * 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 - * `local_only` says), an unreadable one, or a console that - * reports no node files for the deployment's provider (`node_artifacts`) says + * `local_only` says), an unreadable one, or a Core that + * reports no node files for the deployment's provider (`node_installation.runtime_releases`) says * so instead. Each opening, and each return to the window while open, reads - * the installation and the console again, so a fix on the Core host shows + * the installation again, so a fix on the Core host shows * without a reload. * * The page keeps this dialog mounted, so a command survives closing it: it is @@ -57,9 +55,8 @@ const DEFAULT_RETAINED = "8"; * list: read on each opening, every few seconds while open, and once more at * expiry, since a node registered by the command outranks its expiry. */ -export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, fresh, onClose, onRefresh }: { +export function NodeEnrollment({ client, deployment, nodes, open, fresh, onClose, onRefresh }: { client: SandboxAdminClient; - consoleConfig: SandboxConsoleConfig; deployment: SandboxDeployment; nodes: SandboxNode[]; open: boolean; @@ -91,7 +88,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // Nodes download from, and reach Core at, the public URL; the browser's address may be a tunnel or loopback. // 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 available = installation.data?.node_installation; 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; @@ -105,8 +102,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, ? installation.isError ? { text: t("The installation couldn't be read, so no command can be issued."), failed: true } : { text: t("Checking this installation's public URL…") } : !publicUrl ? { text: t("Set a public address other machines can reach before adding nodes.") } - : !nodeFilesAvailable(consoleConfig, deployment.provider) - ? { 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 }) } + : !available?.runtime_releases[deployment.provider] + ? { text: t("This Core 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.suspension !== null; @@ -137,7 +134,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // Expired only once a read begun after the expiry found no node for the command. const expired = lapsed && fresh && checked !== null && checked.startedAt >= expiresAt && checked.nodes === nodes; const command = enrollment && provider && available && publicUrl && (registered || !expired) && !ready - ? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256 }) : ""; + ? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: available.installer_sha256, sourceCommit: installation.data!.source_commit! }) : ""; const nodeId = node?.id ?? null; const polling = open && enrollment !== null && !ready && (registered || !expired); const check = useCallback(async () => { @@ -151,11 +148,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, }, [onRefresh]); useEffect(() => () => { generation.current++; request.current?.abort(); }, []); useEffect(() => { if (open) rememberRequirementsSeen(); }, [open]); - // Rerunning ./install.sh or oac apply on the Core host changes what the console and the installation report. + // Rerunning ./install.sh or oac apply on the Core host changes the installation distribution Core reports. useEffect(() => { if (!open) return; const reread = () => { - void queryClient.invalidateQueries({ queryKey: sandboxConsoleConfigQuery.queryKey }); void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); }; reread(); @@ -266,10 +262,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : sandboxDiagnosticMessage(progress.problem, locale); return createPortal(
- {!available ?

{t("This console serves no node installer. For a console deployed by hand, point OAC_WEB_NODE_PAYLOAD_DIR at the distribution's node payload and restart it.")}

- : !enrollment && blocker ? blocker.failed + {!enrollment && blocker ? blocker.failed ?

{blocker.text}

:

{blocker.text}

+ : !available ?

{t("This Core has no matching node installer.")}

: !enrollment ? (
{ event.preventDefault(); void generate(); }}>

{t("Set the sandbox limits for the host you want to add.")}

diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 82596e9cc..4e1bb94a8 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { type SandboxNode } from "@oac/agents-client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Pencil, Plus, Server, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ConfirmDialog } from "../../components/ConfirmDialog"; @@ -11,8 +11,7 @@ import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-naviga import { InstallationNotice } from "../../components/InstallationNotice"; import { installationQuery } from "../../lib/installation"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import type { SandboxConsoleConfig } from "./console-config"; -import { sandboxAdmin, sandboxConsoleConfigQuery } from "./sandbox-queries"; +import { sandboxAdmin } from "./sandbox-queries"; import { useSandboxPageState } from "./use-sandbox-page-state"; import { NodeEnrollment } from "./NodeEnrollment"; import { NodeList, onOldAddress } from "./NodeList"; @@ -24,16 +23,10 @@ import "./SandboxManagerView.css"; /** Nodes owns node enrollment, the list and individual node management. */ export function SandboxManagerView() { - const { t, i18n } = useTranslation("sandbox"); + const { i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; - const { data: config, isError, isFetching, refetch } = useQuery(sandboxConsoleConfigQuery); return
- {config ? : <> - -
{isError - ? <>

{t("The console configuration could not be read. Refresh to try again.")}

- :

{t("Connecting to this console's Core…")}

}
- } +
; } @@ -51,7 +44,7 @@ function NodesPageHeader({ title, count, back, actions, headingRef }: { title?: ; } -function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig }) { +function SandboxManager() { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { params, navigate, back: goBack } = useConsoleNavigation(); @@ -107,10 +100,10 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig if (params.id === target.id) navigate("nodes"); refresh(); // The host still runs the node's service until it is uninstalled there; the dialog reads the installation for the command. - if (consoleConfig.node_installer && snapshot) { + if (snapshot) { const { deployment } = snapshot; setCleanup({ node: { - name: target.name || target.id, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, + name: target.name || target.id, installationId: deployment.installation_id, oldAddress: onOldAddress(target, deployment.core_url) ? target.core_url : null, }, open: true }); } @@ -171,7 +164,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig window.requestAnimationFrame(() => heading.current?.focus()); }} />; // Rendered first in both the list and a node's page, so an open command outlives the navigation. - const enrollment = hostedNodes && snapshot ? setAdding(false)} onRefresh={refreshNodes} /> : null; + const enrollment = hostedNodes && snapshot ? setAdding(false)} onRefresh={refreshNodes} /> : null; if (params.id && hostedNodes) { const back = () => goBack("nodes"); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index a9d43e540..c4ff9126e 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; @@ -15,7 +15,7 @@ import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { ParseKeys } from "i18next"; import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; -import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, runtimeReleaseFields, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { defaultSandboxResources, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; @@ -81,15 +81,6 @@ function presets(provider: SandboxProvider): Record | return { small: scale(0.5), standard, large: scale(2) }; } -const releaseLabels: Record> = { - source_commit: "Source commit", - image_id: "Image ID", - image_manifest_digest: "Image manifest digest", - microsandbox_ref: "microsandbox reference", - runtime_sha256: "Runtime SHA-256", - firmware_sha256: "Firmware SHA-256", -}; - function presetOf(provider: SandboxProvider, resources: SandboxResources): Preset | null { 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); @@ -102,12 +93,10 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese * which backend (own machines, microsandbox preselected) or the E2B account, * 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. + * declarations decide the size and disk inputs. Core selects the Runtime + * release from its installation distribution. * `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. + * the backend keeps its saved size; another backend starts from its defaults. E2B updates can retain the saved key. * Docker isolates less than microsandbox, so choosing it takes a confirmation, * once per wizard session; a saved Docker deployment has already made it. * Core's address is config.json's `public_url`: the review only shows it, and @@ -149,7 +138,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [discoveryRetry, setDiscoveryRetry] = useState(0); - const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker"); const [confirmingDocker, setConfirmingDocker] = useState(false); @@ -193,13 +181,9 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]); } - // A release the administrator entered comes first, then the saved one of the same backend, - // then the one this console distributes (the release its node installer verifies). const needsRuntime = policy !== null && Object.keys(policy.artifacts).length > 0; - const matched = useQuery({ queryKey: ["sandbox-runtime-release", provider], queryFn: ({ signal }) => distributionRuntime(provider!, signal).catch(() => null), enabled: needsRuntime, staleTime: Infinity, retry: false }); - const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; - - const runtimeReady = !needsRuntime || (provider !== null && isRuntimeRelease(provider, release)); + const release = provider ? installation.data?.node_installation?.runtime_releases[provider] : undefined; + const runtimeReady = !needsRuntime || (!installation.isError && release !== undefined); // 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))); @@ -213,7 +197,7 @@ 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 declared default size and this console's Runtime. + // The saved backend keeps its size; another starts from its declared default size. function choose(next: SandboxProvider) { if (next !== provider) { setRejection(null); @@ -221,7 +205,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const proposed = kept?.resources ?? defaultSandboxResources(next); if (proposed) setResources(proposed); setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); - setRuntime({}); } setProvider(next); } @@ -241,7 +224,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const selection = { provider, expected_generation: expectedGeneration, ...(sized ? { resources } : {}), - ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), }; if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { ...e2bUpdateSelection(template, apiKey), configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); else await onSubmit({ ...selection, ...(provider === "e2b" ? { credential: { api_key: apiKey.trim() }, configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); @@ -253,7 +235,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setFieldRejection(error); if (error instanceof AgentCoreError && error.param) { if (["credential", "configuration", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("e2b"); - else if (error.param === "runtime" || error.param.startsWith("resources.")) setStep("advanced"); + else if (error.param === "runtime") { setStep("review"); void installation.refetch(); } + else if (error.param.startsWith("resources.")) setStep("advanced"); } setResetRequired(error instanceof AgentCoreError && ["sandbox_credential_ownership", "sandbox_reset_required"].includes(error.code ?? "")); setAddressRejected(error instanceof AgentCoreError && error.code === "sandbox_configuration_error"); @@ -380,7 +363,12 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {needsRuntime ? (
{t("Runtime")}{t("The target Runtime release. Existing sandboxes keep their owned release while nodes prepare the target.")}
-
{runtimeReady ? {release.source_commit!.slice(0, 12)} : {t("Runtime release needed")}{t("This console serves no Runtime manifest. Enter the release under advanced settings.")}}
+
+ {runtimeReady ? {release!.source_commit.slice(0, 12)} + : installation.isError || !installation.data ? {installation.isError ? t("The installation could not be read. Refresh to try again.") : t("Checking this installation's public URL…")} + : {t("Runtime release needed")}{t("This Core has no matching installation distribution for the selected provider.")}} + {!runtimeReady ? <> : null} +
) : null}
@@ -433,20 +421,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab : null}
: null} - {needsRuntime ? ( -
- {t("Runtime release")}{t("Filled in from this console's distribution when it serves one. Otherwise copy these from the distribution manifest that matches your nodes; image configuration IDs and manifest digests are different values.")} - {fieldError("runtime") ? : null} - {runtimeReleaseFields(provider!).map((field) => { - const value = (field === "source_commit" ? release.source_commit : release.artifacts?.[field]) ?? ""; - return ( - - { setRuntime(field === "source_commit" ? { ...release, source_commit: event.target.value.trim() } : { ...release, artifacts: { ...release.artifacts, [field]: event.target.value.trim() } }); setFieldRejection(null); }} /> - - ); - })} -
- ) : null} {provider === "e2b" ? ( { setTemplate(event.target.value); setFieldRejection(null); }} autoComplete="off" spellCheck={false} /> diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts deleted file mode 100644 index 23834faba..000000000 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { nodeFilesAvailable, sandboxConsoleConfig } from "./console-config"; - -afterEach(() => vi.unstubAllGlobals()); -describe("bundled console capabilities", () => { - it("uses the existing console login without sending a project or admin bearer", async () => { - const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64), node_artifacts: ["docker"] }))); - vi.stubGlobal("fetch", fetch); - const controller = new AbortController(); - expect(await sandboxConsoleConfig(controller.signal)).toEqual({ node_installer: true, node_installer_sha256: "a".repeat(64), node_artifacts: ["docker"] }); - expect(fetch).toHaveBeenCalledWith("/console/config", { credentials: "include", signal: controller.signal }); - }); - it.each([{}, { node_installer: "true", node_installer_sha256: "a".repeat(64) }, { node_installer: true, node_installer_sha256: "bad" }])("does not enable installation without a verified digest %j", async (body) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); - expect((await sandboxConsoleConfig(new AbortController().signal)).node_installer).toBe(false); - }); - it.each([404, 502])("reports a failed read (HTTP %i) as a failure", async (status) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Failed", { status }))); - await expect(sandboxConsoleConfig(new AbortController().signal)).rejects.toThrow(); - }); - it("blocks a provider's command when the console reports no node files for it", async () => { - const read = async (body: object) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64), ...body })))); - return sandboxConsoleConfig(new AbortController().signal); - }; - const docker = await read({ node_artifacts: ["docker"] }); - expect(nodeFilesAvailable(docker, "docker")).toBe(true); - expect(nodeFilesAvailable(docker, "microsandbox")).toBe(false); - // An absent, null or malformed value reports none. - for (const body of [{}, { node_artifacts: null }, { node_artifacts: "docker" }, { node_artifacts: { docker: true } }]) { - const config = await read(body); - expect(config.node_artifacts).toEqual([]); - expect(nodeFilesAvailable(config, "docker")).toBe(false); - } - }); -}); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts deleted file mode 100644 index eb36c2d8e..000000000 --- a/apps/web/src/features/sandbox/console-config.ts +++ /dev/null @@ -1,34 +0,0 @@ -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: string[]; -} - -const SHA256 = /^[a-f0-9]{64}$/; - -/** - * The console's node installer, its digest and the providers it has node - * files for. An installer is offered only with a well-formed SHA-256 digest. - * A failed read is thrown so callers report it. - */ -export async function sandboxConsoleConfig(signal: AbortSignal): Promise { - const response = await fetch("/console/config", { credentials: "include", signal }); - if (!response.ok) throw new Error(`The console configuration could not be read (HTTP ${response.status}).`); - const config = await response.json() as Partial> & { node_artifacts?: unknown }; - return { - node_installer: config.node_installer === true && SHA256.test(config.node_installer_sha256 ?? ""), - node_installer_sha256: config.node_installer_sha256 ?? "", - node_artifacts: nodeArtifacts(config.node_artifacts), - }; -} - -/** A reported list keeps the providers it names; null or any other value means none. */ -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. */ -export function nodeFilesAvailable(config: SandboxConsoleConfig, provider: string): boolean { - return config.node_artifacts.some((entry) => entry === provider); -} diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index 6d4de7982..a2043585c 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -2,8 +2,7 @@ import type { CoreInstallation } from "@oac/agents-client"; /** * Where the node commands download the installer, and the `--source-url` they - * pass it: the installation's public URL, whose reverse proxy sends - * `/node-install/*` to this console. Unlike the browser's address, it is the + * pass it: Core's public URL. 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`). */ diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index 340c81431..fea4e23f7 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,35 +1,13 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; -import { deploymentContract, type SandboxProvider, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; +import { describe, expect, it } from "vitest"; +import { defaultSandboxResources, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { deploymentContract, type SandboxProvider, type SandboxSpecification } from "@oac/agents-client"; -import deploymentContractFixture from "../../../../../services/core/internal/sandbox/testdata/deployment-contract.json"; 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)}`, microsandbox: { runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) } }; -afterEach(() => vi.unstubAllGlobals()); describe("deployment resources and Runtime", () => { - it.each(deploymentContractFixture.filter((entry) => entry.provider !== "e2b" && (entry.valid || /release|artifact|newline|crlf/.test(entry.name))))("checks the shared release contract: $name", (entry) => { - expect(isRuntimeRelease(entry.provider as SandboxProvider, entry.specification.runtime as unknown as Partial)).toBe(entry.valid); - }); - it("maps only each provider's declared identities from one matched distribution", async () => { - const fetcher = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify(manifest)))); - vi.stubGlobal("fetch", fetcher); - expect(await distributionRuntime("docker", new AbortController().signal)).toEqual({ source_commit: manifest.source_commit, - artifacts: { image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime } }); - expect(await distributionRuntime("microsandbox", new AbortController().signal)).toEqual({ source_commit: manifest.source_commit, - artifacts: { microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox.runtime_sha256, firmware_sha256: manifest.microsandbox.firmware_sha256 } }); - await expect(distributionRuntime("e2b", new AbortController().signal)).rejects.toThrow(); - expect(fetcher.mock.calls.every((call) => call[0] === "/node-install/manifest.json")).toBe(true); - }); - it("requires only the selected provider's manifest identities", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ platform: manifest.platform, source_commit: manifest.source_commit, images: manifest.images, image_manifest_digests: manifest.image_manifest_digests })))); - const release = await distributionRuntime("docker", new AbortController().signal); - expect(isRuntimeRelease("docker", release)).toBe(true); - expect(isRuntimeRelease("microsandbox", release)).toBe(false); - expect(isRuntimeRelease("docker", { ...release, artifacts: { ...release.artifacts, extra: "x" } })).toBe(false); - }); it("preserves saved resources and Runtime only for the same provider", () => { const current: SandboxSpecification = { resources: { cpus: 7, memory_mib: 8192 }, runtime: { source_commit: manifest.source_commit, artifacts: { image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime } } }; @@ -58,26 +36,7 @@ describe("deployment resources and Runtime", () => { expect(validSandboxResources("e2b", { cpus: 2, memory_mib: 2048, root_disk_mib: 1024 })).toBe(false); expect(validSandboxResources("microsandbox", { cpus: 2, memory_mib: 2048, root_disk_mib: 1023, environment_disk_mib: 8192 })).toBe(false); }); - it("accepts only Core's Runtime image in the Runtime reference", async () => { - const digest = "b".repeat(64); - const runtime_ref = `oac-runtime@sha256:${digest}`; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - expect((await distributionRuntime("microsandbox", new AbortController().signal)).artifacts.microsandbox_ref).toBe(runtime_ref); - expect(isRuntimeReleaseField("microsandbox", "microsandbox_ref", runtime_ref)).toBe(true); - for (const runtime_ref of [`custom-runtime@sha256:${digest}`, `oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`, `oac-runtime@sha256:${digest}\n`]) { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - await expect(distributionRuntime("microsandbox", new AbortController().signal)).rejects.toThrow(); - expect(isRuntimeReleaseField("microsandbox", "microsandbox_ref", runtime_ref)).toBe(false); - } - }); - it("rejects unavailable, mutable or incomplete release identities", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 404 }))); - await expect(distributionRuntime("microsandbox", new AbortController().signal)).rejects.toThrow(); - for (const invalid of [{ ...manifest, platform: "linux/arm64" }, { ...manifest, source_commit: "main" }, { ...manifest, source_commit: manifest.source_commit + "\n" }, { ...manifest, runtime_ref: "oac-runtime:latest" }, { ...manifest, microsandbox: {} }]) { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(invalid)))); - await expect(distributionRuntime("microsandbox", new AbortController().signal)).rejects.toThrow(); - } - }); + }); describe("sandboxes a host holds", () => { diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 93882602f..3d5c9cf15 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,4 +1,4 @@ -import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; +import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; /** The size the Provider declares for setup to propose; null when its configuration selects the size. */ export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null { @@ -15,25 +15,6 @@ export function validSandboxResources(provider: SandboxProvider, resources: Sand }); } -export function runtimeReleaseFields(provider: SandboxProvider): string[] { - return ["source_commit", ...Object.keys(deploymentContract.providers[provider].artifacts)]; -} - -export function isRuntimeReleaseField(provider: SandboxProvider, field: string, value: string): boolean { - const rules: Record = deploymentContract.providers[provider].artifacts; - const pattern = field === "source_commit" ? deploymentContract.source_commit_pattern : rules[field]?.pattern; - return pattern !== undefined && new RegExp(`^(?:${pattern})(?![\\s\\S])`).test(value); -} - -export function isRuntimeRelease(provider: SandboxProvider, value: Partial): value is SandboxRuntimeRelease { - const fields = Object.keys(deploymentContract.providers[provider].artifacts); - return fields.length > 0 && typeof value.source_commit === "string" && isRuntimeReleaseField(provider, "source_commit", value.source_commit) - && value.artifacts !== null && typeof value.artifacts === "object" && !Array.isArray(value.artifacts) - && Object.keys(value).every((key) => key === "source_commit" || key === "artifacts") - && Object.keys(value.artifacts).length === fields.length - && fields.every((field) => typeof value.artifacts?.[field] === "string" && isRuntimeReleaseField(provider, field, value.artifacts[field])); -} - export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { return provider === savedProvider && specification ? structuredClone(specification) : null; } @@ -64,16 +45,3 @@ export function sandboxesThatFit(host: { cpus: number | null; memoryBytes: numbe if (!size || host.cpus === null || host.memoryBytes === null || size.cpus <= 0 || size.memory_mib <= 0) return null; return Math.min(Math.floor(host.cpus / size.cpus), Math.floor(host.memoryBytes / (size.memory_mib * 2 ** 20))); } - -/** The paired console serves one matched distribution; Core persists approval. */ -export async function distributionRuntime(provider: SandboxProvider, signal: AbortSignal): Promise { - const response = await fetch("/node-install/manifest.json", { signal, credentials: "include", redirect: "error" }); - if (!response.ok) throw new Error("distribution unavailable"); - const manifest: unknown = await response.json(); - const at = (path: readonly string[]): unknown => path.reduce((value, key) => - value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key) ? (value as Record)[key] : undefined, manifest); - const artifacts = Object.fromEntries(Object.entries(deploymentContract.providers[provider].artifacts).map(([name, rule]) => [name, at(rule.manifest_path)])); - const release = { source_commit: at(["source_commit"]), artifacts }; - if (at(["platform"]) !== "linux/amd64" || !isRuntimeRelease(provider, release as Partial)) throw new Error("invalid distribution"); - return release as SandboxRuntimeRelease; -} diff --git a/apps/web/src/features/sandbox/enrollment-command.test.ts b/apps/web/src/features/sandbox/enrollment-command.test.ts index d0d1a2405..232deafcd 100644 --- a/apps/web/src/features/sandbox/enrollment-command.test.ts +++ b/apps/web/src/features/sandbox/enrollment-command.test.ts @@ -30,6 +30,7 @@ describe("sandbox connection and enrollment", () => { if(process.env.SCENARIO==='download failure') process.exit(22); const args=process.argv.slice(2); if(args.includes('-L')||args.includes('--location')||args.includes('--insecure')) process.exit(90); +if(!args.includes('http://localhost:8080/api/v1/sandbox-node/install/releases/'+'a'.repeat(40)+'/node-install.pyz')) process.exit(91); fs.writeFileSync(args[args.indexOf('-o')+1],fs.readFileSync(process.env.FIXTURE)); if(process.env.SCENARIO==='killed download') process.kill(process.ppid,'SIGKILL');`); executable("sha256sum", `const fs=require('node:fs'), crypto=require('node:crypto'); @@ -56,7 +57,7 @@ process.exit(process.env.SCENARIO==='installer failure'?7:0);`); const digest = scenario === "checksum mismatch" ? "0".repeat(64) : createHash("sha256").update(payload).digest("hex"); const token = "fixture'one-time"; try { - const command = scenario === "uninstall" ? nodeUninstallCommand({ sourceUrl: "http://localhost:8080", installationId: "fixture-installation", scriptDigest: digest }) : nodeInstallCommand({ token, coreUrl: "http://127.0.0.1:8091", sourceUrl: "http://localhost:8080", provider: "docker", installationId: "fixture-installation", scriptDigest: digest }); + const command = scenario === "uninstall" ? nodeUninstallCommand({ sourceUrl: "http://localhost:8080", installationId: "fixture-installation", scriptDigest: digest, sourceCommit: "a".repeat(40) }) : nodeInstallCommand({ token, coreUrl: "http://127.0.0.1:8091", sourceUrl: "http://localhost:8080", provider: "docker", installationId: "fixture-installation", scriptDigest: digest, sourceCommit: "a".repeat(40) }); const proxy = "http://fixture:private%20password@proxy.example:3128"; const proxyEnv: Record = { http_proxy: undefined, https_proxy: undefined, no_proxy: undefined, HTTP_PROXY: undefined, HTTPS_PROXY: undefined, NO_PROXY: undefined, ALL_PROXY: undefined }; if (["uppercase proxy", "root proxy", "empty proxy"].includes(scenario)) Object.assign(proxyEnv, { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, NO_PROXY: "core.example,localhost" }); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index a81e7046b..42b45d511 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -7,7 +7,7 @@ const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; * The download lock protects disposable files; verified entries remain usable * by other running installers. A leading space supports HISTCONTROL=ignorespace. */ -function nodeInstaller(sourceUrl: string, scriptDigest: string): string { +function nodeInstaller(sourceUrl: string, scriptDigest: string, sourceCommit: string): string { if (!/^[0-9a-f]{64}$/.test(scriptDigest)) throw new Error("Invalid node installer checksum"); return ` (umask 077 [ "$(uname -s)/$(uname -m)" = Linux/x86_64 ] || { printf 'Node installation requires Linux amd64. Use a Linux host for Nodes, or a self-hosted Session for this machine.\\n' >&2; exit 1; } @@ -26,7 +26,7 @@ s=; [ "$(id -u)" -eq 0 ] || s=sudo export http_proxy="\${http_proxy-\${HTTP_PROXY-}}" https_proxy="\${https_proxy-\${HTTPS_PROXY-}}" no_proxy="\${no_proxy-\${NO_PROXY-}}" export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" NO_PROXY="$no_proxy" printf '\\n==> Downloading node installer...\\n' && -curl -fs --connect-timeout 15 --max-time 60 --retry 2 --retry-connrefused --retry-delay 1 --max-filesize 1048576 ${quote(sourceUrl + "/node-install/node-install.pyz")} -o "$d/download.partial" || { c=$?; printf 'Cannot download node installer; check the console URL, TLS and proxy settings.\\n' >&2; exit "$c"; } +curl -fs --connect-timeout 15 --max-time 60 --retry 2 --retry-connrefused --retry-delay 1 --max-filesize 1048576 ${quote(sourceUrl + "/api/v1/sandbox-node/install/releases/" + sourceCommit + "/node-install.pyz")} -o "$d/download.partial" || { c=$?; printf 'Cannot download node installer; check the Core URL, TLS and proxy settings.\\n' >&2; exit "$c"; } printf '==> Verifying node installer...\\n' && printf '%s %s\\n' ${quote(scriptDigest)} "$d/download.partial" | sha256sum -c --status || { printf 'Node installer checksum mismatch; generate a fresh command in Web and retry.\\n' >&2; exit 1; } mv "$d/download.partial" "$installer" || exit @@ -44,10 +44,10 @@ const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HT * standard input (`printf` is a shell builtin), never in an argument, the * environment or sudo's command line. */ -export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest }: { - token: string; coreUrl: string; sourceUrl: string; provider: SandboxProvider; installationId: string; scriptDigest: string; +export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest, sourceCommit }: { + token: string; coreUrl: string; sourceUrl: string; provider: SandboxProvider; installationId: string; scriptDigest: string; sourceCommit: 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)})`; + return `${nodeInstaller(sourceUrl, scriptDigest, sourceCommit)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`; } /** @@ -56,8 +56,8 @@ export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, instal * confirms with Core, at the address the node enrolled with, that the node is * removed; `force` skips that check, for an address that no longer answers. */ -export function nodeUninstallCommand({ sourceUrl, installationId, scriptDigest, force = false }: { sourceUrl: string; installationId: string; scriptDigest: string; force?: boolean }): string { - return `${nodeInstaller(sourceUrl, scriptDigest)}${runInstaller} --uninstall --installation-id ${quote(installationId)}${force ? " --force" : ""})`; +export function nodeUninstallCommand({ sourceUrl, installationId, scriptDigest, sourceCommit, force = false }: { sourceUrl: string; installationId: string; scriptDigest: string; sourceCommit: string; force?: boolean }): string { + return `${nodeInstaller(sourceUrl, scriptDigest, sourceCommit)}${runInstaller} --uninstall --installation-id ${quote(installationId)}${force ? " --force" : ""})`; } /** 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 4931b8c91..0d74ee63d 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -3,11 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { SandboxDeployment } from "@oac/agents-client"; import { describe, expect, it, vi } from "vitest"; -import type { SandboxConsoleConfig } from "./console-config"; +import { installationQuery } from "../../lib/installation"; import { SandboxManagerView } from "./SandboxManagerView"; import { SandboxDeploymentPage } from "./SandboxDeploymentPage"; import { SystemPage } from "../system/SystemPage"; -import { sandboxConsoleConfigQuery, sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries"; +import { sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries"; import { beginSandboxWrite, settleSandboxWrite } from "./sandbox-write-ownership"; // Portaled interactive flows are covered by browser acceptance; identify their page owner here. @@ -21,8 +21,7 @@ function cache(provider: SandboxDeployment["provider"]) { 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: [] }; - client.setQueryData(sandboxConsoleConfigQuery.queryKey, () => config); + client.setQueryData(installationQuery.queryKey, { object: "core.installation", installation_id: "install", public_url: "https://core.example", api_base_url: "https://core.example/v1", local_only: false, source_commit: null, node_installation: null, configuration: { settings: [] }, address_bindings: { nodes: 0, nodes_on_other_address: 0, hosted_sandboxes: 0, self_hosted_executors: 0 } }); client.setQueryData(sandboxDeploymentQuery.queryKey, deployment); client.setQueryData(sandboxSnapshotQuery.queryKey, { deployment, nodes: [], allocations: [], nodesError: null, readAt: 0 }); return client; diff --git a/apps/web/src/features/sandbox/sandbox-queries.ts b/apps/web/src/features/sandbox/sandbox-queries.ts index 81029f64e..e58194160 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.ts @@ -2,7 +2,6 @@ import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, typ import { queryOptions, useQuery } from "@tanstack/react-query"; import { confirmSandboxRead, startSandboxRead } from "./sandbox-write-ownership"; -import { sandboxConsoleConfig } from "./console-config"; /** * Cache entries for the sandbox deployment (`/core/v1/sandbox`). Every read @@ -13,17 +12,6 @@ export const sandboxAdmin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox" export const sandboxScope = ["sandbox"] as const; -/** This console's node installer and node files. */ -export const sandboxConsoleConfigQuery = queryOptions({ - queryKey: ["console-config"], - queryFn: async ({ signal }) => { - const config = await sandboxConsoleConfig(signal); - // Never cache a result read after cancellation. - signal.throwIfAborted(); - return config; - }, -}); - /** The deployment alone, for pages that only describe it. */ export const sandboxDeploymentQuery = queryOptions({ queryKey: [...sandboxScope, "deployment"], diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index 61362b13c..b215bd59a 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -53,5 +53,5 @@ export const coreErrorDetails = { "keyLimit": "Enter a valid API key of at most {{max}} characters.", "resourceRange": "Enter a whole number from {{min}} to {{max}}.", "resourceMin": "Enter a whole number of at least {{min}}.", - "runtime": "Use a valid immutable Runtime release for this backend." + "runtime": "This Core has no matching installation distribution for the selected provider." } as const; 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 bb972c9a6..d09b44703 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -51,5 +51,5 @@ export const coreErrorDetails = { "keyLimit": "请输入有效的 API Key,长度最多 {{max}} 个字符。", "resourceRange": "请输入 {{min}} 到 {{max}} 的整数。", "resourceMin": "请输入不小于 {{min}} 的整数。", - "runtime": "请使用适用于此后端的有效不可变 Runtime 版本。" + "runtime": "此 Core 没有适用于所选提供方的配套安装发行文件。" } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index fab73e48c..a41fcf29a 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -1,5 +1,6 @@ import { firstRun } from "./first-run"; export const sandbox = { + "This Core has no matching node installer.": "此 Core 没有配套的节点安装器。", "E2B provider": "E2B 服务商", "Other E2B-compatible provider": "其他兼容 E2B 的服务商", "Loading templates…": "正在读取模板…", @@ -128,8 +129,7 @@ export const sandbox = { "Status unconfirmed": "状态待确认", "Old address": "地址已过期", "Remove and add again": "移除并重新添加", - "This console serves no node installer. For a console deployed by hand, point OAC_WEB_NODE_PAYLOAD_DIR at the distribution's node payload and restart it.": "此控制台没有提供节点安装程序。手动部署的控制台需要把 OAC_WEB_NODE_PAYLOAD_DIR 指向发行包的节点载荷目录,然后重启控制台。", - "This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.": "这个控制台没有 {{provider}} 的节点文件。请用离线包安装 Core,或补齐发布制品后重新运行 ./install.sh。", + "This Core has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.": "此 Core 没有 {{provider}} 的节点文件。请用离线包安装 Core,或补齐发布制品后重新运行 ./install.sh。", "Run on the host you want to add.": "在需要添加的主机上运行。", "Preparing your command…": "正在准备命令…", "Terminal": "终端", @@ -256,7 +256,7 @@ export const sandbox = { "Sign in to the console again to access sandbox management.": "请重新登录控制台以访问沙箱管理。", "Core rejected the sandbox change": "Core 拒绝了此次沙箱更改", "Core rejected the sandbox configuration.": "Core 拒绝了这个沙箱配置。", - "The console configuration could not be read. Refresh to try again.": "无法读取控制台配置。请刷新重试。", + "The installation could not be read. Refresh to try again.": "无法读取安装信息。请刷新重试。", "The sandbox request was rejected. Refresh to check the current state.": "沙箱请求被拒绝。请刷新并检查当前状态。", "The sandbox service is unavailable. Refresh to check the current state.": "沙箱服务不可用。请刷新并检查当前状态。", "Disabled": "已禁用", @@ -366,7 +366,6 @@ export const sandbox = { "microsandbox, the recommended default, runs each sandbox as a lightweight virtual machine: stronger isolation and its own root and data disks with size limits, but the host needs KVM. Docker runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.": "microsandbox(推荐的默认后端):每个沙箱是一个轻量虚拟机,隔离更强,有独立的根盘和数据盘并能限制大小,但主机需要支持 KVM。Docker:每个沙箱是一个共用主机内核的容器,能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。", "E2B runs sandboxes in its cloud: no machines to manage, billed by E2B. Own machines run them on hosts you add, with microsandbox (recommended) or Docker.": "E2B 云端:沙箱跑在 E2B 的云上,不用管理机器,由 E2B 计费。自有机器:沙箱跑在你添加的主机上,用 microsandbox(推荐)或 Docker。", "Each sandbox": "每个沙箱", - "Filled in from this console's distribution when it serves one. Otherwise copy these from the distribution manifest that matches your nodes; image configuration IDs and manifest digests are different values.": "控制台提供发行清单时会自动填好;否则从与节点配套的发行清单里复制。镜像配置 ID 和清单摘要是两个不同的值。", "Firmware SHA-256": "固件 SHA-256", "How big is each sandbox?": "每个沙箱多大?", "Image ID": "镜像 ID", @@ -396,7 +395,7 @@ export const sandbox = { "Current": "当前规格", "The exact ready build, as template-id:build-uuid. A template alias alone is not enough. Each sandbox gets the build's CPU and memory.": "精确的已就绪构建,格式为 template-id:build-uuid,只填模板别名不行。每个沙箱按这个构建分配 CPU 和内存。", "The key is write-only: Core encrypts it and never shows it again.": "key 只写入:Core 加密保存,之后不再显示。", - "This console serves no Runtime manifest. Enter the release under advanced settings.": "这个控制台没有提供 Runtime 清单,请在高级设置里填写。", + "This Core has no matching installation distribution for the selected provider.": "此 Core 没有适用于所选提供方的配套安装发行文件。", "Where should sandboxes run?": "沙箱在哪里运行?", "Which sandbox backend?": "用哪种沙箱后端?", "Recommended": "推荐", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4f24a353e..30c61b7c0 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -22,10 +22,10 @@ export default defineConfig(({ mode }) => { }, server: { proxy: { - // The console service's own routes (sign-in, capability flags) and the + // The console service's own routes (sign-in and domain settings) and the // management surfaces it forwards, as in production. "/console": { target, changeOrigin: true }, - "/node-install": { target, changeOrigin: true }, + "/api/v1": { target, changeOrigin: true }, "/core/v1": { target, changeOrigin: true }, }, }, diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 16ca46a78..c94ddfe6b 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -137,9 +137,14 @@ Core writes this record in the same transaction that creates the Session. Later | `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 | +| `node_installation` | Node installation distribution, below; null when unavailable | | `configuration` | The process settings Core loaded from its environment, under `settings` | | `address_bindings` | What a change of `public_url` affects, counted on each read | +`node_installation`, when non-null, contains `installer_sha256` and `runtime_releases`. The former is the SHA-256 of the node installer matched to Core's source revision; the latter maps provider identifiers to [Runtime releases](./sandbox-deployment.md#runtime-release). An empty map means the installer is available but no provider has complete artifacts; uninstall commands can still use the installer. An absent node distribution or a development build without a source revision makes this field null; a non-null value requires `source_commit`. + +Core validates pinned release metadata at startup and rechecks artifact availability on each installation read and deployment selection: local regular files and their sizes, or a verified pinned HTTPS download. Completing offline artifacts for the same release makes them available without a restart. Node commands use `public_url`, `source_commit` and `installer_sha256` from one response to construct the pinned release URL defined by [node installation downloads](./machine-api.md#node-installation-downloads). + `configuration.settings` has one entry per setting Core loaded, with its dotted `key`, effective `value`, `default`, whether it is `changeable`, whether it is `sensitive`, and the services it `restarts` (`core`, `web`, `database`). A sensitive setting has null `value` and `default` and a boolean `configured` instead; only sensitive settings have `configured`. `oac-core check-config` validates the same environment and exits without starting Core or printing a value. [Configuration](../../docs/configuration.md) describes each setting. diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index 3cd9b507d..50649d43f 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -67,7 +67,7 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf | `invalid_sandbox_configuration` | `resources.cpus` | `min`: 1, `max`: 255 | The CPU count is outside the supported bounds | | `invalid_sandbox_configuration` | `resources.memory_mib` | `min`: 512, `max`: 1048576 | Memory is outside the supported bounds | | `invalid_sandbox_configuration` | `resources.root_disk_mib` or `resources.environment_disk_mib` | `min`: 1024 for microsandbox; `min`: 0, `max`: 0 for Docker and E2B | Disk capacity is missing or unsupported by the provider | -| `invalid_sandbox_configuration` | `runtime` | omitted | The Runtime release is missing, mutable, invalid or not allowed for E2B | +| `invalid_sandbox_configuration` | `runtime` | omitted | Core has no matching installation distribution for the selected provider | Bounds are validation constants, never submitted values. Node names are limited in bytes; Project and key names in trimmed Unicode characters without control characters. Only the first failure is reported, in this order: model provider URL, protocol, key, general limits, the Harness's protocol, then the Harness's required limits; sandbox resources CPU, memory, disk, then Runtime. Model-provider field errors inside a `model_provider` object keep that object's field as `param`. An unknown sandbox provider returns an error without these fields. diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 3a00068fd..59e70c6cd 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -489,6 +489,11 @@ definitions: local_only: description: True when public_url names a loopback host, reachable only from the Core host. type: boolean + node_installation: + allOf: + - $ref: '#/definitions/nativeinstaller.NodeInstallation' + description: Matched node installer and currently servable Provider releases; null when absent. + x-nullable: true object: enum: - core.installation @@ -506,6 +511,7 @@ definitions: - configuration - installation_id - local_only + - node_installation - object - public_url - source_commit @@ -638,8 +644,6 @@ definitions: description: |- Per-sandbox limits, required for Docker and microsandbox. E2B may omit them; Core then uses the validated template build's cpus and memory_mib. - runtime: - $ref: '#/definitions/sandbox.RuntimeRelease' required: - expected_generation type: object @@ -659,8 +663,6 @@ definitions: description: |- Per-sandbox limits, required for Docker and microsandbox. E2B may omit them; Core then uses the validated template build's cpus and memory_mib. - runtime: - $ref: '#/definitions/sandbox.RuntimeRelease' required: - expected_generation type: object @@ -1649,6 +1651,19 @@ definitions: - Anthropic - Responses - ChatCompletions + nativeinstaller.NodeInstallation: + properties: + installer_sha256: + type: string + pattern: '^(?:[0-9a-f]{64})(?![\s\S])' + runtime_releases: + additionalProperties: + $ref: '#/definitions/sandbox.RuntimeRelease' + type: object + required: + - installer_sha256 + - runtime_releases + type: object projects.APIKey: properties: created_at: @@ -6768,7 +6783,7 @@ paths: post: consumes: - application/json - description: Selects a provider, enforced resource limits and pinned Runtime release. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. Every provider returns 409 sandbox_configuration_error while the public URL is loopback or not https. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. + description: Selects a provider and enforced resource limits. Core supplies the pinned Runtime release from its matching installation distribution and rejects runtime input. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. Every provider returns 409 sandbox_configuration_error while the public URL is loopback or not https. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. parameters: - description: Deployment selection in: body diff --git a/contracts/agents-api/machine-api.md b/contracts/agents-api/machine-api.md index 0c6e6b922..3cd59a16f 100644 --- a/contracts/agents-api/machine-api.md +++ b/contracts/agents-api/machine-api.md @@ -8,6 +8,7 @@ Machines call Core under `/api/v1`: sandbox nodes, Runtime daemons, the Sandbox | Route | Caller | Credential | Contract | | --- | --- | --- | --- | +| `GET` / `HEAD sandbox-node/install/…` | Node installer | None | [Node installation downloads](#node-installation-downloads) | | `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Read the node configuration](#read-the-node-configuration) | | `POST sandbox-node/enroll` | Node installer | Enrollment token | [Enroll a node](#enroll-a-node) | | `GET sandbox-node/identity?node_id=` | Node | Node credential | [Recover a node's identity](#recover-a-nodes-identity) | @@ -44,6 +45,14 @@ Core keeps only a SHA-256 digest of each token and credential it stores; install ## Node routes +### Node installation downloads + +Core publicly serves its matched node installation files at `/api/v1/sandbox-node/install/`, without sign-in or credentials. The pinned installer URL is `/api/v1/sandbox-node/install/releases//node-install.pyz`; the revision and checksum come from [installation facts](./admin-api.md#installation-facts). The installer's `--source-url` remains an origin, without the download path. + +The allowlist contains only `node-install.pyz`, `manifest.json`, `SHA256SUMS`, `runtime/seccomp.json` and the manifest-declared files under `artifacts/`. `releases//` pins metadata and artifacts to one release; retained node generations read their saved release. Path traversal, encoded separators, symlinks escaping the payload root, directories, private files and undeclared artifacts cannot be downloaded. + +Local files support GET and HEAD, conditional requests and Range responses (200, 206, 304, 416); HEAD has no response body. Missing declared execution artifacts may redirect with 307 only to verified, version-pinned HTTPS release downloads. Core neither downloads nor caches remote artifacts. Installers independently verify size and SHA-256, and metadata requests never follow redirects. The [node installer](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md#download-contract) owns publication, download locks and recovery. + ### Read the node configuration `GET /api/v1/sandbox-node/configuration` returns the active deployment for node installation and recovery. It never consumes an enrollment token. diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index 572bb5ed9..ba35f6d73 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -92,11 +92,11 @@ A dropped generation can never be prepared or used again. A helper's exit is evi ## Matched fresh installation -The host program release and Core's selected Runtime release are independent. A fresh node gets its executable and private preparer from the console's current release, and reads the exact Runtime source, image identities and native runtime and firmware digests from its authenticated configuration. When that Runtime is older, the console still serves its immutable `releases//` manifest, checksums and allowlisted artifacts: the Runtime helper, firmware, seccomp profile and image bytes come from the selected release, and artifact URLs stay pinned to their verified manifest even if the console's current release changes during a download. +A fresh node gets its executable and private preparer from Core's matched distribution, and reads the exact Runtime source, image identities and native runtime and firmware digests from its authenticated configuration. A retained generation can pin another saved Runtime release. Its helper, firmware, seccomp profile and image bytes come from that release's immutable manifest and checksums through [node installation downloads](./machine-api.md#node-installation-downloads). Artifact URLs stay pinned to the verified manifest throughout the download. A missing retained release refuses the installation rather than substituting the current Runtime, and so does a local bundle that holds only a different Runtime. These refusals happen before the installer writes the node identity, imports the Runtime, registers the node or starts its service. -A published console release keeps its metadata and artifact bytes. Publishing it again first validates all metadata and every existing declared artifact, then may atomically add only missing, checksum-matched declared artifacts; any conflict prevents every addition, and nothing is overwritten. +A published node distribution keeps its metadata and artifact bytes. Publishing it again first validates all metadata and every existing declared artifact, then may atomically add only missing, checksum-matched declared artifacts; any conflict prevents every addition, and nothing is overwritten. ## Restart recovery diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 7cf1723e3..fdba35ffe 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -789,6 +789,107 @@ paths: summary: Recover an enrolled sandbox node identity and observe its readiness tags: - Sandbox Node + /api/v1/sandbox-node/install/{path}: + get: + description: Public matched Linux amd64 installer, metadata or declared artifact. Versioned releases remain addressable; artifacts may redirect to pinned HTTPS release URLs. Local downloads support conditional and range requests. + parameters: + - description: Metadata filename, artifacts/{filename}, or releases/{source_commit}/{filename} + in: path + name: path + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "206": + description: Partial Content + schema: + type: file + "304": + description: Not Modified + "307": + description: Temporary Redirect + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "405": + description: Method Not Allowed + schema: + $ref: '#/definitions/v1.ErrorResponse' + "416": + description: Requested Range Not Satisfiable + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Download sandbox node installation content + tags: + - Sandbox Nodes + head: + description: Public matched Linux amd64 installer, metadata or declared artifact. Versioned releases remain addressable; artifacts may redirect to pinned HTTPS release URLs. Local downloads support conditional and range requests. + parameters: + - description: Metadata filename, artifacts/{filename}, or releases/{source_commit}/{filename} + in: path + name: path + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "206": + description: Partial Content + schema: + type: file + "304": + description: Not Modified + "307": + description: Temporary Redirect + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "405": + description: Method Not Allowed + schema: + $ref: '#/definitions/v1.ErrorResponse' + "416": + description: Requested Range Not Satisfiable + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Download sandbox node installation content + tags: + - Sandbox Nodes schemes: - http - https diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 6db2023c2..6fc872342 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -13,7 +13,7 @@ Every route requires the Core key. [Web's console server](../../docs/web/console | Route | Effect | | --- | --- | | `GET /core/v1/sandbox/deployment` | Read the safe active configuration, rollout, reset and resource counts | -| `POST /core/v1/sandbox/deployment` | Select the initial provider, resources and Runtime | +| `POST /core/v1/sandbox/deployment` | Select the initial provider and resources with Core's matched Runtime | | `PUT /core/v1/sandbox/deployment` | Change the same provider's target online | | `POST /core/v1/sandbox/deployment/reset` | Start or escalate a durable clear of hosted resources | | `DELETE /core/v1/sandbox/deployment/reset?expected_generation=N` | Cancel the remaining clear | @@ -35,12 +35,13 @@ POST and PUT take the same complete selection and require `expected_generation` | `expected_generation` | Required nonnegative integer from GET; never refresh and replay it automatically | | `provider` | Exactly one of `docker`, `microsandbox`, `e2b` | | `resources` | Per-sandbox limits, below; required for Docker and microsandbox, optional for E2B | -| `runtime` | The immutable [Runtime release](#runtime-release); required for Docker and microsandbox, absent for E2B | | `configuration` | The provider's public selectors. E2B: the immutable `template` build and the optional paired `api_url` and `domain`. Docker and microsandbox accept only `{}` or omission | | `credential` | The provider's write-only credential. E2B: `{api_key}`, required at first setup and omitted on PUT to keep the current key; a null or empty key is invalid. Docker and microsandbox reject it | The request has no Core address. Core derives the deployment's `core_url` from the installation public URL (`public_url` in `config.json`, `OAC_PUBLIC_URL` for Core): the origin nodes and sandbox guests use to reach Core. A request that contains `core_url` is rejected with 400 `invalid_request` like any other unknown member. Every hosted sandbox runs outside Core's network namespace and dials the [sandbox Link](../../docs/configuration.md#changing-the-public-url), so every selection is rejected with 409 `sandbox_configuration_error` until the public URL is https on a host that is not loopback: a loopback host names the sandbox's own namespace, and an http origin elsewhere has no Link. Changing the public URL is an installation change: nodes enrolled with the old address receive no new sandboxes and must be removed and added again. +Core chooses the provider's [Runtime release](#runtime-release) from its own matched distribution and saves it in `specification.runtime`. POST and PUT do not accept `runtime`; an explicit field returns 400 `invalid_request`. A provider requiring node artifacts without a matching distribution receives 400 `invalid_sandbox_configuration` after the generation check. [Installation facts](./admin-api.md#installation-facts) reports availability. E2B uses its template build and does not depend on node payloads. + ### Resources | Field | Accepted value | @@ -58,7 +59,7 @@ microsandbox configures the CPUs, memory, a managed root disk and a separate own ### Runtime release -`runtime` contains exactly `source_commit` and `artifacts`. `source_commit` is a lowercase 40-character commit SHA identifying the distribution. `artifacts` is a string map containing exactly the identities declared by the selected adapter; missing, extra, empty, null or malformed entries are rejected. E2B omits `runtime` and selects its immutable build through `configuration.template`. +Saved `specification.runtime` contains exactly `source_commit` and `artifacts`. `source_commit` is a lowercase 40-character commit SHA identifying the distribution. `artifacts` is a string map containing exactly the identities declared by the selected adapter; missing, extra, empty, null or malformed entries are rejected. E2B omits `runtime` and selects its immutable build through `configuration.template`. | Adapter | Artifact key | Identity | | --- | --- | --- | @@ -68,7 +69,7 @@ microsandbox configures the CPUs, memory, a managed root disk and a separate own | microsandbox | `runtime_sha256` | SHA-256 of the native microsandbox runtime binary, as 64 lowercase hex characters | | microsandbox | `firmware_sha256` | SHA-256 of the matching firmware, as 64 lowercase hex characters | -The adapter's deployment policy declares each key, its validation pattern and its selector in the distribution manifest. The generated installer and client contracts use that declaration to copy and validate the identities from the matching distribution. An image configuration ID and an OCI manifest digest identify different objects and never substitute for each other. The node installer verifies the saved release against its payload before registration and keeps the exact local image identity it imports. +The adapter's deployment policy declares each key, its validation pattern and its selector in the distribution manifest. Core uses those selectors to build the saved Runtime release from its matching distribution. The generated installer and client contracts use the same declaration to verify these identities. An image configuration ID and an OCI manifest digest identify different objects and never substitute for each other. The node installer verifies the saved release against its payload before registration and keeps the exact local image identity it imports. The specification digest is SHA-256 over canonical JSON: `provider`, then `resources`, then `runtime` when present; the Runtime fields are `source_commit`, then `artifacts`, whose keys are sorted lexicographically. An artifact map's insertion order does not change the digest. diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index 886d98255..5461fb892 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: 8a781b20f0a359de77594ca570c4e1723332ac16a60d9a9be405ca5e2422e87d +source_hash: f7e1349a5a2dee975b0837499625c8902360ed79be99ece39fd43acbbf9bfed0 --- 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)。 @@ -139,9 +139,14 @@ Core 会在创建 Session 的同一事务中写入此记录。之后的 Agent | `api_base_url` | 在 `public_url` 后附加 `/v1`,即 Project API 密钥使用的 `OPENAI_BASE_URL` | | `local_only` | 当 `public_url` 指向回环主机时为 True,该主机只能由 Core 主机访问 | | `source_commit` | Core 构建所依据的完整源代码提交;开发构建为 null | +| `node_installation` | 节点安装分发,见下文;不可用时为 null | | `configuration` | Core 从环境加载的进程设置,位于 `settings` 中 | | `address_bindings` | 更改 `public_url` 所影响的内容,每次读取都会重新统计 | +`node_installation` 非 null 时包含 `installer_sha256` 和 `runtime_releases`。前者是匹配 Core 源码提交的节点安装程序 SHA-256;后者以提供商为键,值为[Runtime 发行版](sandbox-deployment.md#runtime-release)。空映射表示安装程序可用,但尚无提供商的完整构件;卸载命令仍可使用安装程序。缺少节点分发或开发构建没有源码提交时,该字段为 null;非 null 时 `source_commit` 必须存在。 + +Core 在启动时验证固定发行版元数据,每次读取安装信息或选择部署时重新检查构件可用性:本地普通文件及其大小,或已验证的固定 HTTPS 下载地址。同一发行版补齐离线构件后无需重启即可使用。节点命令使用同一次响应的 `public_url`、`source_commit` 和 `installer_sha256`,按[节点安装下载](machine-api.md#node-installation-downloads)构造固定发行版 URL。 + `configuration.settings` 为 Core 加载的每项设置一条记录,包含以点分隔的 `key`、生效的 `value`、`default`、是否 `changeable`、是否 `sensitive`,以及会 `restarts` 的服务(`core`、`web`、`database`)。 敏感设置的 `value` 和 `default` 为 null,并改为包含一个布尔值 `configured`;只有敏感设置具有 `configured`。`oac-core check-config` 校验同一组环境变量,然后退出,不启动 Core,也不打印值。[配置](../../../docs/zh/configuration.md)会说明每个设置。 diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index e95c3c2fc..3ea13fd8a 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: 6d4d7795e36b8773187073d6d41d991af3e60342ec2f2b84fceabb362a82b0be +source_hash: 974bbdea8dba7855b2a4ef17e3d75b79f9bc8bdda87e4602448b23dd15432b30 --- `/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。 @@ -69,7 +69,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 | `invalid_sandbox_configuration` | `resources.cpus` | `min`:1,`max`:255 | CPU 数量超出支持范围 | | `invalid_sandbox_configuration` | `resources.memory_mib` | `min`:512,`max`:1048576 | 内存超出支持范围 | | `invalid_sandbox_configuration` | `resources.root_disk_mib` 或 `resources.environment_disk_mib` | `min`:microsandbox 为 1024;Docker 和 E2B 的 `min`:0,`max`:0 | 磁盘容量缺失或提供商不支持 | -| `invalid_sandbox_configuration` | `runtime` | 省略 | Runtime release 缺失、可变、无效或 E2B 不允许 | +| `invalid_sandbox_configuration` | `runtime` | 省略 | Core 没有所选提供商的匹配安装分发 | 这些边界是验证常量,绝不是提交的值。节点名称按字节数限制;Project 名称和键名称按去除首尾空白后的 Unicode 字符数限制,且不得包含控制字符。系统仅按以下顺序报告第一个失败项:模型提供商 URL、协议、密钥、常规限制、Harness 协议,然后是 Harness 的必需限制;沙箱资源依次为 CPU、内存、磁盘,然后是 Runtime。`model_provider` 对象内的模型提供商字段错误仍以该对象的相应字段作为 `param`。未知的沙箱提供商返回一个不含这些字段的错误。 diff --git a/contracts/agents-api/zh/machine-api.md b/contracts/agents-api/zh/machine-api.md index e5788d446..bd82b3216 100644 --- a/contracts/agents-api/zh/machine-api.md +++ b/contracts/agents-api/zh/machine-api.md @@ -1,7 +1,7 @@ --- title: "机器连接 API" source: contracts/agents-api/machine-api.md -source_hash: e5fb4508091ff0311acbebe10349fe9ae2c649e1fb7a721d6047f581a2fd7299 +source_hash: a86ae06466685405c0c1a378a24b4ddbb492a4f0a38e148752b97157e04ddd48 --- 机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon、Sandbox I/O 服务和自托管安装器。各路由仅接受所列凭据,不接受 Core 密钥或 Project API 密钥;控制台登录也不授予此处权限。公共源站将 `/api/v1` 转发给 Core,可以直接转发,也可以经过 Web 不改变请求的 HTTP 和 WebSocket 代理。Web 不会给机器请求添加控制台权限。 @@ -10,6 +10,7 @@ source_hash: e5fb4508091ff0311acbebe10349fe9ae2c649e1fb7a721d6047f581a2fd7299 | 路由 | 调用方 | 凭据 | 契约 | | --- | --- | --- | --- | +| `GET` / `HEAD sandbox-node/install/…` | 节点安装器 | 无 | [节点安装下载](#node-installation-downloads) | | `GET sandbox-node/configuration` | 节点安装器与节点 | 登记 token,或节点凭据加 `X-OAC-Node-ID` | [读取节点配置](#read-the-node-configuration) | | `POST sandbox-node/enroll` | 节点安装器 | 登记 token | [登记节点](#enroll-a-node) | | `GET sandbox-node/identity?node_id=` | 节点 | 节点凭据 | [恢复节点身份](#recover-a-node-s-identity) | @@ -46,6 +47,14 @@ Core 对存储的每个 token 和凭据仅保留 SHA-256 摘要;安装授权 ## 节点路由 {#node-routes} +### 节点安装下载 {#node-installation-downloads} + +Core 在 `/api/v1/sandbox-node/install/` 公开提供匹配发行版的节点安装文件,无需登录或凭据。固定安装程序 URL 为 `/api/v1/sandbox-node/install/releases//node-install.pyz`;来源提交和校验和取自[安装信息](admin-api.md#installation-facts)。安装器的 `--source-url` 仍是源站地址,不包含下载路径。 + +允许下载的文件只有 `node-install.pyz`、`manifest.json`、`SHA256SUMS`、`runtime/seccomp.json` 和清单声明的 `artifacts/` 文件。`releases//` 将元数据和构件固定到同一发行版;保留的节点代次读取其保存的发行版。路径穿越、编码分隔符、逃出载荷目录的符号链接、目录、私有文件和未声明构件均不可下载。 + +本地文件通过 GET 或 HEAD 提供,支持条件请求和 Range(200、206、304、416);HEAD 不返回响应体。缺失的已声明执行构件只能以 307 重定向到经验证的固定版本 HTTPS 发行地址。Core 不下载或缓存远端构件。安装器独立验证大小和 SHA-256;元数据不跟随重定向。有关发布、下载锁和恢复,参阅[节点安装器](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md#download-contract)。 + ### 读取节点配置 {#read-the-node-configuration} `GET /api/v1/sandbox-node/configuration` 返回用于节点安装和恢复的活动部署,不消耗登记 token。 diff --git a/contracts/agents-api/zh/node-generation-protocol.md b/contracts/agents-api/zh/node-generation-protocol.md index daf9244bf..b455b44d1 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: fb06cedf8bb6172b5864589d6a2148c8f75252520183481f3ceaca7bb97f3701 +source_hash: 22f73a398924a2e8a222e9634df2b9f4404e909f567d9b9775403252d041a6fa --- 沙箱节点在其主机上运行 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)。 @@ -94,11 +94,11 @@ Core 的丢弃授权是回收的必要条件,但并非充分条件。排队和 ## 完全匹配的新安装 {#matched-fresh-installation} -主机程序版本与 Core 选择的 Runtime 版本彼此独立。全新节点从控制台当前版本获取其可执行文件和私有准备器,并从经认证的配置中读取确切的 Runtime 源、镜像身份以及原生运行时和固件摘要。当该 Runtime 较旧时,控制台仍会提供其不可变的 `releases//` 清单、校验和以及允许列表中的制品:Runtime 辅助程序、固件、seccomp 配置文件和镜像字节都来自所选版本;即使下载期间控制台的当前版本发生变化,制品 URL 仍固定到已验证的清单。 +全新节点从 Core 匹配的分发获取可执行文件和私有准备器,并从经认证的配置中读取确切的 Runtime 源、镜像身份以及原生运行时和固件摘要。保留代次可以固定另一个已保存的 Runtime 发行版。其辅助程序、固件、seccomp 配置文件和镜像字节依据该发行版的不可变清单和校验和,通过[节点安装下载](machine-api.md#node-installation-downloads)获取。整个下载过程中,构件 URL 始终固定到已验证的清单。 所需的保留版本缺失时,安装会被拒绝,而不会替换为当前 Runtime;仅包含另一个 Runtime 的本地捆绑包也会导致拒绝。所有这些拒绝都发生在安装器写入节点身份、导入 Runtime、注册节点或启动服务之前。 -已发布的控制台版本会保留其元数据和制品字节。再次发布时,会先验证所有元数据及每个已存在的已声明制品,然后可以原子地仅添加缺失且校验和匹配的已声明制品;任何冲突都会阻止全部添加,并且不会覆盖任何内容。 +已发布的节点分发会保留其元数据和制品字节。再次发布时,会先验证所有元数据及每个已存在的已声明制品,然后可以原子地仅添加缺失且校验和匹配的已声明制品;任何冲突都会阻止全部添加,并且不会覆盖任何内容。 ## 重启恢复 {#restart-recovery} diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 5f4b66a8f..181740ce1 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: e5a3c062ddffa395cb5832df3cbd2b30b30f04dfa0917911b375331cff070b76 +source_hash: 582645b77dace09a31713aaf7cb5a7ad2d31b0ce597dc2b325080550bd2491c6 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -15,7 +15,7 @@ source_hash: e5a3c062ddffa395cb5832df3cbd2b30b30f04dfa0917911b375331cff070b76 | 路由 | 效果 | | --- | --- | | `GET /core/v1/sandbox/deployment` | 读取安全的当前配置、推出、重置和资源计数 | -| `POST /core/v1/sandbox/deployment` | 选择初始提供商、资源和 Runtime | +| `POST /core/v1/sandbox/deployment` | 选择初始提供商、资源及 Core 匹配的 Runtime | | `PUT /core/v1/sandbox/deployment` | 在线更改同一提供商的目标 | | `POST /core/v1/sandbox/deployment/reset` | 启动托管资源的持久清除或将其升级为持久清除 | | `DELETE /core/v1/sandbox/deployment/reset?expected_generation=N` | 取消剩余清除 | @@ -37,12 +37,13 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 ` | `expected_generation` | 必填的非负整数,来自 GET;绝不会自动刷新并重放 | | `provider` | 必须是 `docker`、`microsandbox`、`e2b` 中恰好一个 | | `resources` | 每个沙箱的限制,见下文;Docker 和 microsandbox 必填,E2B 可选 | -| `runtime` | 不可变的 [Runtime 发行版](#runtime-release);Docker 和 microsandbox 必填,E2B 必须省略 | | `configuration` | 提供商的公开选择器。E2B:不可变的 `template` 构建以及可选且配套的 `api_url` 和 `domain`。Docker 和 microsandbox 仅接受 `{}` 或省略 | | `credential` | 提供商的只写凭据。E2B:`{api_key}`,首次设置时必填,在 PUT 中省略以保留当前密钥;null 或空密钥无效。Docker 和 microsandbox 拒绝该字段 | 请求中没有 Core 地址。Core 根据安装公开 URL(`config.json` 中的 `public_url`,Core 对应 `OAC_PUBLIC_URL`)派生部署的 `core_url`:这是节点和沙箱客户机访问 Core 时使用的源地址。包含 `core_url` 的请求会像包含任何其他未知成员一样被拒绝,并返回 400 `invalid_request`。每个托管沙箱都运行在 Core 的网络命名空间之外,并连接[沙箱 Link](../../../docs/zh/configuration.md#changing-the-public-url),因此在公开 URL 改为非回环主机上的 https 地址之前,任何选择都会被拒绝,并返回 409 `sandbox_configuration_error`:回环主机指向沙箱自身的命名空间,而其他主机上的 http 源地址没有 Link。更改公开 URL 属于安装变更:使用旧地址注册的节点不会收到新沙箱,必须移除后重新添加。 +Core 从自身匹配分发中选择提供商的[Runtime 发行版](#runtime-release),并将其保存在 `specification.runtime`。POST 和 PUT 不接受 `runtime`;显式提交该字段返回 400 `invalid_request`。需要节点构件的提供商缺少匹配分发时,Core 在代次检查后返回 400 `invalid_sandbox_configuration`。可用性见[安装信息](admin-api.md#installation-facts)。E2B 使用模板构建,不依赖节点载荷。 + ### 资源 {#resources} | 字段 | 可接受值 | @@ -60,7 +61,7 @@ microsandbox 会配置 CPU、内存、托管根磁盘,以及位于 `/environme ### Runtime 发行版 {#runtime-release} -`runtime` 恰好包含 `source_commit` 和 `artifacts`。`source_commit` 是标识发行包的 40 字符小写提交 SHA。`artifacts` 是字符串映射,恰好包含所选适配器声明的标识;缺失、多余、空字符串、null 或格式错误的条目均被拒绝。E2B 省略 `runtime`,通过 `configuration.template` 选择其不可变构建。 +保存的 `specification.runtime` 恰好包含 `source_commit` 和 `artifacts`。`source_commit` 是标识发行包的 40 字符小写提交 SHA。`artifacts` 是字符串映射,恰好包含所选适配器声明的标识;缺失、多余、空字符串、null 或格式错误的条目均被拒绝。E2B 省略 `runtime`,通过 `configuration.template` 选择其不可变构建。 | 适配器 | 制品键 | 标识 | | --- | --- | --- | @@ -70,7 +71,7 @@ microsandbox 会配置 CPU、内存、托管根磁盘,以及位于 `/environme | microsandbox | `runtime_sha256` | 原生 microsandbox Runtime 二进制文件的 SHA-256,表示为 64 个小写十六进制字符 | | microsandbox | `firmware_sha256` | 匹配固件的 SHA-256,表示为 64 个小写十六进制字符 | -适配器的部署策略声明每个键、其验证模式及其在发行清单中的选择路径。生成的安装器和客户端契约通过该声明从匹配的发行包复制并验证标识。镜像配置 ID 和 OCI 清单摘要标识不同的对象,绝不能相互替代。节点安装程序会在注册前根据载荷验证已保存的发行版,并保留其导入的精确本地镜像标识。 +适配器的部署策略声明每个键、其验证模式及其在发行清单中的选择路径。Core 使用这些选择路径从匹配的发行包构建保存的 Runtime release。生成的安装器和客户端契约使用同一声明验证这些标识。镜像配置 ID 和 OCI 清单摘要标识不同的对象,绝不能相互替代。节点安装程序会在注册前根据载荷验证已保存的发行版,并保留其导入的精确本地镜像标识。 规范摘要是规范 JSON 的 SHA-256:依次为 `provider`、`resources` 和存在时的 `runtime`;Runtime 字段依次为 `source_commit`、`artifacts`,其中制品键按字典顺序排序。制品映射的插入顺序不会改变摘要。 diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index b84d6528b..6ca384244 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -85,6 +85,11 @@ services: target: /run/agent-host volume: {nocopy: true, subpath: secrets/agent-host} read_only: true + - type: volume + source: data + target: /opt/oac/node-payload + volume: {nocopy: true, subpath: node-payload} + read_only: true - type: volume source: data target: /state @@ -125,7 +130,6 @@ services: environment: OAC_PUBLIC_URL: *public-url OAC_WEB_CORE_KEY_FILE: /run/oac/core.key - OAC_WEB_NODE_PAYLOAD_DIR: /node-payload OAC_LOG_LEVEL: ${OAC_LOG_LEVEL:-} OAC_LOG_FORMAT: ${OAC_LOG_FORMAT:-} OAC_LOG_ADD_SOURCE: ${OAC_LOG_ADD_SOURCE:-} @@ -135,11 +139,6 @@ services: target: /run/oac volume: {nocopy: true, subpath: secrets/web} read_only: true - - type: volume - source: data - target: /node-payload - volume: {nocopy: true, subpath: node-payload} - read_only: true healthcheck: test: [CMD, /usr/local/bin/oac-web, healthcheck] interval: 5s diff --git a/deploy/compose/test_compose.py b/deploy/compose/test_compose.py index 3956ed1d0..e08ac8b76 100644 --- a/deploy/compose/test_compose.py +++ b/deploy/compose/test_compose.py @@ -64,7 +64,7 @@ def test_compose_uses_private_services_and_ordered_initialization(self): self.assertEqual(volume['type'], 'volume') self.assertEqual(volume['source'], 'data') self.assertNotIn('platform', service) - self.assertEqual({v['target'] for v in services['web']['volumes']}, {'/run/oac', '/node-payload'}) + self.assertEqual({v['target'] for v in services['web']['volumes']}, {'/run/oac'}) agent_host = services['agent-host'] self.assertEqual(agent_host['network_mode'], 'service:core') self.assertEqual((agent_host['cgroup'], sorted(agent_host['cap_add']), agent_host['security_opt']), @@ -76,6 +76,7 @@ def mounts(name): return [(v['target'], v['volume']['subpath'], v.get('read_only', False)) for v in services[name]['volumes']] identity = ('/run/agent-host', 'secrets/agent-host', True) self.assertIn(identity, mounts('core')) + self.assertIn(('/opt/oac/node-payload', 'node-payload', True), mounts('core')) self.assertEqual(mounts('agent-host'), [identity, ('/var/lib/oac/agent-host', 'agent-host', False)]) self.assertEqual(services['core']['environment']['OAC_AGENT_HOST_IDENTITY_FILE'], '/run/agent-host/identity.json') self.assertIsNone(services['core']['command']) diff --git a/deploy/node/README.md b/deploy/node/README.md index 74814031c..7da966355 100644 --- a/deploy/node/README.md +++ b/deploy/node/README.md @@ -1,6 +1,6 @@ # Node installer -These modules are packaged as `node-install.pyz`, which Web serves at `/node-install/`. They install a sandbox node on a Linux host that runs sandboxes for one Core; they never install Core. The bootstrap is one file that runs with the host's `python3`. +These modules are packaged as `node-install.pyz`, which Core serves through [node installation downloads](../../contracts/agents-api/machine-api.md#node-installation-downloads). They install a sandbox node on a Linux amd64 host that runs sandboxes for one Core; they never install Core. The bootstrap is one file that runs with the host's `python3`. | Module | Role | | --- | --- | @@ -34,8 +34,8 @@ Configuration files are published only after their complete contents are written The distribution manifest is the one download contract for nodes: flat versioned file names, and the compressed and unpacked size and SHA-256 of the Runtime. - A Compose installation keeps only the node metadata from its release archive; Core's image never acquires execution-only payloads. -- A node obtains bootstrap metadata from the console that generated its command. Web serves artifacts it has locally and redirects missing declared execution artifacts to the versioned HTTPS release base in the verified manifest. Web never downloads or caches those bytes. -- Only artifact requests may follow HTTPS redirects, and only without credentials or cookies. Metadata and enrollment requests stay on the configured console. The console publishes only fixed non-secret files and declared artifact names. +- A node obtains bootstrap metadata from Core at the public origin in its command. The command pins the installer to the source revision and digest from one [installation response](../../contracts/agents-api/admin-api.md#installation-facts). `--source-url` remains that origin; the installer derives the machine download prefix and reads a retained generation's saved release when needed. +- Only artifact requests may follow HTTPS redirects, and only without credentials or cookies. Metadata and enrollment requests stay on the configured Core origin. Core serves only the [machine download allowlist](../../contracts/agents-api/machine-api.md#node-installation-downloads). - Download into private temporary files, verify size and SHA-256 before an atomic rename, clear leftover temporary downloads and extracted files before retrying, and reuse only verified cache entries or exact image identities. Never select a release other than the pinned one. - Release downloads are anonymous. Never add repository credentials to installed node or Runtime configuration. - Manual builds use the `build-` release tag and tag builds the `v*` tag. The manifest's download base must match the release tag; artifact file names and source provenance keep the full source SHA. diff --git a/deploy/node/distribution.py b/deploy/node/distribution.py index 1aec341c4..24479c12a 100644 --- a/deploy/node/distribution.py +++ b/deploy/node/distribution.py @@ -287,7 +287,7 @@ def read(name): else: if not source_url: raise DistributionError('A Core source URL or offline bundle is required') - url = safe_url(source_url.rstrip('/') + '/node-install/' + name) + url = safe_url(source_url.rstrip('/') + '/api/v1/sandbox-node/install/' + name) for attempt in range(3): try: with urllib.request.build_opener(NoRedirect()).open(url, timeout=30) as stream: @@ -317,8 +317,8 @@ def read(name): if (manifest.get('platform') != 'linux/amd64' or not re.fullmatch(r'[0-9a-f]{40}', manifest.get('source_commit', ''))): raise DistributionError('Unsupported distribution platform or revision') - # Resolve artifacts through the console, which selects local bytes or a pinned HTTPS release. - manifest['artifact_base_url'] = source_url.rstrip('/') + '/node-install/artifacts' if source_url and offline_root is None else '' + # Resolve artifacts through Core, which selects local bytes or a pinned HTTPS release. + manifest['artifact_base_url'] = source_url.rstrip('/') + '/api/v1/sandbox-node/install/releases/' + manifest['source_commit'] + '/artifacts' if source_url and offline_root is None else '' return manifest except (ValueError, TypeError, AttributeError): raise DistributionError('Invalid distribution metadata') from None diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py index 24b8a431c..8d124fef4 100644 --- a/deploy/node/node_install.py +++ b/deploy/node/node_install.py @@ -157,7 +157,7 @@ def transient(error): def fetch(source, name): for attempt in range(3): try: - with open_request(source + "/node-install/" + name) as response: + with open_request(source + "/api/v1/sandbox-node/install/" + name) as response: raw = response.read(1024 * 1024 + 1) if len(raw) > 1024 * 1024: raise RuntimeDownloadError("Node bootstrap metadata is too large: " + name) @@ -165,7 +165,7 @@ def fetch(source, name): except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead) as error: if not transient(error) or attempt == 2: status = " (HTTP " + str(error.code) + ")" if isinstance(error, urllib.error.HTTPError) else "" - raise RuntimeDownloadError("Cannot download node metadata " + name + status + "; check the console URL, TLS and network, then rerun") from None + raise RuntimeDownloadError("Cannot download node metadata " + name + status + "; check the Core source URL, TLS and network, then rerun") from None time.sleep(attempt + 1) @@ -205,8 +205,8 @@ def read(name): distribution.image_identities(manifest, "runtime") for name in dict.fromkeys(item["path"] for items in provider_assets.CATALOG.values() for item in items if item["role"] != "policy"): distribution.artifact(manifest, name) - # Metadata stays on the console; artifact requests may redirect to its pinned release. - manifest["artifact_base_url"] = source + "/node-install/releases/" + manifest["source_commit"] + "/artifacts" if source else "" + # Metadata stays on Core; artifact requests may redirect to its pinned release. + manifest["artifact_base_url"] = source + "/api/v1/sandbox-node/install/releases/" + manifest["source_commit"] + "/artifacts" if source else "" return manifest, sums diff --git a/deploy/node/test_distribution.py b/deploy/node/test_distribution.py index bee168ab9..722088b4b 100644 --- a/deploy/node/test_distribution.py +++ b/deploy/node/test_distribution.py @@ -335,7 +335,7 @@ def test_both_metadata_identities_are_required_before_docker_or_archive(self): class ManifestSourceTests(unittest.TestCase): """A release URL recorded by the build is never an artifact source.""" - def test_console_is_the_only_artifact_source(self): + def test_core_release_is_the_only_artifact_source(self): manifest = json.dumps({'source_commit': 'a' * 40, 'platform': 'linux/amd64', 'artifact_base_url': 'https://github.com/example/releases/download/tag'}).encode() sums = (hashlib.sha256(manifest).hexdigest() + ' manifest.json\n').encode() @@ -346,10 +346,13 @@ def test_console_is_the_only_artifact_source(self): (Path(bundle) / 'SHA256SUMS').write_bytes(sums) self.assertEqual(distribution.load_manifest(offline_root=bundle)['artifact_base_url'], '') files = {'SHA256SUMS': sums, 'manifest.json': manifest} - opener = Mock(open=lambda url, timeout: io.BytesIO(files[url.rsplit('/', 1)[1]])) + def read(url, timeout): + self.assertTrue(url.startswith('https://console.example/api/v1/sandbox-node/install/'), url) + return io.BytesIO(files[url.rsplit('/', 1)[1]]) + opener = Mock(open=read) with patch.object(distribution.urllib.request, 'build_opener', return_value=opener): loaded = distribution.load_manifest(source_url='https://console.example') - self.assertEqual(loaded['artifact_base_url'], 'https://console.example/node-install/artifacts') + self.assertEqual(loaded['artifact_base_url'], 'https://console.example/api/v1/sandbox-node/install/releases/' + loaded['source_commit'] + '/artifacts') if __name__ == '__main__': diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py index 0a7aa235f..333b8e4b6 100644 --- a/deploy/node/test_node_install.py +++ b/deploy/node/test_node_install.py @@ -43,6 +43,15 @@ class Response(io.BytesIO): status, headers = 200, {} +class MetadataTransportTests(unittest.TestCase): + def test_fetch_uses_machine_route_for_current_and_retained_metadata(self): + for name in ("manifest.json", "SHA256SUMS", "releases/" + "a" * 40 + "/manifest.json"): + with self.subTest(name=name), mock.patch.object(installer, "open_request", return_value=io.BytesIO(b"metadata")) as opened: + with installer.fetch("https://core.example", name) as response: + self.assertEqual(response.read(), b"metadata") + opened.assert_called_once_with("https://core.example/api/v1/sandbox-node/install/" + name) + + class NodeInstallTests(unittest.TestCase): def setUp(self): base = Path.home() / ".oac/tests/node-install" @@ -92,8 +101,8 @@ def configuration_response(self, request, **kwargs): def artifact_response(self, request, **kwargs): url = getattr(request, "full_url", request) - # The fixture manifest records a release URL; nodes still download from their console. - self.assertTrue(url.startswith(self.args.source_url + "/node-install/releases/" + self.manifest["source_commit"] + "/artifacts/"), url) + # The fixture manifest records a release URL; nodes still download through Core. + self.assertTrue(url.startswith(self.args.source_url + "/api/v1/sandbox-node/install/releases/" + self.manifest["source_commit"] + "/artifacts/"), url) for name, item in self.manifest["artifacts"].items(): if url.endswith("/" + item["filename"]): return Response(self.payloads[name]) @@ -187,7 +196,7 @@ def fetch(_source, name): def artifact_response(request, **_kwargs): url = getattr(request, "full_url", request) for manifest, files in ((program, payloads), (self.manifest, self.payloads)): - prefix = self.args.source_url + "/node-install/releases/" + manifest["source_commit"] + "/artifacts/" + prefix = self.args.source_url + "/api/v1/sandbox-node/install/releases/" + manifest["source_commit"] + "/artifacts/" for name, item in manifest["artifacts"].items(): if url == prefix + item["filename"]: # Only the node executable comes from the host release. diff --git a/docs/api/index.md b/docs/api/index.md index a1061a939..a71a8e24d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -8,9 +8,9 @@ Core serves three namespaces. Each has one kind of caller and its own credential | --- | --- | --- | --- | --- | | `/v1` | Applications: business systems and the official OpenAI SDK | Project API key | Exactly the 58 method and path pairs of the pinned official Agents API, listed in [upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream-routes.json). Core-only fields sit inside `x_agents_core`: `harness`, `model_provider`, `harness_config`, `environment`, and the read-only Session `installation` | [Agents API guide](./public-agent-api.md) | | `/core/v1` | Web's console server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, executor credentials, default models, metrics, audit, sandbox deployment and nodes | [Core administration API](../../contracts/agents-api/admin-api.md) | -| `/api/v1` | Nodes, Runtime daemons, the Sandbox I/O service, self-hosted executors and their installers | Machine credentials: node enrollment tokens and node credentials, installation grants, executor credentials, daemon credentials, and the Serve and Runtime credentials a Link Hello carries. Each works only on its own routes | Machine bootstrap and connections under `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets, the sandbox Link at `/api/v1/sandbox-link`, and the public native installer downloads | [Machine connection API](../../contracts/agents-api/machine-api.md) | +| `/api/v1` | Nodes, Runtime daemons, the Sandbox I/O service, self-hosted executors and their installers | Machine credentials: node enrollment tokens and node credentials, installation grants, executor credentials, daemon credentials, and the Serve and Runtime credentials a Link Hello carries. Each works only on its own routes | Machine bootstrap and connections under `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets, the sandbox Link at `/api/v1/sandbox-link`, and public native and node installer downloads | [Machine connection API](../../contracts/agents-api/machine-api.md) | -A credential used in another namespace gets 401: a Project API key on `/core/v1` or `/api/v1`, the Core key on `/v1` or `/api/v1`. How Projects and keys behave is in [Projects own assets](../concepts.md#projects-own-assets). +On credential-protected routes, a credential used in another namespace gets 401: a Project API key on `/core/v1` or `/api/v1`, the Core key on `/v1` or `/api/v1`. How Projects and keys behave is in [Projects own assets](../concepts.md#projects-own-assets). **Routing.** Web forwards `/v1`, `/api/v1` and `/docs` to Core unchanged ([console server](../web/console-server.md)). A signed-in browser reaches `/core/v1` through Web, which adds the Core key. Operator scripts call `/core/v1` inside Core's network namespace on the Core host ([script the Core API](../getting-started/operations.md#script-the-core-api)). diff --git a/docs/configuration.md b/docs/configuration.md index 4a5f5318d..c503b1563 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,7 +58,7 @@ Model providers are not process settings; see [Default models](#default-models). | `OAC_OAUTH_TRUSTED_ORIGINS` | unset | Comma-separated HTTPS origins | | `OAC_HISTORY_SETTINGS_FILE` | unset | Optional [Runtime history file](#runtime-history-file). Sensitive; Core reports only whether it is configured | -An unset or empty value selects the default. Edit `.env`, then run `oac apply`. Core reads every process setting, and every file a setting names, once at startup and reports what it loaded at `GET /core/v1/installation`. `oac-core check-config` loads and validates the same settings and files without starting Core. The native installer catalog under `OAC_PROVIDER_ROOT` is not a setting; Core checks it only when it starts. Errors name the variable, never its value. Sensitive settings report only whether they are configured. +An unset or empty value selects the default. Edit `.env`, then run `oac apply`. Core reads every process setting, and every file a setting names, once at startup and reports what it loaded at `GET /core/v1/installation`. `oac-core check-config` loads and validates the same settings and files without starting Core. The installation catalogs under `OAC_PROVIDER_ROOT` are distribution files, not settings. Core validates their metadata at startup and rechecks node artifact availability when reading installation facts or selecting a deployment; see [installation facts](../contracts/agents-api/admin-api.md#installation-facts). Errors name the variable, never its value. Sensitive settings report only whether they are configured. ### Runtime history file @@ -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 [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 | +| Sandbox size, 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) | @@ -114,7 +114,7 @@ The initialization service generates secrets and the installation ID once, then | `secrets/agent-host/` | `identity.json`, the [agent host's identity](#agent-host-container) | Core and the agent host | | `state/` | Private Provider state, mounted in Core at `/state`. Each adapter owns a subdirectory; E2B uses `e2b/`, with no group or other access | Core | | `agent-host/` | The [agent host's state directory](#agent-host-container) | The agent host; initialization checks whether it is empty | -| `node-payload/` | Verified node installation metadata | Web | +| `node-payload/` | Verified node installation metadata, mounted read-only at `/opt/oac/node-payload` | Core | Initialization prepares this directory; application services receive their secret directories read-only. `docker compose exec web oac-web core-key` prints the Core key to the operator terminal without writing it to container logs. Database passwords and credential encryption keys are never printed. @@ -189,7 +189,7 @@ Core reads its process environment. Compose interpolates `.env` into it and moun | `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) | +| `OAC_PROVIDER_ROOT` | Absolute adapter artifact root. The Core image sets `/opt/oac`. Each adapter owns its helper paths beneath this root. Core loads self-hosted daemon installers from `native-installers/` and node installation files from `node-payload/`, checking their metadata against its own source revision. Missing optional distributions are unavailable; malformed metadata rejects startup. A development build without a source revision publishes no matched installation distribution. [Installation facts](../contracts/agents-api/admin-api.md#installation-facts) describes availability. Adapter state lives at `/state`, the data volume's [`state/`](#compose-installations) | Core logs the history file path it loads, never environment values or file contents. @@ -206,6 +206,5 @@ Compose sets these for Web. Set them yourself only when you run the console with | `OAC_WEB_UPSTREAM` | `http://core:8091` | Core's origin, HTTP or HTTPS, without credentials, query or path. The healthcheck probes its `/healthz` | | `OAC_WEB_CORE_KEY_FILE` | Required | Absolute path of a regular file with no group or other permissions, holding the Core key: at least 32 characters, no whitespace, at most 4 KiB | | `OAC_WEB_DIST` | `/www` | Absolute directory of the built console; must contain `index.html` | -| `OAC_WEB_NODE_PAYLOAD_DIR` | unset | Absolute path of the matched distribution's node payload (the installer's `node-payload/`). Unset, `/node-install/*` is not served and Add node is unavailable | An unset or empty variable selects its default. An invalid value stops the console at startup with a message naming the variable. The console also reads the three log [process settings](#settings) and rejects the values Core rejects. Use HTTPS for any browser that is not on the same machine. diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 0aa74c4a8..427195221 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -8,9 +8,9 @@ You add a node by generating a command in Web and running it on the host. The [s ## Before you add a node -- **Core has a public URL** that the host and its sandboxes can reach. Nodes download from Core's console and connect to Core at `public_url`. Until it is set, Add node says *Set a public address other machines can reach before adding nodes*; see [Configure the public address](./install.md#configure-the-domain-and-https). +- **Core has a public URL** that the host and its sandboxes can reach. Nodes download from and connect to Core at `public_url`. Until it is set, Add node says *Set a public address other machines can reach before adding nodes*; see [Configure the public address](./install.md#configure-the-domain-and-https). - **The sandbox configuration is saved.** Open **System** → **Manage sandbox configuration**, choose **Own machines**, the backend and a sandbox size, and **Save configuration**. To change a saved configuration, choose **Reset deployment** first. Every node of an installation uses that backend. -- **The console can serve the node files.** Nodes download their Runtime and provider files from the console, which redirects to the release for files it does not hold, and check each file's size and SHA-256 against the release manifest. Node hosts therefore need access to the release. Without the files, Add node says *This console has no node files for …*. +- **Core can serve the node files.** Nodes download their Runtime and provider files from Core, which redirects to the release for files it does not hold, and check each file's size and SHA-256 against the release manifest. Node hosts therefore need access to the release. Without the files, Add node says *This Core has no node files for …*. The Core host joins like any other host: to run sandboxes on it, add it as a node. @@ -21,7 +21,7 @@ The Core host joins like any other host: to run sandboxes on it, add it as a nod 3. Choose **Generate command** and copy the command. It registers one node, once, and only if it runs within 10 minutes; Web counts down and offers **Generate new command** when it expires. 4. Run it on the host. Web follows the node from registered to connected to ready. -The command downloads the node installer from your console, checks its SHA-256 and runs it with a one-time enrollment token. The installer downloads the node files and checks each against the release manifest, imports the Runtime image, registers the node, starts its service and waits until Core reports the node connected and ready. It never installs software, and it stops with a one-line hint before changing anything when a prerequisite is missing. +The command downloads the node installer from Core, checks its SHA-256 and runs it with a one-time enrollment token. The installer downloads the node files and checks each against the release manifest, imports the Runtime image, registers the node, starts its service and waits until Core reports the node connected and ready. It never installs software, and it stops with a one-line hint before changing anything when a prerequisite is missing. Node installation and removal require root. Web's command uses `sudo` unless the shell is already root. The installer creates an `oac-node` service user and a system service; the node runs as that user, not as root. Running the installer as an ordinary user fails before it reads the enrollment token or changes the host. @@ -106,13 +106,13 @@ The [reset contract](../../contracts/agents-api/sandbox-deployment.md#generation ```sh (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo printf '\n==> Downloading node installer...\n' && - curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/node-install.pyz' -o "$d/node-install.pyz" && + curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/api/v1/sandbox-node/install/releases//node-install.pyz' -o "$d/node-install.pyz" && printf '==> Verifying node installer...\n' && printf '%s %s\n' '' "$d/node-install.pyz" | sha256sum -c --status && $s python3 "$d/node-install.pyz" ${NO_COLOR+--no-color} --uninstall --installation-id '') ``` -Uninstall first asks Core, at the address the node enrolled with, whether the node was removed, and refuses while Core still lists it. When the node enrolled with an address other than the current public URL, the dialog also shows **Old Core address gone?**: if that address no longer responds, it gives the command with `--force`, which skips the check; remove the node on the Nodes page first. Without the dialog, take the installer's SHA-256 from the `node-install.pyz` line of `https://core.example/node-install/SHA256SUMS`. Uninstall stops and removes the service, the node state, the records and the Docker network. It deletes the `oac-node` account only if the installer created it and no node remains; an adopted account only loses the groups the installer added. +Uninstall first asks Core, at the address the node enrolled with, whether the node was removed, and refuses while Core still lists it. When the node enrolled with an address other than the current public URL, the dialog also shows **Old Core address gone?**: if that address no longer responds, it gives the command with `--force`, which skips the check; remove the node on the Nodes page first. Without the dialog, use `source_commit` from [installation facts](../../contracts/agents-api/admin-api.md#installation-facts) for `` and take the installer's SHA-256 from the `node-install.pyz` line of `https://core.example/api/v1/sandbox-node/install/releases//SHA256SUMS`. Uninstall stops and removes the service, the node state, the records and the Docker network. It deletes the `oac-node` account only if the installer created it and no node remains; an adopted account only loses the groups the installer added. It never deletes sandboxes, volumes or images. It keeps the Runtime image and prints the `docker image rm` command. For microsandbox it keeps the store under `/var/lib/oac-node/.oac/m/`, prints how to delete it (`sudo -u oac-node rm -rf `), and keeps a created account until the store is gone; rerun uninstall afterwards. With `--force`, microVMs may still use the store, so check `pgrep -u oac-node` first. Uninstall can be rerun until it completes. @@ -168,7 +168,7 @@ A node reports only its first failed check, in this order: the Docker daemon or | `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 | 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 | +| `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 Core and the release. The node retries with growing delays, up to 30 minutes apart | 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/getting-started/operations.md b/docs/getting-started/operations.md index aa3a21c9f..44e0bae0e 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -155,7 +155,7 @@ Nodes on other hosts keep running. To uninstall them the usual way, remove them An installation runs one release for its whole life. In-place version upgrades and downgrades are not supported. Nothing migrates data between releases. -To move to a new release, install it into a new, empty directory, with its own database, Core key and nodes, and add nodes from its Web. Keep the old installation, its data and its nodes until their work is finished. Nodes run the program of the console that added them and are never upgraded in place; Core accepts only nodes that speak its own node protocol. +To move to a new release, install it into a new, empty directory, with its own database, Core key and nodes, and add nodes from its Web. Keep the old installation, its data and its nodes until their work is finished. Nodes run the program of the Core release that added them and are never upgraded in place; Core accepts only nodes that speak its own node protocol. An interrupted installation can [resume with its saved configuration](./install.md#install). An unrelated nonempty directory is refused. @@ -184,6 +184,6 @@ Installation and mutating `oac` commands share the [installation lock](../config | Core | No published port. Web forwards `/v1`, `/api/v1` and `/docs` | No published port. Web forwards `/v1`, `/api/v1` and `/docs` | | PostgreSQL | No published port | No published port | -Web signs administrators in with the Core key, checks the origin of every request, and forwards signed-in `/core/v1` requests to Core with the Core key, which stays on the server. It forwards `/v1` and `/api/v1` to Core unchanged, with the caller's own credential, serves only the non-secret node payload at `/node-install/`, and has no Docker or KVM access. Machine routes under `/api/v1` use their own enrollment and connection credentials. No service receives a Docker socket. +Web signs administrators in with the Core key, checks the origin of every request, and forwards signed-in `/core/v1` requests to Core with the Core key, which stays on the server. It forwards `/v1` and `/api/v1` to Core unchanged, with the caller's own credential and has no Docker or KVM access. Core serves public node downloads and authenticates machine enrollment and connections under `/api/v1`; see the [machine connection API](../../contracts/agents-api/machine-api.md). No service receives a Docker socket. Sandboxes are the isolation boundary ([Runtime and outer isolation](../concepts.md#runtime-and-outer-isolation)). Docker sandboxes share the node's kernel, and a Docker node is [root-equivalent](./nodes.md#what-the-installer-sets-up) on its host; microsandbox gives each sandbox a microVM with an explicit [network policy](./nodes.md#what-the-installer-sets-up). Core itself has no Docker socket or KVM access. diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 253c7dffd..5e5f0f0f6 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -138,7 +138,7 @@ The backend fingerprint identifies a native resource namespace, not capacity. Co ### Distribution artifacts and process paths -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. +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 artifact catalog and Python projection. Run the command without `-write` to check freshness. Distribution packaging and node installation read this projection; Core checks availability from the registered declarations. 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. diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 17e74d289..45a81d721 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -8,7 +8,7 @@ This page lists the Core routes each console page reads and writes, and how the | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | -| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config`, `/node-install/manifest.json` | The Core key at sign-in, then the console session cookie; `/node-install/manifest.json` needs no sign-in | Sign-in and sign-out; the node installer and node artifacts for Add node; the distribution's Runtime release for Docker and microsandbox setup. See [console server](./console-server.md) | +| Console server | `/console/auth`, `/console/auth/{login,logout}` | The Core key at sign-in, then the console session cookie | Sign-in and sign-out; see [console server](./console-server.md) | | Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | The Core key, added by the console server | Projects, keys, resource reads and deletion, diagnostics, executor credentials and installation commands, provenance, summaries, Core metrics, the installation, default models | | Sandbox administration | `/core/v1/sandbox/**` | The Core key, added by the console server | Sandbox configuration, Nodes, fleet and capacity figures on Overview and Sandbox metrics, Runtime observations of every project | | Agents API | `/v1/**` | Project API key | Not used. The console shows developers how to call it (see [Provenance and monitoring](#provenance-and-monitoring)) | @@ -72,7 +72,7 @@ In an archived project the section hides **Issue credential** and **Rotate** beh | Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs: the creating key's name, or **Unknown** when Core has no record | | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | -| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the call samples; `public_url` as the download origin and `--source-url` of the node install and uninstall commands (and the install command's `--core-url`). A sensitive setting with a value, or an unknown member, fails the read | +| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the call samples; `public_url` as the download origin and `--source-url` of the node install and uninstall commands (and the install command's `--core-url`); `node_installation` supplies the installer digest and provider artifact availability, and commands pin the download release with `source_commit` from the same response. A sensitive setting with a value, or an unknown member, fails the read | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting, and the popover then shows only Core's status. The [Core metrics contract](../../contracts/agents-api/core-metrics.md) defines every measurement | `local_only`, or no `public_url`, stops Add node from issuing a command and Clean up the host from giving one. Overview, Nodes and System then show a visible warning, with Review the public address leading to System. Nodes disables Add node with a visible reason, and Getting started leaves its sandbox step to do. @@ -95,18 +95,18 @@ The list carries each harness's configuration, so the console does not read `GET | Operation | Route | Console use | | --- | --- | --- | -| 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 | +| 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 Docker or microsandbox with `resources` and let Core select the matched 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` (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 | +| 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 the installation response's `node_installation.runtime_releases` omits the deployment's provider. The dialog reads installation facts again on opening and when the window regains focus | | Update node | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**: the name and sandbox limits together (the retained limit only for microsandbox; under Docker, Core sets it to the active limit) | | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion, and a Clean up the host dialog then gives the host's uninstall command (requiring root or sudo; for a node enrolled with another address than the deployment's, also with `--force`, which skips the installer's confirmation with Core) | | Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | -An E2B deployment has no nodes; its API key is write-only. Overview and Sandbox metrics count its running and starting sandboxes from the deployment's `resources.allocations` and `resources.pending`, while the hosted Runtime rows come from Runtime observations. The two sources refresh independently, so the console does not infer retention or cleanup from their difference. The Runtime release sent for Docker and microsandbox comes from the console's own `GET /node-install/manifest.json`; without it the administrator enters the release under advanced settings. +An E2B deployment has no nodes; its API key is write-only. Overview and Sandbox metrics count its running and starting sandboxes from the deployment's `resources.allocations` and `resources.pending`, while the hosted Runtime rows come from Runtime observations. The two sources refresh independently, so the console does not infer retention or cleanup from their difference. Core selects the Runtime release for Docker and microsandbox from its matched distribution and reports availability in [installation facts](../../contracts/agents-api/admin-api.md#installation-facts). Deployment writes follow the [selection request](../../contracts/agents-api/sandbox-deployment.md#selection-request). ## Writes diff --git a/docs/web/console-server.md b/docs/web/console-server.md index 52b89daa0..10d77c68b 100644 --- a/docs/web/console-server.md +++ b/docs/web/console-server.md @@ -32,17 +32,15 @@ The deployment's reverse proxy sends every path to the console. The console forw | `/healthz` | No | `GET` or `HEAD` answers `200 ok` | | `/v1`, `/api/v1` and below | — | Forwarded to Core unchanged, with the caller's credential, streaming and WebSocket upgrades | | `/docs`, `/docs/*` | No | The API reference and its OpenAPI documents, forwarded to Core unchanged | -| `/node-install/*` | No | The node installation payload (see [Node installation payload](#node-installation-payload)) | | `/console/auth`, `/console/auth/login`, `/console/auth/logout` | No | [Sign-in](#sign-in) | | `/`, `/index.html`, `/favicon.svg`, `/oac-mark.svg`, `/assets/*` | No | Static console assets | -| `/console/config` | Yes | [Console configuration](#console-configuration) | | `/core/v1/*` | Yes | [Forwarded to Core](#forwarding-to-core) | | `/core` and other paths under `/core/` | Yes | 404 | | Any other path | Yes | Static assets; a path without a file extension falls back to `index.html` | Every request except `/healthz`, `/v1`, `/api/v1` and `/docs` must pass these checks first: -1. **Host and origin.** The `Host` header must equal the host of `OAC_PUBLIC_URL`. An `Origin` header, when present, must equal that origin, and `Sec-Fetch-Site` must be `same-origin` or `none`. A write that carries neither `Origin` nor `Sec-Fetch-Site: same-origin` needs a same-origin `Referer`. Otherwise the console answers 403. `/node-install/*` checks only the host and the path. +1. **Host and origin.** The `Host` header must equal the host of `OAC_PUBLIC_URL`. An `Origin` header, when present, must equal that origin, and `Sec-Fetch-Site` must be `same-origin` or `none`. A write that carries neither `Origin` nor `Sec-Fetch-Site: same-origin` needs a same-origin `Referer`. Otherwise the console answers 403. 2. **Safe request.** The path must start with `/` and contain no `%`, backslash, NUL, dot segment or empty segment. Absolute-form request targets, `CONNECT` and `TRACE` get 400. An `Upgrade` header gets 400 except on `/v1`, `/api/v1` and `/docs`, which are forwarded before these checks. A `/core/v1` request can therefore never leave that prefix. 3. **Sign-in.** Paths that need sign-in answer 401 without a valid session cookie. @@ -64,6 +62,8 @@ On the way back, it removes `Set-Cookie`, `WWW-Authenticate`, `Location`, `Refre The console never retries a request. Browser code calls `/core/v1` through the typed clients in [`packages/agents-client`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md); [console API usage](./console-api-usage.md) lists what each page reads and writes. +Node installation facts come from [Core installation facts](../../contracts/agents-api/admin-api.md#installation-facts). Core serves [node installation downloads](../../contracts/agents-api/machine-api.md#node-installation-downloads) through the same unchanged `/api/v1` proxy. The console has no installation payload directory or distribution catalog. + ## Sign-in | Method and route | Request | Result | @@ -82,20 +82,6 @@ The administrator signs in with the deployment's [Core key](../getting-started/o Sign-in errors: 400 for a malformed body, 401 `Invalid Core key`, 405 for a method other than `POST`, 415 for a body that is not JSON, 429 as above, and 503 when the console cannot create a session. -## Console configuration - -`GET /console/config` returns what the signed-in browser needs to add nodes: - -| Field | Meaning | -| --- | --- | -| `node_installer` | Whether the console serves a node installation payload | -| `node_installer_sha256` | SHA-256 of that payload's `node-install.pyz`; Add node commands verify it before running the installer | -| `node_artifacts` | The providers (`docker`, `microsandbox`) whose node artifacts the payload holds, locally or as a pinned release download. Read on every request, so artifacts added by rerunning the installer appear without a restart | - -## Node installation payload - -With `OAC_WEB_NODE_PAYLOAD_DIR` set, the console serves the matched distribution's node payload at `/node-install/` without sign-in: `node-install.pyz`, `manifest.json`, `SHA256SUMS`, `runtime/seccomp.json`, and the node artifacts the manifest declares under `artifacts/`. An artifact missing locally redirects (307) to its pinned release download. Node install and uninstall commands download from `/node-install/`, so the reverse proxy must send that path to the console. Nodes verify every checksum themselves. - ## Public address The console does not configure a domain or obtain certificates. The operator's reverse proxy or hosting platform terminates HTTPS and routes to the console, and `OAC_PUBLIC_URL` records the origin that browsers, applications, nodes and executors use. The console accepts only its host, so DNS rebinding cannot reach it. diff --git a/docs/zh/api/index.md b/docs/zh/api/index.md index ac4d31a70..b2de2fd51 100644 --- a/docs/zh/api/index.md +++ b/docs/zh/api/index.md @@ -1,7 +1,7 @@ --- title: "API 命名空间和凭据" source: docs/api/index.md -source_hash: aac5fe238391097213335c12fb9b9da4786a7afc1ef79f8ef7d3c3cb5c1a3f75 +source_hash: 86807f273119da9229d555f067afe17bf057e5883b69c6faf9138d42ba57c395 --- Core 提供三个命名空间。每个命名空间都有一种调用方及其独立凭据,凭据只能在其所属命名空间中使用。 @@ -10,9 +10,9 @@ Core 提供三个命名空间。每个命名空间都有一种调用方及其独 | --- | --- | --- | --- | --- | | `/v1` | 应用程序:业务系统和官方 OpenAI SDK | Project API key | 固定版本官方 Agents API 中全部且仅有的 58 个方法和路径对,列于 [upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream-routes.json)。仅属于 Core 的字段位于 `x_agents_core` 中:`harness`、`model_provider`、`harness_config`、`environment`,以及只读的 Session `installation` | [Agents API 指南](public-agent-api.md) | | `/core/v1` | Web 的控制台服务器和操作员脚本 | [Core key](../getting-started/operations.md#core-key) | 安装信息、Project 和密钥、资源读取和删除、Session 归档、执行器凭据、默认模型、指标、审计、沙箱部署和节点 | [Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) | -| `/api/v1` | 节点、Runtime 守护进程、Sandbox I/O 服务、自托管执行器及其安装程序 | 机器凭据:节点注册令牌和节点凭据、安装授权、执行器凭据、守护进程凭据,以及 Link Hello 携带的 Serve 凭据和 Runtime 凭据。每种凭据只能用于其各自的路由 | `/api/v1/sandbox-node/*` 和 `/api/v1/agent-daemon/*` 下的机器初始化与连接(包括 WebSockets)、`/api/v1/sandbox-link` 上的沙箱 Link,以及公共原生安装程序下载 | [机器连接 API](../../../contracts/agents-api/zh/machine-api.md) | +| `/api/v1` | 节点、Runtime 守护进程、Sandbox I/O 服务、自托管执行器及其安装程序 | 机器凭据:节点注册令牌和节点凭据、安装授权、执行器凭据、守护进程凭据,以及 Link Hello 携带的 Serve 凭据和 Runtime 凭据。每种凭据只能用于其各自的路由 | `/api/v1/sandbox-node/*` 和 `/api/v1/agent-daemon/*` 下的机器初始化与连接(包括 WebSockets)、`/api/v1/sandbox-link` 上的沙箱 Link,以及公共原生和节点安装程序下载 | [机器连接 API](../../../contracts/agents-api/zh/machine-api.md) | -在其他命名空间中使用凭据会返回 401:在 `/core/v1` 或 `/api/v1` 上使用 Project API key,或者在 `/v1` 或 `/api/v1` 上使用 Core key。有关 Project 和密钥的行为,请参阅 [Project 自有资产](../concepts.md#projects-own-assets)。 +在需要凭据的路由上,使用其他命名空间的凭据会返回 401:在 `/core/v1` 或 `/api/v1` 上使用 Project API key,或者在 `/v1` 或 `/api/v1` 上使用 Core key。有关 Project 和密钥的行为,请参阅 [Project 自有资产](../concepts.md#projects-own-assets)。 **路由。** Web 把 `/v1`、`/api/v1` 和 `/docs` 原样转发到 Core([控制台服务器](../web/console-server.md))。已登录的浏览器通过 Web 访问 `/core/v1`,由 Web 附上 Core key。操作员脚本在 Core 主机上从 Core 的网络命名空间内调用 `/core/v1`([编写 Core API 脚本](../getting-started/operations.md#script-the-core-api))。 diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index e13860eec..3fd22bfe3 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 81c6eba549710a69817aad66ca1e3d519b2920e00de868532e0bf62fd58e11fb +source_hash: 93f76a4b608e08bb4c4a9f215b2c9802015d9c0782c19403e1f31ba0ea5e3633 --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -62,7 +62,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | `OAC_OAUTH_TRUSTED_ORIGINS` | unset | Comma-separated HTTPS origins | | `OAC_HISTORY_SETTINGS_FILE` | unset | 可选的 [Runtime 历史文件](#runtime-history-file)。敏感;Core 只报告它是否已配置 | -未设置或为空的值使用默认值。编辑 `.env`,然后运行 `oac apply`。Core 在启动时一次性读取所有进程设置及设置指向的文件,并在 `GET /core/v1/installation` 报告加载的结果。`oac-core check-config` 会在不启动 Core 的情况下加载并校验同样的设置和文件。`OAC_PROVIDER_ROOT` 下的原生安装程序目录清单不属于设置,Core 只在启动时检查它。错误信息只指明变量名,绝不包含其值。敏感设置只报告是否已配置。 +未设置或为空的值使用默认值。编辑 `.env`,然后运行 `oac apply`。Core 在启动时一次性读取所有进程设置及设置指向的文件,并在 `GET /core/v1/installation` 报告加载的结果。`oac-core check-config` 会在不启动 Core 的情况下加载并校验同样的设置和文件。`OAC_PROVIDER_ROOT` 下的安装目录清单是分发文件,不是设置。Core 在启动时验证元数据,并在读取安装信息或选择部署时重新检查节点构件可用性;参阅[安装信息](../../contracts/agents-api/zh/admin-api.md#installation-facts)。错误信息只指明变量名,绝不包含其值。敏感设置只报告是否已配置。 ### Runtime 历史文件 {#runtime-history-file} @@ -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 会推荐 [Provider 声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小和发行版。E2B 密钥仅可写入,并且已加密 | +| 沙箱大小、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) | @@ -118,7 +118,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | `secrets/agent-host/` | `identity.json`,即 [agent host 的身份](#agent-host-container) | Core 和 agent host | | `state/` | 私有 Provider 状态,在 Core 中挂载到 `/state`。每个适配器拥有一个子目录;E2B 使用 `e2b/`,不允许组或其他用户访问 | Core | | `agent-host/` | [agent host 的状态目录](#agent-host-container) | agent host;初始化时检查它是否为空 | -| `node-payload/` | 已验证的节点安装元数据 | Web | +| `node-payload/` | 已验证的节点安装元数据,只读挂载于 `/opt/oac/node-payload` | Core | 初始化会准备该目录;应用服务以只读方式接收各自的机密目录。`docker compose exec web oac-web core-key` 把 Core 密钥打印到运维人员终端,不写入容器日志。数据库密码和凭据加密密钥绝不打印。 @@ -193,7 +193,7 @@ Core 读取进程环境。Compose 将 `.env` 插值到环境中,并把机密 | `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) | +| `OAC_PROVIDER_ROOT` | 适配器构件的绝对根目录。Core 镜像设置为 `/opt/oac`。每个适配器都拥有此根目录下的辅助路径。Core 从 `native-installers/` 加载自托管守护进程安装程序,从 `node-payload/` 加载节点安装文件,并将其元数据与自身源码提交核对。缺少可选分发时报告不可用;元数据无效则拒绝启动。没有源码提交的开发构建不发布匹配的安装分发。[安装信息](../../contracts/agents-api/zh/admin-api.md#installation-facts)描述可用性。适配器状态位于 `/state`,即数据卷的 [`state/`](#compose-installations) | Core 会记录所加载的历史文件路径,但绝不记录环境变量的值或文件内容。 @@ -210,6 +210,5 @@ Compose 为 Web 设置这些变量。仅在不使用 Compose 运行控制台时 | `OAC_WEB_UPSTREAM` | `http://core:8091` | Core 的源地址,可以使用 HTTP 或 HTTPS,且不得包含凭据、查询参数或路径。健康检查探测其 `/healthz` | | `OAC_WEB_CORE_KEY_FILE` | 必填 | 常规文件的绝对路径,该文件没有组或其他用户权限,并保存 Core 密钥:至少 32 个字符、不含空白字符、最大 4 KiB | | `OAC_WEB_DIST` | `/www` | 已构建控制台的绝对目录;必须包含 `index.html` | -| `OAC_WEB_NODE_PAYLOAD_DIR` | 未设置 | 所匹配发行版的节点载荷(即安装程序的 `node-payload/`)的绝对路径。未设置时,不提供 `/node-install/*`,且 Add node 不可用 | 未设置或为空的变量使用其默认值。无效值会阻止控制台启动,并显示一条指明变量名的消息。控制台还会读取三个日志[进程设置](#settings),并拒绝 Core 拒绝的值。对于不在同一台计算机上的任何浏览器,请使用 HTTPS。 diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index d0c39328b..30dcc82d9 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: cd12954864bf9c15cf2d700fc3d9ebaf6126c0ab4297784850cfdd32b86ae656 +source_hash: 87469c706dabc52eb29de00c107ba99038024212556191dbf7bb2efd0f92421f --- 节点是一台 Linux 主机,在沙箱后端为 Docker 或 microsandbox 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 @@ -10,9 +10,9 @@ source_hash: cd12954864bf9c15cf2d700fc3d9ebaf6126c0ab4297784850cfdd32b86ae656 ## 添加节点前 {#before-you-add-a-node} -- **Core 已有主机及沙箱可访问的公开 URL。** 节点从 Core 控制台下载文件,并通过 `public_url` 连接 Core。设置前,Add node 显示 *Set a public address other machines can reach before adding nodes*;参阅[配置公开地址](install.md#configure-the-domain-and-https)。 +- **Core 已有主机及沙箱可访问的公开 URL。** 节点从 Core 下载文件,并通过 `public_url` 连接 Core。设置前,Add node 显示 *Set a public address other machines can reach before adding nodes*;参阅[配置公开地址](install.md#configure-the-domain-and-https)。 - **沙箱配置已保存。** 打开 **System** → **Manage sandbox configuration**,选择 **Own machines**、后端和沙箱规格,最后选择 **Save configuration**。要更改已保存的配置,先选择 **Reset deployment**。同一安装的所有节点使用同一后端。 -- **控制台能提供节点文件。** 节点从控制台下载 Runtime 和提供商文件;控制台缺少文件时重定向到发行下载地址。节点依据发行清单检查各文件的大小和 SHA-256。因此节点主机需要能访问发行下载地址。缺少文件时,Add node 显示 *This console has no node files for …*。 +- **Core 能提供节点文件。** 节点从 Core 下载 Runtime 和提供商文件;Core 缺少文件时重定向到发行下载地址。节点依据发行清单检查各文件的大小和 SHA-256。因此节点主机需要能访问发行下载地址。缺少文件时,Add node 显示 *This Core has no node files for …*。 Core 主机与其他主机一样加入:要在它上面运行沙箱,将它添加为节点。 @@ -23,7 +23,7 @@ Core 主机与其他主机一样加入:要在它上面运行沙箱,将它添 3. 选择 **Generate command** 并复制命令。命令仅注册一个节点、只能使用一次,且必须在 10 分钟内执行;Web 显示倒计时,过期后提供 **Generate new command**。 4. 在主机上执行。Web 跟踪节点从注册、连接到就绪的过程。 -命令从你的控制台下载节点安装程序、检查 SHA-256,并使用一次性注册令牌运行它。安装程序下载节点文件并逐一对照发行清单验证、导入 Runtime 镜像、注册节点、启动服务,并等待 Core 报告节点已连接且就绪。程序不安装软件;前置条件缺失时,会在修改任何内容之前停止并输出一行提示。 +命令从 Core 下载节点安装程序、检查 SHA-256,并使用一次性注册令牌运行它。安装程序下载节点文件并逐一对照发行清单验证、导入 Runtime 镜像、注册节点、启动服务,并等待 Core 报告节点已连接且就绪。程序不安装软件;前置条件缺失时,会在修改任何内容之前停止并输出一行提示。 安装和移除节点需要 root。除非 shell 已是 root,Web 命令会使用 `sudo`。安装程序创建 `oac-node` 服务用户和系统服务;节点以该用户运行,而非 root。普通用户运行安装程序时,会在读取注册令牌或修改主机前失败。 @@ -108,13 +108,13 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 ```sh (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo printf '\n==> Downloading node installer...\n' && - curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/node-install.pyz' -o "$d/node-install.pyz" && + curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/api/v1/sandbox-node/install/releases//node-install.pyz' -o "$d/node-install.pyz" && printf '==> Verifying node installer...\n' && printf '%s %s\n' '' "$d/node-install.pyz" | sha256sum -c --status && $s python3 "$d/node-install.pyz" ${NO_COLOR+--no-color} --uninstall --installation-id '') ``` -卸载先向节点注册时使用的 Core 地址确认节点是否已移除;Core 仍列出该节点时拒绝卸载。注册地址与当前公开 URL 不同时,对话框还展示 **Old Core address gone?**:该地址不再响应时,提供带 `--force` 的命令以跳过检查;先在 Nodes 页面移除节点。不使用对话框时,从 `https://core.example/node-install/SHA256SUMS` 的 `node-install.pyz` 行获取安装程序 SHA-256。卸载停止并移除服务、节点状态、记录和 Docker 网络。只有安装程序创建了 `oac-node` 且不再有节点时,才删除该账号;复用的账号仅移除安装程序添加的组。 +卸载先向节点注册时使用的 Core 地址确认节点是否已移除;Core 仍列出该节点时拒绝卸载。注册地址与当前公开 URL 不同时,对话框还展示 **Old Core address gone?**:该地址不再响应时,提供带 `--force` 的命令以跳过检查;先在 Nodes 页面移除节点。不使用对话框时,将[安装信息](../../../contracts/agents-api/zh/admin-api.md#installation-facts)的 `source_commit` 用作 ``,并从 `https://core.example/api/v1/sandbox-node/install/releases//SHA256SUMS` 的 `node-install.pyz` 行获取安装程序 SHA-256。卸载停止并移除服务、节点状态、记录和 Docker 网络。只有安装程序创建了 `oac-node` 且不再有节点时,才删除该账号;复用的账号仅移除安装程序添加的组。 程序不删除沙箱、卷或镜像。保留 Runtime 镜像并输出 `docker image rm` 命令。microsandbox 保留 `/var/lib/oac-node/.oac/m/` 存储,并输出删除方法(`sudo -u oac-node rm -rf `);存储删除前保留所创建的账号,之后重新卸载。使用 `--force` 时 microVM 可能仍使用存储,请先检查 `pgrep -u oac-node`。可以重复卸载直到完成。 @@ -170,7 +170,7 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 | `capacity_insufficient` | Host too small | 主机 CPU 或内存不足以运行一个沙箱 | 使用更大主机或修改沙箱规格 | | `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 分钟 | +| `runtime_download_failed` | Runtime download failed | 准备新配置时无法下载或验证 Runtime 文件 | 检查节点到 Core 和发行下载地址的 HTTPS 访问。节点以递增间隔重试,最长间隔 30 分钟 | 新用户组成员关系仅对新进程生效。重启节点服务:`sudo systemctl restart oac-node-.service`。已注册但从未连接的节点通常无法通过公开 URL 访问 Core,或 `/api/v1` WebSocket 无法通过反向代理。 diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md index 2c98c1d97..d5eaf1580 100644 --- a/docs/zh/getting-started/operations.md +++ b/docs/zh/getting-started/operations.md @@ -1,7 +1,7 @@ --- title: "运维" source: docs/getting-started/operations.md -source_hash: 5a14d994e1926ad016a10eb4d6211882e16b0187c874025e6922452f62dbd926 +source_hash: 7b495e7468328b7e67479eb447a85b113fe246bf8d009ad00de1e3a75031b0d1 --- 安装运维人员负责 Core 主机、存储和可用性。节点主机运行各自的服务;参阅[节点](nodes.md)。设置见[配置参考](../configuration.md)。 @@ -158,7 +158,7 @@ cd && rm -rf ~/.oac/core 安装在整个生命周期使用同一发行版本。不支持原地升级或降级,也不在版本间迁移数据。 -迁移到新版本时,安装到全新的空目录,使用独立数据库、Core 密钥和节点,并从新 Web 添加节点。保留旧安装、数据和节点,直到工作完成。节点运行添加它的控制台所提供的程序,不原地升级;Core 仅接受使用自身节点协议的节点。 +迁移到新版本时,安装到全新的空目录,使用独立数据库、Core 密钥和节点,并从新 Web 添加节点。保留旧安装、数据和节点,直到工作完成。节点运行添加它的 Core 发行版所提供的程序,不原地升级;Core 仅接受使用自身节点协议的节点。 中断的安装可以[沿用已保存配置继续](install.md#install)。与本安装无关的非空目录会被拒绝。 @@ -187,6 +187,6 @@ cd && rm -rf ~/.oac/core | Core | 不发布端口。Web 转发 `/v1`、`/api/v1` 和 `/docs` | 不发布端口。Web 转发 `/v1`、`/api/v1` 和 `/docs` | | PostgreSQL | 不发布端口 | 不发布端口 | -Web 使用 Core 密钥让管理员登录,检查每个请求来源,并用保留在服务器上的 Core 密钥将已登录的 `/core/v1` 请求转发到 Core。它把 `/v1` 和 `/api/v1` 原样转发给 Core,使用调用方自己的凭据;Web 仅在 `/node-install/` 提供不含密钥的节点文件,没有 Docker 或 KVM 访问权限。`/api/v1` 机器路由使用独立注册和连接凭据。没有服务持有 Docker 套接字。 +Web 使用 Core 密钥让管理员登录,检查每个请求来源,并用保留在服务器上的 Core 密钥将已登录的 `/core/v1` 请求转发到 Core。它把 `/v1` 和 `/api/v1` 原样转发给 Core,使用调用方自己的凭据,没有 Docker 或 KVM 访问权限。Core 在 `/api/v1` 提供公开节点下载,并认证机器注册和连接;参阅[机器连接 API](../../../contracts/agents-api/zh/machine-api.md)。没有服务持有 Docker 套接字。 沙箱是隔离边界([Runtime 与外层隔离](../concepts.md#runtime-and-outer-isolation))。Docker 沙箱共享节点内核,Docker 节点在主机上[等同于 root 权限](nodes.md#what-the-installer-sets-up);microsandbox 为每个沙箱提供具有显式[网络策略](nodes.md#what-the-installer-sets-up)的 microVM。Core 自身无 Docker 套接字或 KVM 访问权限。 diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 0ab419f53..8e1a9c343 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: 6d82188e3b303276e2a3bc5ddf65e158794f8749b3ed0f210e61dd62a94a3039 +source_hash: 0d0e2493b3bc3078ca6070c676fabd80a1e4a8da043f98eded8f998ef82d9585 --- **Sandbox Provider** 为 Core 管理的 Environment 提供计算资源,以及在其中启动 [Sandbox I/O 服务](#oac-sandbox-io)的有界引导流程;该服务是 Provider 启动的唯一进程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -140,7 +140,7 @@ backend fingerprint 标识原生资源命名空间,不表示容量。Core 保 ### 发行产物与进程路径 {#distribution-artifacts-and-process-paths} -每个 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 分支。 +每个 node adapter 注册负责其类型化 `NodeArtifacts` 声明,即共享 node artifact 加上其包导出的原生文件:逻辑发行路径、release filename suffix 和安装角色(`node`、`runtime`、`policy` 或 `image`)。注册拒绝缺失声明、不安全路径和未知角色。`go run ./services/core/cmd/provider-artifacts -write` 生成共享 artifact catalog 和 Python projection。不带 `-write` 运行可检查是否最新。发行打包和 node 安装读取此投影;Core 根据已注册的声明检查可用性。添加 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 构造与发现不读取进程环境变量。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index fcf17a36c..5d0c9ae44 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: cb3dcd402e9c0b9e9f7ac5cd6d7f7311191f4d4d0b30e41ce6197ce8d5d82996 +source_hash: 974aac836d5b04b3bb4408faa59debb09d6af721009f947aa06e4959fc819499 --- 本页列出各控制台页面读取和写入的 Core 路由,以及控制台如何限定读取范围。[administrator API contract](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API namespaces and credentials](../api/index.md) 定义了本文使用的术语。 @@ -10,7 +10,7 @@ source_hash: cb3dcd402e9c0b9e9f7ac5cd6d7f7311191f4d4d0b30e41ce6197ce8d5d82996 | 接口 | 路径 | 身份验证 | 控制台用途 | | --- | --- | --- | --- | -| Console server | `/console/auth`、`/console/auth/{login,logout}`、`/console/config`、`/node-install/manifest.json` | 登录时使用 Core 密钥,随后使用控制台会话 Cookie;`/node-install/manifest.json` 无需登录 | 登录和退出;Add node 所用的节点安装程序和节点构件;用于 Docker 和 microsandbox 设置的发行版 Runtime release。参见 [console server](console-server.md) | +| Console server | `/console/auth`、`/console/auth/{login,logout}` | 登录时使用 Core 密钥,随后使用控制台会话 Cookie | 登录和退出;参见 [console server](console-server.md) | | Administrator API | `/core/v1/**`,不包括 `/core/v1/sandbox` | Core 密钥,由控制台服务器添加 | 项目、密钥、资源读取和删除、诊断、执行器凭据和安装命令、来源信息、汇总、Core 指标、安装信息、默认模型 | | Sandbox administration | `/core/v1/sandbox/**` | Core 密钥,由控制台服务器添加 | Sandbox 配置;Overview 和 Sandbox metrics 中的 Nodes、机群与容量数据;每个项目的 Runtime observations | | Agents API | `/v1/**` | 项目 API 密钥 | 不使用。控制台会向开发者说明如何调用它(参见 [Provenance and monitoring](#provenance-and-monitoring)) | @@ -74,7 +74,7 @@ source_hash: cb3dcd402e9c0b9e9f7ac5cd6d7f7311191f4d4d0b30e41ce6197ce8d5d82996 | 资源所有者 | `GET /core/v1/projects/{project_id}/resource-owners` | 每个资源列表的 Creator 列和详情页的创建者信息,每批最多处理 100 个 ID:创建密钥的名称;Core 无记录时显示 **Unknown** | | 写入操作 | `GET /core/v1/projects/{project_id}/write-operations` | 项目的写入历史,按最新优先,可按密钥和资源类型筛选,每页 50 条 | | 汇总 | `GET /core/v1/summary` | Overview(按项目)、Agents 列表(`group_by=agent`)、项目页面(按项目并使用 `group_by=key`)、Agent 指标(跳过空闲项目,并统计从范围开始以来按创建密钥划分的使用量)、Projects 列表(最近活动) | -| 安装 | `GET /core/v1/installation` | System 的 Installation 信息(`public_url`、`api_base_url`、`installation_id`、`source_commit`)和只读 Startup 设置(`configuration.settings`;敏感设置仅显示其是否为 `configured`);调用示例中的 `api_base_url`;作为下载来源以及节点安装和卸载命令中 `--source-url` 的 `public_url`(还包括安装命令中的 `--core-url`)。如果敏感设置包含值,或存在未知成员,读取会失败 | +| 安装 | `GET /core/v1/installation` | System 的 Installation 信息(`public_url`、`api_base_url`、`installation_id`、`source_commit`)和只读 Startup 设置(`configuration.settings`;敏感设置仅显示其是否为 `configured`);调用示例中的 `api_base_url`;作为下载来源以及节点安装和卸载命令中 `--source-url` 的 `public_url`(还包括安装命令中的 `--core-url`);`node_installation` 提供安装程序摘要和提供商构件可用性,命令使用同一响应中的 `source_commit` 固定下载发行版。如果敏感设置包含值,或存在未知成员,读取会失败 | | Core 指标 | `GET /core/v1/metrics?range=` | Core 指标页面;Overview 上的 Core 弹出内容。不存在该路由的 Core(404)会显示为未报告数据,此时弹出内容仅显示 Core 状态。[Core metrics contract](../../../contracts/agents-api/zh/core-metrics.md) 定义了每项度量 | 如果为 `local_only`,或者没有 `public_url`,Add node 将无法签发命令,Clean up the host 也无法提供命令。随后 Overview、Nodes 和 System 会显示醒目警告,并通过 Review the public address 前往 System。Nodes 会禁用 Add node 并显示明确原因,Getting started 则将 sandbox 步骤保留为待办项。 @@ -97,18 +97,18 @@ source_hash: cb3dcd402e9c0b9e9f7ac5cd6d7f7311191f4d4d0b30e41ce6197ce8d5d82996 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 部署 | `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 摘要中 | +| 部署 | `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,并由 Core 选择匹配的 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`(一个[就绪状态码](../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` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取这两项信息 | +| 注册 | `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 来源),或者安装响应的 `node_installation.runtime_releases` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取安装信息 | | 更新节点 | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**:同时修改名称和 Sandbox 限制(保留数量限制仅适用于 microsandbox;在 Docker 下,Core 会将其设为活动数量限制) | | 移除节点 | `DELETE /core/v1/sandbox/nodes/{node_id}` | 确认后移除节点;只有 Core 确认删除后该行才会消失,随后 Clean up the host 对话框会提供主机的卸载命令(需要 root 或 sudo;对于使用不同于部署地址的地址注册的节点,还需要使用 `--force`,以跳过安装程序与 Core 的确认) | | Runtime 观测 | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics:每个项目的托管 Runtimes,每项均以所属项目为标签;E2B Sandbox 对话框还会将 `observation.disk` 显示为已用量/限制值(其他位置为 null) | -E2B 部署没有节点;其 API 密钥为只写。Overview 和 Sandbox metrics 根据部署的 `resources.allocations` 和 `resources.pending` 统计其正在运行和正在启动的 Sandbox,而托管 Runtime 行来自 Runtime observations。两个来源独立刷新,因此控制台不会根据两者之间的差异推断保留或清理状态。用于 Docker 和 microsandbox 的 Runtime release 来自控制台自身的 `GET /node-install/manifest.json`;如果无法获取该信息,管理员需在高级设置中输入 release。 +E2B 部署没有节点;其 API 密钥为只写。Overview 和 Sandbox metrics 根据部署的 `resources.allocations` 和 `resources.pending` 统计其正在运行和正在启动的 Sandbox,而托管 Runtime 行来自 Runtime observations。两个来源独立刷新,因此控制台不会根据两者之间的差异推断保留或清理状态。Core 从匹配的分发选择 Docker 和 microsandbox 的 Runtime release,并在[安装信息](../../../contracts/agents-api/zh/admin-api.md#installation-facts)中报告可用性。部署写入遵循[选择请求](../../../contracts/agents-api/zh/sandbox-deployment.md#selection-request)。 ## 写入 {#writes} diff --git a/docs/zh/web/console-server.md b/docs/zh/web/console-server.md index a152253ce..6a216eefd 100644 --- a/docs/zh/web/console-server.md +++ b/docs/zh/web/console-server.md @@ -1,7 +1,7 @@ --- title: "控制台服务器" source: docs/web/console-server.md -source_hash: 2cc4b562301d95640d2b653ec522a5407a70a98e1f4e3c1fe89bd246ffd1199e +source_hash: 8e6a27804d34799bf7a7ee380487e30af2b1688174975e28f6bd71c2210f381c --- 控制台服务器(`services/web`、`oac-web` 进程)提供构建后的控制台,使用 Core 密钥认证管理员,并将已登录浏览器的 `/core/v1` 请求携带该密钥转发到 Core。浏览器不持有 Core 密钥或任何 API 密钥。应用、节点和自托管执行器经控制台到达 Core,控制台原样转发 `/v1`、`/api/v1` 和 `/docs`。 @@ -34,17 +34,15 @@ flowchart LR | `/healthz` | 否 | `GET` 或 `HEAD` 返回 `200 ok` | | `/v1`、`/api/v1` 及其下级路径 | — | 原样转发到 Core,保留调用方凭据、流式响应和 WebSocket 升级 | | `/docs`、`/docs/*` | 否 | API 参考及其 OpenAPI 文档,原样转发到 Core | -| `/node-install/*` | 否 | 节点安装文件(参阅[节点安装文件](#node-installation-payload)) | | `/console/auth`、`/console/auth/login`、`/console/auth/logout` | 否 | [登录](#sign-in) | | `/`、`/index.html`、`/favicon.svg`、`/oac-mark.svg`、`/assets/*` | 否 | 控制台静态资源 | -| `/console/config` | 是 | [控制台配置](#console-configuration) | | `/core/v1/*` | 是 | [转发到 Core](#forwarding-to-core) | | `/core` 及 `/core/` 下其他路径 | 是 | 404 | | 其他路径 | 是 | 静态资源;无扩展名的路径回退到 `index.html` | 除 `/healthz`、`/v1`、`/api/v1` 和 `/docs` 外,每个请求首先必须通过这些检查: -1. **Host 与来源。** `Host` 请求头必须等于 `OAC_PUBLIC_URL` 的主机。存在 `Origin` 时必须等于该来源,`Sec-Fetch-Site` 必须为 `same-origin` 或 `none`。写请求既无 `Origin` 又无 `Sec-Fetch-Site: same-origin` 时,需要同源 `Referer`。否则控制台返回 403。`/node-install/*` 仅检查主机和路径。 +1. **Host 与来源。** `Host` 请求头必须等于 `OAC_PUBLIC_URL` 的主机。存在 `Origin` 时必须等于该来源,`Sec-Fetch-Site` 必须为 `same-origin` 或 `none`。写请求既无 `Origin` 又无 `Sec-Fetch-Site: same-origin` 时,需要同源 `Referer`。否则控制台返回 403。 2. **安全请求。** 路径必须以 `/` 开头,不含 `%`、反斜杠、NUL、点路径段或空路径段。绝对形式请求目标、`CONNECT` 和 `TRACE` 返回 400。`Upgrade` 头返回 400,但 `/v1`、`/api/v1` 和 `/docs` 在这些检查之前就被转发。因此 `/core/v1` 请求无法离开该前缀。 3. **登录。** 需要登录的路径在无有效会话 cookie 时返回 401。 @@ -66,6 +64,8 @@ flowchart LR 控制台不重试请求。浏览器代码通过 [`packages/agents-client`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md) 的类型化客户端调用 `/core/v1`;[控制台 API 使用](console-api-usage.md)列出各页面读写内容。 +节点安装信息来自 [Core 安装信息](../../../contracts/agents-api/zh/admin-api.md#installation-facts)。Core 通过同一个不改变请求的 `/api/v1` 代理提供[节点安装下载](../../../contracts/agents-api/zh/machine-api.md#node-installation-downloads)。控制台没有安装载荷目录或分发目录清单。 + ## 登录 {#sign-in} | 方法和路由 | 请求 | 结果 | @@ -84,20 +84,6 @@ flowchart LR 登录错误:请求体格式错误为 400,错误密钥为 401 `Invalid Core key`,非 `POST` 方法为 405,非 JSON 请求体为 415,上述频率限制为 429,无法创建会话为 503。 -## 控制台配置 {#console-configuration} - -`GET /console/config` 返回已登录浏览器添加节点所需的信息: - -| 字段 | 含义 | -| --- | --- | -| `node_installer` | 控制台是否提供节点安装文件 | -| `node_installer_sha256` | 文件中 `node-install.pyz` 的 SHA-256;Add node 命令执行安装程序前验证它 | -| `node_artifacts` | 文件中包含节点资产的提供商(`docker`、`microsandbox`),资产在本地或通过固定发行下载提供。每次请求都读取,因此重新运行安装程序新增的资产无需重启即可出现 | - -## 节点安装文件 {#node-installation-payload} - -设置 `OAC_WEB_NODE_PAYLOAD_DIR` 后,控制台在 `/node-install/` 无需登录地提供匹配发行版的节点文件:`node-install.pyz`、`manifest.json`、`SHA256SUMS`、`runtime/seccomp.json`,以及清单声明的 `artifacts/` 下节点资产。本地缺失的资产重定向(307)到固定发行下载地址。节点安装和卸载命令从 `/node-install/` 下载,因此反向代理必须将该路径发给控制台。节点自行验证每个校验和。 - ## 公开地址 {#public-address} 控制台不配置域名,也不申请证书。运维人员的反向代理或托管平台终止 HTTPS 并把流量转到控制台,`OAC_PUBLIC_URL` 记录浏览器、应用、节点和执行器使用的源地址。控制台只接受该地址的主机,因此 DNS 重绑定不能访问它。 diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index 38d52560a..e421793ab 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -1,3 +1,5 @@ +import checksumFixture from "../../../services/core/internal/nativeinstaller/testdata/checksums.json"; +import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; import { AdminClient } from "./admin-client"; import { AgentCoreError, OpenAIAgentsClient } from "./client"; @@ -336,10 +338,51 @@ describe("AdminClient installation", () => { const headers = { key: "core.runtime_history.headers", value: null, default: null, configured: true, changeable: true, sensitive: true, restarts: ["core"] }; const installation = { object: "core.installation", installation_id: resourceId, public_url: "https://core.example", api_base_url: "https://core.example/v1", - local_only: false, source_commit: "a".repeat(40), + local_only: false, source_commit: "a".repeat(40), node_installation: null, configuration: { settings: [port, headers] }, address_bindings: { nodes: 2, nodes_on_other_address: 1, hosted_sandboxes: 3, self_hosted_executors: 1 }, }; + const runtime = { source_commit: installation.source_commit, artifacts: { image_id: "sha256:" + "b".repeat(64), image_manifest_digest: "sha256:" + "c".repeat(64) } }; + const node = { installer_sha256: "d".repeat(64), runtime_releases: { docker: runtime } }; + it("shares installer checksum acceptance with Core and its schema", async () => { + const schema = readFileSync(new URL("../../../contracts/agents-api/core.openapi.yaml", import.meta.url), "utf8"); + const field = schema.split(" nativeinstaller.NodeInstallation:\n")[1]?.split(" runtime_releases:")[0]; + const pattern = field?.match(/pattern: '([^']+)'/)?.[1]; + expect(pattern).toBeDefined(); + for (const value of checksumFixture.valid) { + expect(new RegExp(pattern!).test(value)).toBe(true); + await expect(clientWith({ ...installation, node_installation: { ...node, installer_sha256: value } }).client.retrieveInstallation()).resolves.toBeDefined(); + } + for (const value of checksumFixture.invalid) { + expect(new RegExp(pattern!).test(value)).toBe(false); + await expect(clientWith({ ...installation, node_installation: { ...node, installer_sha256: value } }).client.retrieveInstallation()).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + }); + it("reads a matched Core distribution and allows an installer without available providers", async () => { + for (const node_installation of [node, { ...node, runtime_releases: {} }]) { + const snapshot = { ...installation, node_installation }; + expect(await clientWith(snapshot).client.retrieveInstallation()).toEqual(snapshot); + } + }); + it("rejects malformed, unmatched or undeclared node installation facts", async () => { + const invalidNodes = [ + {}, { ...node, extra: true }, { ...node, installer_sha256: "bad" }, + { ...node, installer_sha256: node.installer_sha256 + "\n" }, + { ...node, runtime_releases: null }, { ...node, runtime_releases: [] }, + { ...node, runtime_releases: { unknown: runtime } }, { ...node, runtime_releases: { e2b: runtime } }, + { ...node, runtime_releases: { docker: { ...runtime, source_commit: "e".repeat(40) } } }, + { ...node, runtime_releases: { docker: { ...runtime, source_commit: runtime.source_commit + "\n" } } }, + { ...node, runtime_releases: { docker: { ...runtime, artifacts: {} } } }, + { ...node, runtime_releases: { docker: { ...runtime, artifacts: { ...runtime.artifacts, extra: "bad" } } } }, + { ...node, runtime_releases: { docker: { ...runtime, artifacts: { ...runtime.artifacts, image_id: runtime.artifacts.image_id + "\n" } } } }, + ]; + for (const node_installation of invalidNodes) { + await expect(clientWith({ ...installation, node_installation }).client.retrieveInstallation()).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + await expect(clientWith({ ...installation, source_commit: null, node_installation: node }).client.retrieveInstallation()).rejects.toMatchObject({ code: "invalid_admin_response" }); + const { node_installation: _node, ...missing } = installation; + await expect(clientWith(missing).client.retrieveInstallation()).rejects.toMatchObject({ code: "invalid_admin_response" }); + }); it("reads installation facts before any deployment and rejects inconsistent snapshots", async () => { expect(await clientWith(installation).client.retrieveInstallation()).toEqual(installation); expect(await clientWith({ ...installation, source_commit: null }).client.retrieveInstallation()).toMatchObject({ source_commit: null }); @@ -352,6 +395,7 @@ describe("AdminClient installation", () => { { ...installation, token: "leak" }, { ...installation, configuration: null }, { ...installation, installation_id: null }, + { ...installation, source_commit: installation.source_commit + "\n" }, { ...installation, public_url: null, api_base_url: null }, configuration([{ ...port, configured: true }]), ]) { diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index dd980fb88..fc842d97b 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -1,3 +1,5 @@ +import { projectSandboxRuntimeRelease, type SandboxProvider } from "./sandbox-client"; +import { deploymentContract } from "./deployment-contract"; import { coreHarnessKinds, modelProviderProtocols } from "./harness-catalog"; import { AgentCoreError, projectRuntimeObservation, projectSavedAgentConfiguration } from "./client"; import { projectTokenUsage } from "./usage-projection"; @@ -9,7 +11,7 @@ import { adminRuntimeObservationListFields, adminSessionCountsFields, adminSummaryResponseFields, adminSummaryRowFields, adminUsageCoverageFields, coreHarnessFields, coreHarnessListFields, executorConnectionFields, executorConnectionStatusValues, executorCredentialFields, executorCredentialListFields, harnessModelConfigurationFields, providerErrorCodeValues, installationConfigurationFields, - installationFields, installationSettingFields, installationSettingRequired, installationServiceValues, issuedExecutorCredentialFields, + installationFields, nodeInstallationFields, installationSettingFields, installationSettingRequired, installationServiceValues, issuedExecutorCredentialFields, managedArchiveFields, managedArchiveStateValues, modelConfigurationSupportFields, projectFields, projectsAPIKeyFields, resourceOwnerFields, resourceOwnerListFields, runtimeDiskObservationFields, writeauditAPIKeyFields, actionValues, writeauditOperationFields, resourceTypeValues, writeauditPageFields, @@ -266,7 +268,20 @@ export function projectInstallation(value: unknown): CoreInstallation { 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(); + (installation.source_commit !== null && (typeof installation.source_commit !== "string" || !new RegExp(`^(?:${deploymentContract.source_commit_pattern})(?![\\s\\S])`).test(installation.source_commit)))) return invalidAdminResponse(); + let nodeInstallation = null; + if (installation.node_installation !== null) { + if (installation.source_commit === null) return invalidAdminResponse(); + const node = record(installation.node_installation, nodeInstallationFields); + if (typeof node.installer_sha256 !== "string" || !/^[a-f0-9]{64}(?![\s\S])/.test(node.installer_sha256) || !isRecord(node.runtime_releases)) return invalidAdminResponse(); + const releases = Object.fromEntries(Object.entries(node.runtime_releases).map(([provider, value]) => { + if (!hasOwn(deploymentContract.providers, provider) || deploymentContract.providers[provider as SandboxProvider].mode !== "nodes") return invalidAdminResponse(); + const release = projectSandboxRuntimeRelease(value, provider as SandboxProvider); + if (release.source_commit !== installation.source_commit) return invalidAdminResponse(); + return [provider, release]; + })); + nodeInstallation = { installer_sha256: node.installer_sha256, runtime_releases: releases }; + } const bindings = record(installation.address_bindings, addressBindingsFields); if (![bindings.nodes, bindings.nodes_on_other_address, bindings.hosted_sandboxes, bindings.self_hosted_executors].every(isNonnegativeInteger) || (bindings.nodes_on_other_address as number) > (bindings.nodes as number)) return invalidAdminResponse(); @@ -274,5 +289,5 @@ export function projectInstallation(value: unknown): CoreInstallation { if (!Array.isArray(configuration.settings)) return invalidAdminResponse(); const settings = configuration.settings.map(projectInstallationSetting); if (new Set(settings.map((setting) => setting.key)).size !== settings.length) return invalidAdminResponse(); - return { ...installation, address_bindings: { ...bindings }, configuration: { settings } } as unknown as CoreInstallation; + return { ...installation, node_installation: nodeInstallation, address_bindings: { ...bindings }, configuration: { settings } } as unknown as CoreInstallation; } diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 5ce1a7918..0133e4a58 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -307,11 +307,12 @@ export interface Installation { configuration: InstallationConfiguration; installation_id: string; local_only: boolean; + node_installation: NodeInstallation | null; object: "core.installation"; public_url: string; source_commit: string | null; } -export const installationFields = ["address_bindings", "api_base_url", "configuration", "installation_id", "local_only", "object", "public_url", "source_commit"] as const; +export const installationFields = ["address_bindings", "api_base_url", "configuration", "installation_id", "local_only", "node_installation", "object", "public_url", "source_commit"] as const; export interface InstallationConfiguration { settings: InstallationSetting[]; } @@ -474,6 +475,11 @@ export interface NodeHost { total_memory_bytes: number | null; } export const nodeHostFields = ["available_disk_bytes", "available_memory_bytes", "cpu_utilization", "effective_cpu_cores", "observed_at", "total_memory_bytes"] as const; +export interface NodeInstallation { + installer_sha256: string; + runtime_releases: Record; +} +export const nodeInstallationFields = ["installer_sha256", "runtime_releases"] as const; export interface NodeRollout { diagnostic?: NodeDiagnosticCode; ready_generation: number | null; @@ -754,9 +760,8 @@ export interface SandboxDeploymentChangeInput { expected_generation: number; provider?: string; resources?: SandboxResources; - runtime?: RuntimeRelease; } -export const sandboxDeploymentChangeInputFields = ["configuration", "credential", "expected_generation", "provider", "resources", "runtime"] as const; +export const sandboxDeploymentChangeInputFields = ["configuration", "credential", "expected_generation", "provider", "resources"] as const; export const sandboxDeploymentChangeInputRequired = ["expected_generation"] as const; export interface SandboxDeploymentInput { configuration?: Record; @@ -764,9 +769,8 @@ export interface SandboxDeploymentInput { expected_generation: number; provider?: string; resources?: SandboxResources; - runtime?: RuntimeRelease; } -export const sandboxDeploymentInputFields = ["configuration", "credential", "expected_generation", "provider", "resources", "runtime"] as const; +export const sandboxDeploymentInputFields = ["configuration", "credential", "expected_generation", "provider", "resources"] as const; export const sandboxDeploymentInputRequired = ["expected_generation"] as const; export interface SandboxEnrollmentToken { enrollment_id: string; diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 1ace80953..827747fe8 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -53,7 +53,7 @@ export type SandboxAllocation = NodeAllocation; // The schema types each Provider's configuration, credential and metadata as free-form objects; the client names E2B's. /** * Core derives the deployment's address from the installation public URL; a `core_url` member is rejected. - * `expected_generation` is required, zero for first setup. Docker and microsandbox require `resources` and `runtime`; + * `expected_generation` is required, zero for first setup. Node providers require `resources`; Core selects their Runtime release; * E2B may omit `resources` to adopt its template build's CPU and memory, and always runs that build. */ export type InitializeSandboxDeployment = Omit & { @@ -103,12 +103,19 @@ function projectSpecification(value: unknown, provider: SandboxProvider): Sandbo const rules = deploymentContract.providers[provider].artifacts; valid(hasOwn(specification, "runtime") === (Object.keys(rules).length > 0)); if (!hasOwn(specification, "runtime")) return { resources: { ...resources } as unknown as SandboxResources }; - const runtime = members(specification.runtime, runtimeReleaseFields); + return { resources: { ...resources } as unknown as SandboxResources, runtime: projectSandboxRuntimeRelease(specification.runtime, provider) }; +} + +/** Validate a provider release using the same declaration as saved specifications. */ +export function projectSandboxRuntimeRelease(value: unknown, provider: SandboxProvider): SandboxRuntimeRelease { + const rules = deploymentContract.providers[provider].artifacts; + valid(Object.keys(rules).length > 0); + const runtime = members(value, runtimeReleaseFields); const matches = (value: unknown, pattern: string) => typeof value === "string" && new RegExp(`^(?:${pattern})(?![\\s\\S])`).test(value); valid(matches(runtime.source_commit, deploymentContract.source_commit_pattern)); const artifacts = members(runtime.artifacts, Object.keys(rules)); valid(Object.entries(rules).every(([name, rule]) => matches(artifacts[name], rule.pattern))); - return { resources: { ...resources } as unknown as SandboxResources, runtime: { source_commit: runtime.source_commit as string, artifacts: { ...artifacts } as Record } }; + return { source_commit: runtime.source_commit as string, artifacts: { ...artifacts } as Record }; } /** The adapter's public projection has no credential member. */ function projectE2B(configuration: unknown, metadata: unknown): Pick { diff --git a/scripts/compose-smoke.py b/scripts/compose-smoke.py index e427b2a8e..f12e589e0 100644 --- a/scripts/compose-smoke.py +++ b/scripts/compose-smoke.py @@ -55,7 +55,8 @@ def prepare_pinned_payload(destination): def build_images(directory, tag): - revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + # Match the fixture's Core identity to its verified distribution metadata. + revision = json.loads((ROOT / 'deploy/compose/smoke-pins.json').read_text())['revision'] protocol = re.search(r'const Version = "([^"]+)"', (ROOT / 'internal/agentdaemon/proto/version.go').read_text()).group(1) go_env = {**os.environ, 'CGO_ENABLED': '0', 'GOOS': 'linux', 'GOARCH': 'amd64'} @@ -151,7 +152,7 @@ def client(): origin = 'http://localhost:8080' address = '' - def request(path, body=None, headers=None, status=200): + def request(path, body=None, headers=None, status=200, anonymous=False): if body is not None and not isinstance(body, bytes): body = json.dumps(body).encode() req = urllib.request.Request(address + path, data=body, headers={ @@ -159,7 +160,7 @@ def request(path, body=None, headers=None, status=200): 'Content-Type': 'application/json', 'OpenAI-Beta': 'agents=v1', **(headers or {}), }) try: - response = browser.open(req, timeout=30) + response = (client() if anonymous else browser).open(req, timeout=30) except urllib.error.HTTPError as error: response = error with response: @@ -206,9 +207,11 @@ def terminate(_signum, _frame): api = {'Authorization': 'Bearer ' + project_key} assert get('/v1/agents', headers=api)['data'] == [], 'Authenticated API is unavailable' - installer = request('/node-install/node-install.pyz') - checksums = dict(line.split(' ', 1)[::-1] for line in request('/node-install/SHA256SUMS').decode().splitlines()) - assert hashlib.sha256(installer).hexdigest() == checksums['node-install.pyz'], 'Node installer checksum mismatch' + assert facts['source_commit'] == pins['revision'] and facts['node_installation'], 'Missing matching Core distribution' + download = '/api/v1/sandbox-node/install/releases/' + facts['source_commit'] + '/' + installer = request(download + 'node-install.pyz', anonymous=True) + checksums = dict(line.split(' ', 1)[::-1] for line in request(download + 'SHA256SUMS', anonymous=True).decode().splitlines()) + assert hashlib.sha256(installer).hexdigest() == checksums['node-install.pyz'] == facts['node_installation']['installer_sha256'], 'Node installer checksum mismatch' boundary = 'oac-compose-smoke' content = b'x' * (5 * 1024 * 1024) body = (f'--{boundary}\r\nContent-Disposition: form-data; name="purpose"\r\n\r\nuser_data\r\n' diff --git a/scripts/patch-agents-openapi.py b/scripts/patch-agents-openapi.py index 9239b0976..7a1148a1c 100644 --- a/scripts/patch-agents-openapi.py +++ b/scripts/patch-agents-openapi.py @@ -74,6 +74,19 @@ def main() -> None: raise ValueError("Expected one Runtime observation reason field") observation = observation.replace(marker, marker + " pattern: '" + fixture["schema_pattern"] + "'\n") document = document[:start] + observation + document[end:] + # The catalog owns checksum syntax; response schemas use strict ECMA endings. + catalog = (Path(__file__).resolve().parent.parent / "services/core/internal/nativeinstaller/catalog.go").read_text() + checksum = re.search(r'const checksumPattern = "([^"]+)"', catalog) + if checksum is None: + raise ValueError("Missing installer checksum declaration") + start = document.index(" nativeinstaller.NodeInstallation:\n") + end = re.search(r"^ \S", document[start + 1:], re.M).start() + start + 1 + node = document[start:end] + marker = " installer_sha256:\n type: string\n" + if node.count(marker) != 1: + raise ValueError("Expected one node installer checksum field") + node = node.replace(marker, marker + " pattern: '^(?:" + checksum.group(1) + ")(?![\\s\\S])'\n") + document = document[:start] + node + document[end:] series_start = " v1.RuntimeHistorySeries:\n" series_end = "\n v1.RuntimeHistoryTime:\n" if document.count(series_start) != 1 or document.count(series_end) != 1: diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index ab354b4cf..88197bf02 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -6,7 +6,9 @@ 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 `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. +`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, and `Distribution`, which is nil when no matched installation artifacts are installed. 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. + +`cmd/server` loads and closes one `nativeinstaller.Catalog` for the [installation distributions](../../docs/configuration.md#appendix-core-environment-without-the-installer). The administration installation read, deployment selection and machine downloads share it. Provider artifact selectors and validation come from each registered deployment policy; the catalog adds no provider-specific mapping. `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. diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 9d7661088..edb010c2d 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -208,13 +208,12 @@ func run(config processconfig.Config) error { linkRelay := relay.New(links) defer linkRelay.Close() connections := &runtimeenrollment.Connections{Store: sessionStore, Links: linkRelay} - var catalog *nativeinstaller.Catalog - if config.NativeInstallers != "" { - catalog, err = nativeinstaller.Load(config.NativeInstallers, buildRevision) - if err != nil { - return err - } + catalog, err := nativeinstaller.Load(config.ProviderPaths.ArtifactRoot, buildRevision, sandboxProviders) + if err != nil { + return err } + defer catalog.Close() + var nativeInstaller *api.NativeInstaller if buildRevision != "" { nativeInstaller = &api.NativeInstaller{Version: buildRevision, Base: config.PublicOrigin.InstallerBase(), Catalog: catalog} @@ -306,7 +305,7 @@ func run(config processconfig.Config) error { } deps := api.Dependencies{ Engine: config.DefaultHarness, Harnesses: config.Harnesses, CoreKeys: config.CoreKeys, - Installation: installationFacts(config), InstallationBindings: deploymentService, + Installation: installationFacts(config), InstallationBindings: deploymentService, Distribution: catalog, Projects: projectService, ProjectsReader: projectStore, ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore, Vaults: vaultService, VaultsReader: vaultStore, diff --git a/services/core/internal/api/contract_routes_test.go b/services/core/internal/api/contract_routes_test.go index 69ed45da8..b79089c65 100644 --- a/services/core/internal/api/contract_routes_test.go +++ b/services/core/internal/api/contract_routes_test.go @@ -69,7 +69,8 @@ func contractOperations(t *testing.T, file, prefix string) map[string]bool { func TestContractsPublishExactlyTheRegisteredCoreAndMachineRoutes(t *testing.T) { // 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{}} + deps.Execution.NativeInstaller = &NativeInstaller{Version: "contract-test", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Artifacts: map[string]nativeinstaller.Artifact{"linux-amd64": {}}}} + deps.Distribution = &nativeinstaller.Catalog{} h := &Handler{Dependencies: deps} contracts := map[string]string{"/v1": "openapi.yaml", "/core/v1": "core.openapi.yaml", "/api/v1": "runtime.openapi.yaml"} published := map[string]map[string]bool{} diff --git a/services/core/internal/api/core_store_validation_test.go b/services/core/internal/api/core_store_validation_test.go index bd950b480..e676035ee 100644 --- a/services/core/internal/api/core_store_validation_test.go +++ b/services/core/internal/api/core_store_validation_test.go @@ -46,7 +46,7 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { {upperCapacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request", writeDeploymentError}, {capacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request", writeDeploymentError}, {resourceErr, "invalid_sandbox_configuration", "resources.cpus", map[string]any{"min": float64(1), "max": float64(255)}, "invalid sandbox configuration: cpus must be 1..255 and memory_mib must be 512..1048576", "invalid_sandbox_configuration", writeDeploymentError}, - {runtimeErr, "invalid_sandbox_configuration", "runtime", nil, "invalid sandbox configuration: managed nodes require a pinned Runtime release", "invalid_sandbox_configuration", writeDeploymentError}, + {runtimeErr, "invalid_sandbox_configuration", "runtime", nil, "invalid sandbox configuration: this Core has no matching installation distribution for the selected provider", "invalid_sandbox_configuration", writeDeploymentError}, } { if tc.err == nil { t.Fatal("missing validator error") diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 68227eb9c..5ff14a52d 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -24,6 +25,8 @@ type Dependencies struct { // InstallationBindings counts what is bound to the current public URL. Installation Installation InstallationBindings InstallationBindings + // Distribution is nil when no matched installation artifacts are installed. + Distribution *nativeinstaller.Catalog Projects Projects ProjectsReader ProjectsReader diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index d4268234a..53f4d42b2 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -19,7 +19,7 @@ type NativeInstaller struct { Version string // Base is the public URL prefix of the versioned installer downloads. Base string - // Catalog holds the matching installation artifacts. It is nil when the + // Catalog holds installation artifacts; NativeAvailable is false when the // operator installed none: installations then report unavailable and the // grant routes answer 503 installation_unavailable. Catalog *nativeinstaller.Catalog @@ -32,7 +32,7 @@ func (h *Handler) installationFor(ctx context.Context, principal identity.Princi return result, nil } result.Version = installer.Version - if installer.Catalog == nil { + if !installer.Catalog.NativeAvailable() { return result, nil } token, expires, err := h.Environments.AuthorizeEnvironmentInstallation(ctx, principal, environment, installer.Version) @@ -63,7 +63,7 @@ func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, // are registered only when this Core serves a native installer. func (h *Handler) installationAuthorization(w http.ResponseWriter, r *http.Request) (sessions.InstallationAuthorization, string, bool) { w.Header().Set("Cache-Control", "no-store") - if h.Execution.NativeInstaller.Catalog == nil { + if !h.Execution.NativeInstaller.Catalog.NativeAvailable() { writeError(w, 503, "installation_unavailable", "Matching native installation artifacts are unavailable.") return sessions.InstallationAuthorization{}, "", false } diff --git a/services/core/internal/api/environment_installation_test.go b/services/core/internal/api/environment_installation_test.go index 86a22ccd6..4be77d6db 100644 --- a/services/core/internal/api/environment_installation_test.go +++ b/services/core/internal/api/environment_installation_test.go @@ -39,7 +39,7 @@ 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.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build"}} + deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build", Artifacts: map[string]nativeinstaller.Artifact{"linux-amd64": {}}}} 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"}}}` r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) diff --git a/services/core/internal/api/installation.go b/services/core/internal/api/installation.go index aae2c35d9..3cbca64fb 100644 --- a/services/core/internal/api/installation.go +++ b/services/core/internal/api/installation.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" ) type InstallationService string @@ -28,6 +29,8 @@ type Installation struct { LocalOnly bool `json:"local_only" binding:"required"` // Full source commit Core was built from; null for development builds. SourceCommit *string `json:"source_commit" extensions:"x-nullable" binding:"required"` + // Matched node installer and currently servable Provider releases; null when absent. + NodeInstallation *nativeinstaller.NodeInstallation `json:"node_installation" extensions:"x-nullable" binding:"required"` // The process settings Core loaded. Configuration InstallationConfiguration `json:"configuration" binding:"required"` AddressBindings deployment.AddressBindings `json:"address_bindings" binding:"required"` @@ -74,5 +77,6 @@ func (h *Handler) getInstallation(w http.ResponseWriter, r *http.Request) { } value := h.Installation value.AddressBindings = bindings + value.NodeInstallation = h.Distribution.NodeInstallation() writeJSON(w, http.StatusOK, value) } diff --git a/services/core/internal/api/installation_distribution_test.go b/services/core/internal/api/installation_distribution_test.go new file mode 100644 index 000000000..beeaa9077 --- /dev/null +++ b/services/core/internal/api/installation_distribution_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" +) + +func installationCatalog(t *testing.T) *nativeinstaller.Catalog { + t.Helper() + root := t.TempDir() + revision := strings.Repeat("a", 40) + manifest := map[string]any{"source_commit": revision, "platform": "linux/amd64", "artifact_base_url": "https://downloads.example/v1", + "images": map[string]string{"runtime": "sha256:" + strings.Repeat("1", 64)}, "image_manifest_digests": map[string]string{"runtime": "sha256:" + strings.Repeat("2", 64)}, + "runtime_ref": "oac-runtime@sha256:" + strings.Repeat("3", 64), "microsandbox": map[string]string{"runtime_sha256": strings.Repeat("4", 64), "firmware_sha256": strings.Repeat("5", 64)}} + requirements, err := providers.Builtin().ArtifactCatalog() + if err != nil { + t.Fatal(err) + } + entries := map[string]any{} + for _, artifacts := range requirements { + for _, artifact := range artifacts { + entries[artifact.Path] = map[string]any{"filename": revision + "-" + artifact.Suffix, "size": 8, "sha256": strings.Repeat("1", 64)} + } + } + manifest["artifacts"] = entries + raw, _ := json.Marshal(manifest) + files := map[string][]byte{"manifest.json": raw, "node-install.pyz": []byte("installer"), "runtime/seccomp.json": []byte("{}")} + sums := "" + for name, data := range files { + sums += fmt.Sprintf("%x %s\n", sha256.Sum256(data), name) + } + files["SHA256SUMS"] = []byte(sums) + for name, data := range files { + path := filepath.Join(root, "node-payload", "releases", revision, name) + if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.WriteFile(path, data, 0600) != nil { + t.Fatal("fixture publication") + } + } + if err := os.WriteFile(filepath.Join(root, "node-payload", "active.json"), []byte(`{"source_commit":"`+revision+`"}`), 0600); err != nil { + t.Fatal(err) + } + catalog, err := nativeinstaller.Load(root, revision, providers.Builtin()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { catalog.Close() }) + return catalog +} + +func TestInstallationDistributionOwnsDeploymentReleaseAndAnonymousDownloads(t *testing.T) { + deps, fakes := sandboxFakes(t) + deps.Distribution = installationCatalog(t) + source := strings.Repeat("a", 40) + deps.Installation.SourceCommit = &source + fakes.installationBindings.addressBindings = func(context.Context) (deployment.AddressBindings, error) { return deployment.AddressBindings{}, nil } + fakes.deployment.decodeConfiguration = providers.Builtin().DecodeInput + calls := 0 + selectRelease := func(_ context.Context, input sandbox.Selection) (deployment.View, error) { + calls++ + if input.Provider == "e2b" { + if input.Runtime != nil { + t.Fatal("E2B acquired a node release") + } + } else if !reflect.DeepEqual(input.Runtime, deps.Distribution.RuntimeRelease(input.Provider)) { + t.Fatal("not the installation's release", input.Runtime) + } + return deployment.View{Provider: input.Provider, Specification: &input.DeploymentSpec}, nil + } + fakes.deploymentChanges.initializeSandboxDeployment = selectRelease + fakes.deploymentChanges.updateSandboxDeployment = selectRelease + h := newTestHandler(t, deps) + for _, method := range []string{"POST", "PUT"} { + req := httptest.NewRequest(method, "/core/v1/sandbox/deployment", strings.NewReader(`{"provider":"docker","expected_generation":0,"resources":{"cpus":2,"memory_mib":2048}}`)) + req.Header.Set("Authorization", "Bearer administrator") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"image_id":"sha256:`) { + t.Fatal(method, w.Code, w.Body.String()) + } + } + if calls != 2 { + t.Fatal(calls) + } + request := httptest.NewRequest("GET", "/core/v1/installation", nil) + request.Header.Set("Authorization", "Bearer administrator") + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"node_installation":{"installer_sha256":`) || !strings.Contains(w.Body.String(), `"runtime_releases":{"docker":`) { + t.Fatal(w.Code, w.Body.String()) + } + for _, method := range []string{"GET", "HEAD"} { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(method, "/api/v1/sandbox-node/install/releases/"+source+"/node-install.pyz", nil)) + if w.Code != 200 || method == "HEAD" && w.Body.Len() != 0 { + t.Fatal(method, w.Code, w.Body.String()) + } + } + // A node catalog alone must not enable native installation grants or downloads. + if deps.Distribution.NativeAvailable() { + t.Fatal("node catalog implies native artifacts") + } +} + +func TestMissingInstallationReleaseReachesDeploymentValidation(t *testing.T) { + deps, fakes := sandboxFakes(t) + fakes.deployment.decodeConfiguration = providers.Builtin().DecodeInput + fakes.deploymentChanges.initializeSandboxDeployment = func(_ context.Context, input sandbox.Selection) (deployment.View, error) { + if input.Runtime != nil { + t.Fatal("missing catalog supplied runtime") + } + if input.ExpectedGeneration != 2 { + return deployment.View{}, &deployment.GenerationStaleError{CurrentGeneration: 2} + } + return deployment.View{}, providers.Builtin().ValidateSpecification(input.Provider, input.DeploymentSpec) + } + h := newTestHandler(t, deps) + for _, tc := range []struct { + provider string + generation, status int + code string + }{{"docker", 1, 409, "sandbox_generation_stale"}, {"docker", 2, 400, "invalid_sandbox_configuration"}, {"e2b", 2, 200, ""}} { + req := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(fmt.Sprintf(`{"provider":%q,"expected_generation":%d,"resources":{"cpus":2,"memory_mib":2048},"configuration":{}}`, tc.provider, tc.generation))) + req.Header.Set("Authorization", "Bearer administrator") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != tc.status || tc.code != "" && !strings.Contains(w.Body.String(), tc.code) { + t.Fatal(tc, w.Code, w.Body.String()) + } + } +} diff --git a/services/core/internal/api/installation_test.go b/services/core/internal/api/installation_test.go index b6e35df20..7150cdc03 100644 --- a/services/core/internal/api/installation_test.go +++ b/services/core/internal/api/installation_test.go @@ -63,6 +63,8 @@ func TestDeploymentAddressIsNotInput(t *testing.T) { method, body, code string status int }{ + {http.MethodPost, `{"provider":"docker","runtime":null,"expected_generation":0}`, `"code":"invalid_request"`, http.StatusBadRequest}, + {http.MethodPut, `{"provider":"docker","runtime":{},"expected_generation":1}`, `"code":"invalid_request"`, http.StatusBadRequest}, {http.MethodPost, `{"provider":"docker","core_url":"https://core.example","expected_generation":0}`, `"code":"invalid_request"`, http.StatusBadRequest}, {http.MethodPut, `{"provider":"docker","core_url":"https://core.example","expected_generation":1}`, `"code":"invalid_request"`, http.StatusBadRequest}, {http.MethodPost, `{"provider":"e2b","expected_generation":0,"credential":{"api_key":"key"},"configuration":{"template":"runtime:build"}}`, `"code":"sandbox_configuration_error"`, http.StatusConflict}, diff --git a/services/core/internal/api/machine_routes.go b/services/core/internal/api/machine_routes.go index d0a0253b0..81af95128 100644 --- a/services/core/internal/api/machine_routes.go +++ b/services/core/internal/api/machine_routes.go @@ -84,7 +84,7 @@ func (h *Handler) registerMachineRoutes(router chi.Router) { r.Handle("/agent-daemon/connection", h.Execution.Connection) if installer := h.Execution.NativeInstaller; installer != nil { - if installer.Catalog != nil { + if installer.Catalog.NativeAvailable() { // @Summary Download native installation content // @Description Public versioned bootstrap script, checksum or Linux amd64 archive. An archive may redirect to its qualified release URL; local archives support conditional and range requests. GET and HEAD share the download headers. // @Tags Native Installation @@ -103,6 +103,23 @@ func (h *Handler) registerMachineRoutes(router chi.Router) { r.Post("/agent-daemon/installation/claim", h.claimNativeInstallation) } + if h.Distribution != nil { + // @Summary Download sandbox node installation content + // @Description Public matched Linux amd64 installer, metadata or declared artifact. Versioned releases remain addressable; artifacts may redirect to pinned HTTPS release URLs. Local downloads support conditional and range requests. + // @Tags Sandbox Nodes + // @Produce octet-stream + // @Param path path string true "Metadata filename, artifacts/{filename}, or releases/{source_commit}/{filename}" + // @Success 200 {file} file + // @Success 206 {file} file + // @Success 304 "Not Modified" + // @Success 307 "Temporary Redirect" + // @Failure 400,403,404,405,416,500 {object} v1.ErrorResponse + // @Router /api/v1/sandbox-node/install/{path} [get] + // @Router /api/v1/sandbox-node/install/{path} [head] + r.Get("/sandbox-node/install/*", h.Distribution.ServeNodeHTTP) + r.Head("/sandbox-node/install/*", h.Distribution.ServeNodeHTTP) + } + // @Summary Open a sandbox Link // @Description Upgrades to a WebSocket that carries the Sandbox link protocol. The Sandbox I/O service connects as the serve peer and the agent-host Runtime as the attach peer. Each peer authenticates in its Link Hello after the upgrade. // @Tags Sandbox Link diff --git a/services/core/internal/api/machine_routes_test.go b/services/core/internal/api/machine_routes_test.go index 73a6e7693..31f364f09 100644 --- a/services/core/internal/api/machine_routes_test.go +++ b/services/core/internal/api/machine_routes_test.go @@ -67,7 +67,7 @@ func TestMachineRoutesPreserveAuthorityAndMethodPrecedence(t *testing.T) { deps.Execution.Enrollment = runtimeenrollment.EnrollmentHandler(store, origin) deps.Execution.Connection = &runtimeenrollment.Connections{Store: store, Links: links} deps.Execution.Links = links - deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build"}} + deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build", Artifacts: map[string]nativeinstaller.Artifact{"linux-amd64": {}}}} nodeID := uuid.NewString() hub := node.NewHub(node.HubOptions{Authenticate: func(_ context.Context, id, credential string) (node.Identity, error) { store.calls++ diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index cd899671b..b553dfca2 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -16,11 +16,10 @@ type SandboxDeploymentInput struct { ExpectedGeneration *uint64 `json:"expected_generation" binding:"required"` // Per-sandbox limits, required for Docker and microsandbox. E2B may omit // them; Core then uses the validated template build's cpus and memory_mib. - Resources sandbox.Resources `json:"resources"` - Runtime *sandbox.RuntimeRelease `json:"runtime,omitempty"` - Provider string `json:"provider"` - Configuration json.RawMessage `json:"configuration" swaggertype:"object"` - Credential json.RawMessage `json:"credential,omitempty" swaggertype:"object"` + Resources sandbox.Resources `json:"resources"` + Provider string `json:"provider"` + Configuration json.RawMessage `json:"configuration" swaggertype:"object"` + Credential json.RawMessage `json:"credential,omitempty" swaggertype:"object"` } type SandboxDeploymentChangeInput struct { @@ -33,7 +32,7 @@ func (h *Handler) selection(v SandboxDeploymentInput) (sandbox.Selection, error) if err != nil { return sandbox.Selection{}, err } - return sandbox.Selection{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}, Configuration: c}, nil + return sandbox.Selection{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: h.Distribution.RuntimeRelease(v.Provider)}, Configuration: c}, nil } // DeploymentChanges sets up and updates the sandbox deployment through the @@ -51,7 +50,7 @@ type DeploymentReset interface { } // @Summary Initialize the deployment sandbox provider -// @Description Selects a provider, enforced resource limits and pinned Runtime release. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. Every provider returns 409 sandbox_configuration_error while the public URL is loopback or not https. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. +// @Description Selects a provider and enforced resource limits. Core supplies the pinned Runtime release from its matching installation distribution and rejects runtime input. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. Every provider returns 409 sandbox_configuration_error while the public URL is loopback or not https. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -66,7 +65,7 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req return } var input SandboxDeploymentInput - if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil { + if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "expected_generation") != nil || input.ExpectedGeneration == nil { writeDeploymentError(w, r, deployment.ErrInvalidInput) return } @@ -99,7 +98,7 @@ func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request return } var input SandboxDeploymentChangeInput - if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil { + if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "expected_generation") != nil || input.ExpectedGeneration == nil { writeDeploymentError(w, r, deployment.ErrInvalidInput) return } diff --git a/services/core/internal/nativeinstaller/catalog.go b/services/core/internal/nativeinstaller/catalog.go index 900209e56..2ef0d0ad9 100644 --- a/services/core/internal/nativeinstaller/catalog.go +++ b/services/core/internal/nativeinstaller/catalog.go @@ -19,6 +19,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" ) //go:embed assets/* @@ -35,14 +36,51 @@ type Catalog struct { Artifacts map[string]Artifact `json:"artifacts"` directory string local map[string]bool + nodes *nodePayload } -var checksum = regexp.MustCompile(`^[0-9a-f]{64}$`) +const checksumPattern = "[0-9a-f]{64}" -// Load checks the matched catalog without downloading execution payloads. Local +var checksum = regexp.MustCompile("^" + checksumPattern + "$") + +// Load reads the installation's existing catalogs from their artifact root. +// A development build or absent artifacts has no qualified distribution. +func Load(root, version string, registry *providers.Registry) (*Catalog, error) { + if root == "" || version == "" { + return nil, nil + } + c, err := loadNative(filepath.Join(root, "native-installers"), version) + if err != nil { + return nil, err + } + nodes, err := loadNodes(filepath.Join(root, "node-payload"), version, registry) + if err != nil { + return nil, err + } + if c == nil && nodes == nil { + return nil, nil + } + if c == nil { + c = &Catalog{Version: version} + } + c.nodes = nodes + return c, nil +} + +// NativeAvailable distinguishes native artifacts from a node-only catalog. +func (c *Catalog) NativeAvailable() bool { return c != nil && len(c.Artifacts) != 0 } + +func (c *Catalog) Close() error { + if c != nil && c.nodes != nil { + return c.nodes.root.Close() + } + return nil +} + +// loadNative checks the matched catalog without downloading execution payloads. Local // offline archives are verified once; the directory stays immutable while serving. // A directory without catalog.json holds no installer and returns nil. -func Load(directory, version string) (*Catalog, error) { +func loadNative(directory, version string) (*Catalog, error) { raw, err := os.ReadFile(filepath.Join(directory, "catalog.json")) if errors.Is(err, os.ErrNotExist) { return nil, nil diff --git a/services/core/internal/nativeinstaller/catalog_test.go b/services/core/internal/nativeinstaller/catalog_test.go index fba4024dc..abe6b8933 100644 --- a/services/core/internal/nativeinstaller/catalog_test.go +++ b/services/core/internal/nativeinstaller/catalog_test.go @@ -27,10 +27,10 @@ func TestCatalogRequiresMatchedImmutableArtifacts(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "linux-amd64.tar.gz"), data, 0600); err != nil { t.Fatal(err) } - if _, err := Load(dir, "wrong-build"); err == nil { + if _, err := loadNative(dir, "wrong-build"); err == nil { t.Fatal("accepted mismatched Core") } - catalog, err := Load(dir, "build") + catalog, err := loadNative(dir, "build") if err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func TestCatalogRequiresMatchedImmutableArtifacts(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "linux-amd64.tar.gz"), []byte("changed"), 0600); err != nil { t.Fatal(err) } - if _, err := Load(dir, "build"); err == nil { + if _, err := loadNative(dir, "build"); err == nil { t.Fatal("accepted corrupt archive") } } @@ -69,7 +69,7 @@ func TestOnlineCatalogRedirectsOnlyDeclaredMatchedArchives(t *testing.T) { } } save() - catalog, err := Load(dir, "build") + catalog, err := loadNative(dir, "build") if err != nil { t.Fatal(err) } @@ -86,7 +86,7 @@ func TestOnlineCatalogRedirectsOnlyDeclaredMatchedArchives(t *testing.T) { modified.URL = bad manifest.Artifacts["linux-amd64"] = modified save() - if _, err := Load(dir, "build"); err == nil { + if _, err := loadNative(dir, "build"); err == nil { t.Fatalf("accepted %s", bad) } manifest.Artifacts["linux-amd64"] = previous @@ -95,7 +95,7 @@ func TestOnlineCatalogRedirectsOnlyDeclaredMatchedArchives(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "linux-amd64.tar.gz"), payload, 0600); err != nil { t.Fatal(err) } - catalog, err = Load(dir, "build") + catalog, err = loadNative(dir, "build") if err != nil { t.Fatal(err) } @@ -107,13 +107,13 @@ func TestOnlineCatalogRedirectsOnlyDeclaredMatchedArchives(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "linux-amd64.tar.gz"), []byte("corrupt"), 0600); err != nil { t.Fatal(err) } - if _, err := Load(dir, "build"); err == nil { + if _, err := loadNative(dir, "build"); err == nil { t.Fatal("corruption must not fall back to online download") } } func TestMissingCatalogServesNoInstallers(t *testing.T) { - if catalog, err := Load(t.TempDir(), "build"); catalog != nil || err != nil { + if catalog, err := loadNative(t.TempDir(), "build"); catalog != nil || err != nil { t.Fatal(catalog, err) } } @@ -127,7 +127,7 @@ func TestCatalogRejectsUnsupportedPlatforms(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "catalog.json"), raw, 0600); err != nil { t.Fatal(err) } - if _, err := Load(dir, "build"); err == nil { + if _, err := loadNative(dir, "build"); err == nil { t.Fatal("unsupported platform accepted") } }) @@ -188,3 +188,24 @@ func TestDownloadResponsesKeepConditionalRangesAndJSONErrors(t *testing.T) { } } } + +func TestInstallerChecksumsMatchSharedFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/checksums.json") + if err != nil { + t.Fatal(err) + } + var fixture struct{ Valid, Invalid []string } + if err = json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + for _, value := range fixture.Valid { + if !checksum.MatchString(value) { + t.Fatalf("valid checksum rejected: %q", value) + } + } + for _, value := range fixture.Invalid { + if checksum.MatchString(value) { + t.Fatalf("invalid checksum accepted: %q", value) + } + } +} diff --git a/services/core/internal/nativeinstaller/node_manifest.go b/services/core/internal/nativeinstaller/node_manifest.go new file mode 100644 index 000000000..d93c95aa0 --- /dev/null +++ b/services/core/internal/nativeinstaller/node_manifest.go @@ -0,0 +1,113 @@ +package nativeinstaller + +import ( + "encoding/json" + "errors" + "net/url" + "regexp" + "strings" + "unicode" +) + +type nodeArtifact struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type nodeManifest struct { + SourceCommit string `json:"source_commit"` + ArtifactBaseURL string `json:"artifact_base_url"` + Artifacts map[string]nodeArtifact `json:"artifacts"` + allowed map[string]bool + object map[string]any +} + +func (p *nodePayload) readNodeManifest(prefix string) (nodeManifest, error) { + raw, err := p.readMetadata(prefix + "manifest.json") + if err != nil { + return nodeManifest{}, err + } + return p.parseNodeManifest(prefix, raw) +} + +func (p *nodePayload) parseNodeManifest(prefix string, raw []byte) (nodeManifest, error) { + var manifest nodeManifest + if json.Unmarshal(raw, &manifest.object) != nil { + return manifest, errors.New("invalid node manifest") + } + manifest.SourceCommit, _ = manifest.object["source_commit"].(string) + if value := manifest.object["artifact_base_url"]; value != nil { + var ok bool + manifest.ArtifactBaseURL, ok = value.(string) + if !ok { + return manifest, errors.New("invalid node artifact base URL") + } + } + manifest.Artifacts = map[string]nodeArtifact{} + entries, ok := manifest.object["artifacts"].(map[string]any) + if manifest.object["artifacts"] != nil && !ok { + return manifest, errors.New("invalid node artifacts") + } + for logical, value := range entries { + object, ok := value.(map[string]any) + if !ok { + return manifest, errors.New("invalid node artifact") + } + var entry nodeArtifact + entry.Filename, _ = object["filename"].(string) + entry.SHA256, _ = object["sha256"].(string) + if !nodeArtifactName.MatchString(entry.Filename) || !checksum.MatchString(entry.SHA256) { + return manifest, errors.New("invalid node artifact identity") + } + size, _ := object["size"].(float64) + if size <= 0 || size > 1<<53 || size != float64(int64(size)) { + return manifest, errors.New("invalid node artifact size") + } + entry.Size = int64(size) + manifest.Artifacts[logical] = entry + } + + if prefix != "releases/"+manifest.SourceCommit+"/" { + return manifest, errors.New("node manifest release mismatch") + } + manifest.allowed = p.allowed + if manifest.ArtifactBaseURL != "" { + for logical, entry := range manifest.Artifacts { + if p.allowed[logical] && manifest.artifactURL(entry.Filename) == "" { + return manifest, errors.New("invalid pinned node artifact URL") + } + } + } + return manifest, nil +} + +var nodeArtifactName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// Release locations come only from the installed, verified distribution manifest. +// Core redirects missing artifacts instead of downloading or caching them itself. +func (m nodeManifest) artifactURL(filename string) string { + if !payloadRevision.MatchString(m.SourceCommit) || !nodeArtifactName.MatchString(filename) || !strings.Contains(filename, m.SourceCommit) { + return "" + } + allowed := false + for logical, entry := range m.Artifacts { + if m.allowed[logical] && entry.Filename == filename && entry.Size > 0 && checksum.MatchString(entry.SHA256) { + allowed = true + break + } + } + if !allowed { + return "" + } + base, err := url.Parse(m.ArtifactBaseURL) + if err != nil || base.Scheme != "https" || base.Hostname() == "" || base.User != nil || base.RawQuery != "" || base.ForceQuery || base.Fragment != "" || strings.Trim(base.Path, "/") == "" || strings.Contains(m.ArtifactBaseURL, "\\") || strings.IndexFunc(m.ArtifactBaseURL, unicode.IsSpace) >= 0 { + return "" + } + for _, part := range strings.Split(strings.ToLower(base.Path), "/") { + if part == "latest" { + return "" + } + } + return strings.TrimRight(base.String(), "/") + "/" + filename +} diff --git a/services/core/internal/nativeinstaller/node_payload.go b/services/core/internal/nativeinstaller/node_payload.go new file mode 100644 index 000000000..52a4a5fe3 --- /dev/null +++ b/services/core/internal/nativeinstaller/node_payload.go @@ -0,0 +1,266 @@ +package nativeinstaller + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "os" + "regexp" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" +) + +const nodeInstallPath = "/api/v1/sandbox-node/install/" + +var payloadRevision = regexp.MustCompile("^(?:" + sandbox.SourceCommitPattern + ")$") +var nodePayloadFiles = map[string]bool{ + "node-install.pyz": true, "manifest.json": true, "SHA256SUMS": true, "runtime/seccomp.json": true, +} + +// NodeInstallation reports the matched installer and currently servable releases. +// Its source revision is the installation's source_commit. +type NodeInstallation struct { + InstallerSHA256 string `json:"installer_sha256" binding:"required"` + RuntimeReleases map[string]sandbox.RuntimeRelease `json:"runtime_releases" binding:"required"` +} + +type nodePayload struct { + root *os.Root + prefix string + digest string + manifest nodeManifest + releases map[string]sandbox.RuntimeRelease + artifacts map[string][]providerassets.Artifact + allowed map[string]bool +} + +func loadNodes(directory, version string, registry *providers.Registry) (_ *nodePayload, err error) { + root, err := os.OpenRoot(directory) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer func() { + if err != nil { + root.Close() + } + }() + raw, err := root.ReadFile("active.json") + var active struct { + SourceCommit string `json:"source_commit"` + } + if err != nil || len(raw) > 256 || json.Unmarshal(raw, &active) != nil || !payloadRevision.MatchString(version) || active.SourceCommit != version { + return nil, errors.New("node payload does not match this Core build") + } + artifacts, err := registry.ArtifactCatalog() + if err != nil { + return nil, err + } + p := &nodePayload{root: root, prefix: "releases/" + version + "/", artifacts: artifacts, allowed: map[string]bool{}, releases: map[string]sandbox.RuntimeRelease{}} + for _, entries := range artifacts { + for _, artifact := range entries { + p.allowed[artifact.Path] = true + } + } + // Initialization verifies immutable metadata before publishing it. Verify it + // here as well for installations assembled without the initialization image. + sumsRaw, err := p.readMetadata(p.prefix + "SHA256SUMS") + if err != nil { + return nil, err + } + sums := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(sumsRaw)), "\n") { + sum, name, ok := strings.Cut(line, " ") + if !ok || !checksum.MatchString(sum) || sums[name] != "" { + return nil, errors.New("invalid node metadata checksums") + } + sums[name] = sum + } + var manifestRaw []byte + for name := range nodePayloadFiles { + if name == "SHA256SUMS" { + continue + } + data, e := p.readMetadata(p.prefix + name) + if e != nil { + return nil, e + } + if name == "manifest.json" { + manifestRaw = data + } + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != sums[name] { + return nil, fmt.Errorf("node metadata checksum mismatch: %s", name) + } + } + p.digest = sums["node-install.pyz"] + p.manifest, err = p.parseNodeManifest(p.prefix, manifestRaw) + if err != nil { + return nil, err + } + if p.manifest.object["platform"] != "linux/amd64" { + return nil, errors.New("node payload requires Linux amd64") + } + for provider := range artifacts { + adapter, e := registry.Lookup(provider) + if e != nil { + return nil, e + } + release := sandbox.RuntimeRelease{SourceCommit: version, Artifacts: map[string]string{}} + for name, rule := range adapter.Policy.Artifacts { + var value any = p.manifest.object + for _, key := range rule.ManifestPath { + object, ok := value.(map[string]any) + if !ok { + value = nil + break + } + value = object[key] + } + release.Artifacts[name], _ = value.(string) + } + if e = release.Validate(adapter.Policy.Artifacts); e != nil { + return nil, fmt.Errorf("invalid node distribution for %s: %w", provider, e) + } + p.releases[provider] = release + } + p.manifest.object = nil + return p, nil +} + +func (p *nodePayload) readMetadata(name string) ([]byte, error) { + f, err := p.root.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > 1<<20 { + return nil, errors.New("invalid node metadata file") + } + raw, err := io.ReadAll(io.LimitReader(f, 1<<20+1)) + if len(raw) > 1<<20 { + return nil, errors.New("node metadata is too large") + } + return raw, err +} + +func (p *nodePayload) available(provider string) bool { + entries, ok := p.artifacts[provider] + if !ok { + return false + } + for _, artifact := range entries { + entry, ok := p.manifest.Artifacts[artifact.Path] + if !ok || !nodeArtifactName.MatchString(entry.Filename) || entry.Size <= 0 || !checksum.MatchString(entry.SHA256) { + return false + } + info, err := p.root.Stat(p.prefix + "artifacts/" + entry.Filename) + local := err == nil && info.Mode().IsRegular() && info.Size() == entry.Size + remote := errors.Is(err, os.ErrNotExist) && p.manifest.artifactURL(entry.Filename) != "" + if !local && !remote { + return false + } + } + return true +} + +// RuntimeRelease rechecks local availability, including same-release additions. +func (c *Catalog) RuntimeRelease(provider string) *sandbox.RuntimeRelease { + if c == nil || c.nodes == nil || !c.nodes.available(provider) { + return nil + } + release := c.nodes.releases[provider] + release.Artifacts = maps.Clone(release.Artifacts) + return &release +} + +func (c *Catalog) NodeInstallation() *NodeInstallation { + if c == nil || c.nodes == nil { + return nil + } + result := &NodeInstallation{InstallerSHA256: c.nodes.digest, RuntimeReleases: map[string]sandbox.RuntimeRelease{}} + for provider := range c.nodes.releases { + if release := c.RuntimeRelease(provider); release != nil { + result.RuntimeReleases[provider] = *release + } + } + return result +} + +func (c *Catalog) ServeNodeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + v1.WriteHTTPError(w, 405, "", "Method not allowed") + return + } + notFound := func() { v1.WriteHTTPError(w, 404, "", "404 page not found") } + if c == nil || c.nodes == nil || !strings.HasPrefix(r.URL.Path, nodeInstallPath) || r.URL.EscapedPath() != r.URL.Path { + notFound() + return + } + p := c.nodes + name := strings.TrimPrefix(r.URL.Path, nodeInstallPath) + prefix := p.prefix + manifest := p.manifest + if strings.HasPrefix(name, "releases/") { + parts := strings.SplitN(name, "/", 3) + if len(parts) != 3 || !payloadRevision.MatchString(parts[1]) { + notFound() + return + } + prefix, name = "releases/"+parts[1]+"/", parts[2] + if prefix != p.prefix { + var err error + manifest, err = p.readNodeManifest(prefix) + if err != nil { + notFound() + return + } + } + } + allowed := nodePayloadFiles[name] + if !allowed && strings.HasPrefix(name, "artifacts/") { + filename := strings.TrimPrefix(name, "artifacts/") + for logical, entry := range manifest.Artifacts { + if p.allowed[logical] && nodeArtifactName.MatchString(filename) && entry.Filename == filename { + allowed = true + break + } + } + } + if !allowed { + notFound() + return + } + f, err := p.root.Open(prefix + name) + if err != nil { + if errors.Is(err, os.ErrNotExist) && strings.HasPrefix(name, "artifacts/") { + if target := manifest.artifactURL(strings.TrimPrefix(name, "artifacts/")); target != "" { + http.Redirect(w, r, target, http.StatusTemporaryRedirect) + return + } + } + notFound() + return + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + notFound() + return + } + w.Header().Set("Content-Type", "application/octet-stream") + http.ServeContent(&downloadResponse{ResponseWriter: w}, r, info.Name(), info.ModTime(), f) +} diff --git a/services/core/internal/nativeinstaller/node_payload_test.go b/services/core/internal/nativeinstaller/node_payload_test.go new file mode 100644 index 000000000..56a4dccd8 --- /dev/null +++ b/services/core/internal/nativeinstaller/node_payload_test.go @@ -0,0 +1,331 @@ +package nativeinstaller + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" +) + +const nodeRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func nodeFixture(t *testing.T, remote string) (string, map[string]any) { + t.Helper() + root := t.TempDir() + manifest := map[string]any{"source_commit": nodeRevision, "platform": "linux/amd64", "artifact_base_url": remote, + "images": map[string]string{"runtime": "sha256:" + strings.Repeat("1", 64)}, + "image_manifest_digests": map[string]string{"runtime": "sha256:" + strings.Repeat("2", 64)}, + "runtime_ref": "oac-runtime@sha256:" + strings.Repeat("3", 64), + "microsandbox": map[string]string{"runtime_sha256": strings.Repeat("4", 64), "firmware_sha256": strings.Repeat("5", 64)}} + entries := map[string]any{} + catalog, err := providers.Builtin().ArtifactCatalog() + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte("artifact")) + for _, artifacts := range catalog { + for _, artifact := range artifacts { + entries[artifact.Path] = map[string]any{"filename": nodeRevision + "-" + artifact.Suffix, "size": 8, "sha256": hex.EncodeToString(sum[:])} + } + } + manifest["artifacts"] = entries + publishFixture(t, root, manifest) + return root, manifest +} + +func publishFixture(t *testing.T, root string, manifest map[string]any) { + t.Helper() + prefix := filepath.Join(root, "node-payload", "releases", nodeRevision) + raw, _ := json.Marshal(manifest) + files := map[string][]byte{"manifest.json": raw, "node-install.pyz": []byte("installer"), "runtime/seccomp.json": []byte("{}")} + sums := "" + for name, data := range files { + sum := sha256.Sum256(data) + sums += fmt.Sprintf("%x %s\n", sum, name) + writeNodeFile(t, filepath.Join(prefix, name), data) + } + writeNodeFile(t, filepath.Join(prefix, "SHA256SUMS"), []byte(sums)) + writeNodeFile(t, filepath.Join(root, "node-payload", "active.json"), []byte(`{"source_commit":"`+nodeRevision+`"}`)) +} + +func writeNodeFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} + +func TestNodeCatalogMatchesCoreAndProviderDeclarations(t *testing.T) { + root, _ := nodeFixture(t, "https://downloads.example/v1") + c, err := Load(root, nodeRevision, providers.Builtin()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + snapshot := c.NodeInstallation() + if c.NativeAvailable() || snapshot == nil || len(snapshot.RuntimeReleases) != 2 || c.RuntimeRelease("e2b") != nil { + t.Fatalf("snapshot=%+v", snapshot) + } + release := c.RuntimeRelease("docker") + if release.SourceCommit != nodeRevision || release.Artifacts["image_id"] != "sha256:"+strings.Repeat("1", 64) || len(release.Artifacts) != 2 { + t.Fatal(release) + } + release.Artifacts["image_id"] = "mutated" + if c.RuntimeRelease("docker").Artifacts["image_id"] == "mutated" { + t.Fatal("caller mutated the catalog") + } + if c, err := Load(root, "", providers.Builtin()); c != nil || err != nil { + t.Fatal("development build guessed a release", c, err) + } + if _, err := Load(root, strings.Repeat("b", 40), providers.Builtin()); err == nil { + t.Fatal("mismatched build accepted") + } + if c, err := Load(t.TempDir(), nodeRevision, providers.Builtin()); c != nil || err != nil { + t.Fatal("absent payload", c, err) + } +} + +func TestNodeCatalogRechecksSameReleaseArtifacts(t *testing.T) { + root, manifest := nodeFixture(t, "") + c, err := Load(root, nodeRevision, providers.Builtin()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if len(c.NodeInstallation().RuntimeReleases) != 0 { + t.Fatal("offline artifacts missing") + } + requirements, _ := providers.Builtin().ArtifactCatalog() + entries := manifest["artifacts"].(map[string]any) + for _, artifact := range requirements["docker"] { + entry := entries[artifact.Path].(map[string]any) + writeNodeFile(t, filepath.Join(root, "node-payload", "releases", nodeRevision, "artifacts", entry["filename"].(string)), []byte("artifact")) + } + if len(c.NodeInstallation().RuntimeReleases) != 1 || c.RuntimeRelease("docker") == nil || c.RuntimeRelease("microsandbox") != nil { + t.Fatal("same-release additions not observed") + } + entry := entries[requirements["docker"][0].Path].(map[string]any) + writeNodeFile(t, filepath.Join(root, "node-payload", "releases", nodeRevision, "artifacts", entry["filename"].(string)), []byte("wrong size")) + if c.RuntimeRelease("docker") != nil { + t.Fatal("corrupt local payload reported available") + } +} + +func TestNodeCatalogRejectsMalformedMetadata(t *testing.T) { + for _, test := range []string{"checksum", "source", "platform", "identity", "active", "symlink", "artifact_hash", "artifact_name", "remote", "base_type", "artifacts_type"} { + t.Run(test, func(t *testing.T) { + root, m := nodeFixture(t, "https://downloads.example/v1") + switch test { + case "base_type": + m["artifact_base_url"] = 42 + publishFixture(t, root, m) + case "artifacts_type": + m["artifacts"] = []any{} + publishFixture(t, root, m) + case "artifact_hash": + m["artifacts"].(map[string]any)["native/bin/oac-node"].(map[string]any)["sha256"] = "bad" + publishFixture(t, root, m) + case "artifact_name": + m["artifacts"].(map[string]any)["native/bin/oac-node"].(map[string]any)["filename"] = "../secret" + publishFixture(t, root, m) + case "remote": + m["artifact_base_url"] = "https://downloads.example/latest" + publishFixture(t, root, m) + case "source": + m["source_commit"] = strings.Repeat("b", 40) + publishFixture(t, root, m) + case "platform": + m["platform"] = "linux/arm64" + publishFixture(t, root, m) + case "identity": + m["images"] = map[string]string{"runtime": "invalid"} + publishFixture(t, root, m) + case "checksum": + writeNodeFile(t, filepath.Join(root, "node-payload", "releases", nodeRevision, "node-install.pyz"), []byte("changed")) + case "active": + writeNodeFile(t, filepath.Join(root, "node-payload", "active.json"), []byte(`{}`)) + case "symlink": + path := filepath.Join(root, "node-payload", "releases", nodeRevision, "node-install.pyz") + os.Remove(path) + if err := os.Symlink(filepath.Join(t.TempDir(), "secret"), path); err != nil { + t.Fatal(err) + } + } + if c, err := Load(root, nodeRevision, providers.Builtin()); err == nil { + c.Close() + t.Fatal("invalid metadata accepted") + } + }) + } +} + +func TestNodeDownloadsKeepAllowlistRangesAndPinnedRedirects(t *testing.T) { + root, m := nodeFixture(t, "https://downloads.example/v1") + c, err := Load(root, nodeRevision, providers.Builtin()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + prefix := nodeInstallPath + "releases/" + nodeRevision + "/" + get := func(method, path string, headers http.Header) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, nil) + r.Header = headers + w := httptest.NewRecorder() + c.ServeNodeHTTP(w, r) + return w + } + full := get("GET", prefix+"node-install.pyz", nil) + if full.Code != 200 || full.Body.String() != "installer" { + t.Fatal(full) + } + head := get("HEAD", prefix+"node-install.pyz", nil) + if head.Code != 200 || head.Body.Len() != 0 || head.Header().Get("Content-Length") != "9" { + t.Fatal(head) + } + partial := get("GET", prefix+"node-install.pyz", http.Header{"Range": []string{"bytes=1-3"}}) + if partial.Code != 206 || partial.Body.String() != "nst" { + t.Fatal(partial) + } + unchanged := get("GET", prefix+"node-install.pyz", http.Header{"If-Modified-Since": []string{full.Header().Get("Last-Modified")}}) + if unchanged.Code != 304 { + t.Fatal(unchanged) + } + invalid := get("GET", prefix+"node-install.pyz", http.Header{"Range": []string{"bytes=99-"}}) + if invalid.Code != 416 || !strings.Contains(invalid.Body.String(), `"error"`) { + t.Fatal(invalid) + } + for _, path := range []string{"active.json", "secrets/core.key", "self-hosted-install.pyz", "../node-install.pyz", "artifacts/unknown", "releases/../../node-install.pyz", "releases/" + strings.Repeat("b", 40) + "/manifest.json", "%2e%2e/node-install.pyz"} { + if w := get("GET", nodeInstallPath+path, nil); w.Code != 404 { + t.Fatal(path, w.Code) + } + } + if w := get("POST", prefix+"node-install.pyz", nil); w.Code != 405 { + t.Fatal(w) + } + entries := m["artifacts"].(map[string]any) + entry := entries["native/bin/oac-node"].(map[string]any) + name := entry["filename"].(string) + for _, method := range []string{"GET", "HEAD"} { + w := get(method, prefix+"artifacts/"+name, nil) + if w.Code != 307 || w.Header().Get("Location") != "https://downloads.example/v1/"+name { + t.Fatal(w) + } + } + secret := filepath.Join(t.TempDir(), "secret") + writeNodeFile(t, secret, []byte("secret")) + path := filepath.Join(root, "node-payload", "releases", nodeRevision, "artifacts", name) + os.MkdirAll(filepath.Dir(path), 0700) + if err := os.Symlink(secret, path); err != nil { + t.Fatal(err) + } + if w := get("GET", prefix+"artifacts/"+name, nil); w.Code != 404 { + t.Fatal("symlink escaped", w) + } + for _, bad := range []string{"http://downloads.example/v1", "https://user:secret@downloads.example/v1", "https://downloads.example/latest", "https://downloads.example/v1?q=x", "https://downloads.example/v1#x", "https://downloads.example/", "https://downloads.example/v1\\bad", "https://downloads.example/v1 bad"} { + manifest := c.nodes.manifest + manifest.ArtifactBaseURL = bad + if manifest.artifactURL(name) != "" { + t.Fatal("unsafe redirect", bad) + } + } +} + +func TestNodeRetainedReleaseAndLocalDownloadKeepTheirOwnIdentity(t *testing.T) { + root, m := nodeFixture(t, "https://downloads.example/v1") + c, err := Load(root, nodeRevision, providers.Builtin()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + old := strings.Repeat("b", 40) + retained := map[string]any{"source_commit": old, "artifacts": map[string]any{ + "native/bin/oac-node": map[string]any{"filename": "old-node", "size": 8, "sha256": strings.Repeat("1", 64)}, + "private/key": map[string]any{"filename": "key", "size": 6, "sha256": strings.Repeat("2", 64)}, + }} + raw, _ := json.Marshal(retained) + prefix := filepath.Join(root, "node-payload", "releases", old) + writeNodeFile(t, filepath.Join(prefix, "manifest.json"), raw) + writeNodeFile(t, filepath.Join(prefix, "artifacts/old-node"), []byte("old-node")) + writeNodeFile(t, filepath.Join(prefix, "artifacts/key"), []byte("secret")) + request := func(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", nodeInstallPath+path, nil) + c.ServeNodeHTTP(w, r) + return w + } + if w := request("releases/" + old + "/artifacts/old-node"); w.Code != 200 || w.Body.String() != "old-node" { + t.Fatal(w) + } + if w := request("releases/" + old + "/artifacts/key"); w.Code != 404 { + t.Fatal("nondeclared file exposed", w) + } + retained["source_commit"] = nodeRevision + raw, _ = json.Marshal(retained) + writeNodeFile(t, filepath.Join(prefix, "manifest.json"), raw) + if w := request("releases/" + old + "/artifacts/old-node"); w.Code != 404 { + t.Fatal("retained identity mismatch", w) + } + // The current catalog never follows an unrelated active pointer after load. + writeNodeFile(t, filepath.Join(root, "node-payload", "active.json"), []byte(`{"source_commit":"`+old+`"}`)) + if w := request("node-install.pyz"); w.Code != 200 || w.Body.String() != "installer" { + t.Fatal("current release changed", w) + } + entry := m["artifacts"].(map[string]any)["images/runtime.tar.gz"].(map[string]any) + name := entry["filename"].(string) + local := filepath.Join(root, "node-payload", "releases", nodeRevision, "artifacts", name) + writeNodeFile(t, local, []byte("artifact")) + r := httptest.NewRequest("GET", nodeInstallPath+"artifacts/"+name, nil) + r.Header.Set("Range", "bytes=4-") + w := httptest.NewRecorder() + c.ServeNodeHTTP(w, r) + if w.Code != 206 || w.Body.String() != "fact" || w.Header().Get("Location") != "" { + t.Fatal("local artifact precedence/range", w) + } + writeNodeFile(t, local, []byte("bad")) + if c.RuntimeRelease("docker") != nil { + t.Fatal("remote hid corrupt local artifact") + } +} + +func TestCatalogSharesIndependentNativeAndNodeAvailability(t *testing.T) { + root, _ := nodeFixture(t, "https://downloads.example/v1") + native := Catalog{Version: nodeRevision, ProtocolVersion: proto.Version, Artifacts: map[string]Artifact{"linux-amd64": {SHA256: strings.Repeat("1", 64), URL: "https://downloads.example/v1/oac-native-" + nodeRevision + "-linux-amd64.tar.gz"}}} + raw, _ := json.Marshal(native) + writeNodeFile(t, filepath.Join(root, "native-installers", "catalog.json"), raw) + c, err := Load(root, nodeRevision, providers.Builtin()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if !c.NativeAvailable() || c.NodeInstallation() == nil { + t.Fatal("one distribution hid the other") + } + w := httptest.NewRecorder() + c.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/agent-daemon/install/"+nodeRevision+"/linux-amd64.tar.gz", nil)) + if w.Code != 307 || w.Header().Get("Location") != native.Artifacts["linux-amd64"].URL { + t.Fatal(w) + } + if err := os.RemoveAll(filepath.Join(root, "node-payload")); err != nil { + t.Fatal(err) + } + nativeOnly, err := Load(root, nodeRevision, providers.Builtin()) + if err != nil { + t.Fatal(err) + } + defer nativeOnly.Close() + if !nativeOnly.NativeAvailable() || nativeOnly.NodeInstallation() != nil { + t.Fatal("native-only catalog changed availability") + } +} diff --git a/services/core/internal/nativeinstaller/testdata/checksums.json b/services/core/internal/nativeinstaller/testdata/checksums.json new file mode 100644 index 000000000..13dc9a305 --- /dev/null +++ b/services/core/internal/nativeinstaller/testdata/checksums.json @@ -0,0 +1,15 @@ +{ + "valid": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ], + "invalid": [ + "", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n", + "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg" + ] +} diff --git a/services/core/internal/processconfig/config.go b/services/core/internal/processconfig/config.go index f5672b59c..fb0923f6f 100644 --- a/services/core/internal/processconfig/config.go +++ b/services/core/internal/processconfig/config.go @@ -71,10 +71,8 @@ type Config struct { // ProviderPaths locate adapter helpers under OAC_PROVIDER_ROOT and their // private state under the Compose state mount. ProviderPaths sandbox.ProcessPaths - // NativeInstallers is the native installer catalog directory under - // OAC_PROVIDER_ROOT; empty when the root is unset. - NativeInstallers string - Log log.Config + + Log log.Config } // RuntimeHistory is the file named by OAC_HISTORY_SETTINGS_FILE, with its @@ -157,9 +155,6 @@ func Load() (Config, error) { return Config{}, err } c.ProviderPaths = sandbox.ProcessPaths{ArtifactRoot: os.Getenv("OAC_PROVIDER_ROOT"), StateRoot: providerStateRoot} - if c.ProviderPaths.ArtifactRoot != "" { - c.NativeInstallers = c.ProviderPaths.ArtifactRoot + "/native-installers" - } return c, nil } diff --git a/services/core/internal/processconfig/config_test.go b/services/core/internal/processconfig/config_test.go index fb33337a1..6a46ad3f6 100644 --- a/services/core/internal/processconfig/config_test.go +++ b/services/core/internal/processconfig/config_test.go @@ -54,14 +54,14 @@ func TestLoadAppliesDefaults(t *testing.T) { } 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" { + c.WriteAuditRetention != 90*24*time.Hour || c.OAuthTrustedOrigins != nil || c.ProviderPaths.StateRoot != "/state" { t.Fatalf("%+v", c) } if h := c.RuntimeHistory; h.File != "" || h.Endpoint != "" || h.QueueCapacity != 256 || h.Timeout != 2*time.Second || h.SampleInterval != 30*time.Second { t.Fatalf("%+v", h) } t.Setenv("OAC_PROVIDER_ROOT", "/opt/oac") - if c, err = Load(); err != nil || c.ProviderPaths.ArtifactRoot != "/opt/oac" || c.NativeInstallers != "/opt/oac/native-installers" { + if c, err = Load(); err != nil || c.ProviderPaths.ArtifactRoot != "/opt/oac" { t.Fatal(c, err) } } diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go index d448870ac..f0c69994c 100644 --- a/services/core/internal/sandbox/deployment.go +++ b/services/core/internal/sandbox/deployment.go @@ -69,9 +69,10 @@ type RuntimeRelease struct { Artifacts map[string]string `json:"artifacts" binding:"required"` } -func (r RuntimeRelease) validate(artifacts map[string]ArtifactRule) error { +// Validate checks the pinned identities against their adapter declaration. +func (r RuntimeRelease) Validate(artifacts map[string]ArtifactRule) error { invalid := &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: Runtime must reference one immutable distribution", ErrInvalid)} - if !regexp.MustCompile("^(?:"+sourceCommitPattern+")$").MatchString(r.SourceCommit) || len(r.Artifacts) != len(artifacts) { + if !regexp.MustCompile("^(?:"+SourceCommitPattern+")$").MatchString(r.SourceCommit) || len(r.Artifacts) != len(artifacts) { return invalid } for name, rule := range artifacts { @@ -99,9 +100,9 @@ func (s DeploymentSpec) ValidatePolicy(provider string, policy DeploymentPolicy) return nil } if s.Runtime == nil { - return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", ErrInvalid)} + return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: this Core has no matching installation distribution for the selected provider", ErrInvalid)} } - return s.Runtime.validate(policy.Artifacts) + return s.Runtime.Validate(policy.Artifacts) } func (s DeploymentSpec) Digest(provider string) string { diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index 621c840d0..33abeb8ce 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -11,7 +11,9 @@ import ( // The node installer (node_spec.py) and the TypeScript client // (deployment-contract.ts) consume its generated projections. const minimumDiskMiB uint32 = 1024 -const sourceCommitPattern = "[0-9a-f]{40}" + +// SourceCommitPattern identifies a distribution built from a full Git commit. +const SourceCommitPattern = "[0-9a-f]{40}" type resourceRule struct { Name string `json:"name"` @@ -59,7 +61,7 @@ func DeploymentContract(providers map[string]ProviderProjection) (python, typesc SourceCommitPattern string `json:"source_commit_pattern"` Providers map[string]ProviderProjection `json:"providers"` MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, sourceCommitPattern, providers, minimumDiskMiB}) + }{resourceContract, SourceCommitPattern, 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" } diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index e7fc81699..e2814f103 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -3,15 +3,21 @@ package providers_test import ( "bytes" "context" + "crypto/sha256" "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "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/nativeinstaller" "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" @@ -130,10 +136,38 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { if err != nil { t.Fatal(err) } + + runtime := sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"regional_image": "sha256:" + strings.Repeat("b", 64)}} + root := t.TempDir() + entries := map[string]any{} + for _, artifact := range adapter.NodeArtifacts { + entries[artifact.Path] = map[string]any{"filename": runtime.SourceCommit + "-" + artifact.Suffix, "size": 8, "sha256": strings.Repeat("c", 64)} + } + manifest, _ := json.Marshal(map[string]any{"source_commit": runtime.SourceCommit, "platform": "linux/amd64", "artifact_base_url": "https://downloads.example/v1", "artifacts": entries, "regional": map[string]string{"image": runtime.Artifacts["regional_image"]}, "images": map[string]string{"runtime": "sha256:" + strings.Repeat("1", 64)}, "image_manifest_digests": map[string]string{"runtime": "sha256:" + strings.Repeat("2", 64)}, "runtime_ref": "oac-runtime@sha256:" + strings.Repeat("3", 64), "microsandbox": map[string]string{"runtime_sha256": strings.Repeat("4", 64), "firmware_sha256": strings.Repeat("5", 64)}}) + files := map[string][]byte{"manifest.json": manifest, "node-install.pyz": []byte("installer"), "runtime/seccomp.json": []byte("{}")} + sums := "" + for name, data := range files { + sums += fmt.Sprintf("%x %s\n", sha256.Sum256(data), name) + } + files["SHA256SUMS"] = []byte(sums) + for name, data := range files { + path := filepath.Join(root, "node-payload", "releases", runtime.SourceCommit, name) + if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.WriteFile(path, data, 0600) != nil { + t.Fatal("fixture publication") + } + } + if err := os.WriteFile(filepath.Join(root, "node-payload", "active.json"), []byte(`{"source_commit":"`+runtime.SourceCommit+`"}`), 0600); err != nil { + t.Fatal(err) + } + distribution, err := nativeinstaller.Load(root, runtime.SourceCommit, registry) + if err != nil { + t.Fatal(err) + } + defer distribution.Close() // The flow reaches only the deployment setup; every other dependency // panics if called. h, err := api.NewHandler(api.Dependencies{ - Engine: "codex", CoreKeys: auth, InstallationBindings: service, + Engine: "codex", CoreKeys: auth, InstallationBindings: service, Distribution: distribution, Projects: struct{ api.Projects }{}, ProjectsReader: struct{ api.ProjectsReader }{}, ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, @@ -169,8 +203,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { if err != nil { t.Fatal(err) } - runtime := sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"regional_image": "sha256:" + strings.Repeat("b", 64)}} - body, _ := json.Marshal(map[string]any{"provider": kind, "expected_generation": 0, "resources": sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, "runtime": runtime, "configuration": map[string]string{"zone": "west"}}) + body, _ := json.Marshal(map[string]any{"provider": kind, "expected_generation": 0, "resources": sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, "configuration": map[string]string{"zone": "west"}}) request := httptest.NewRequest("POST", "/core/v1/sandbox/deployment", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer fixture-admin") response := httptest.NewRecorder() @@ -185,6 +218,27 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { if saved.Specification.Runtime.SourceCommit != runtime.SourceCommit || len(saved.Specification.Runtime.Artifacts) != 1 || saved.Specification.Runtime.Artifacts["regional_image"] != runtime.Artifacts["regional_image"] { t.Fatal("adapter artifact did not roundtrip", saved.Specification.Runtime) } + + // Missing distribution must not hide a stale generation before preparation. + missing := sandbox.Selection{Provider: kind, ExpectedGeneration: 0, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: regionalConfiguration{Zone: "west"}} + var stale *deployment.GenerationStaleError + if err := changes.CheckSetup(t.Context(), installation, missing); !errors.As(err, &stale) { + t.Fatal("missing distribution replaced stale generation", err) + } + missing.ExpectedGeneration = saved.Generation + var validation *sandbox.ValidationError + if err := registry.ValidateSpecification(kind, missing.DeploymentSpec); !errors.As(err, &validation) || validation.Param != "runtime" { + t.Fatal("missing distribution not typed", err) + } + // The Core-selected release is stable across an explicit idempotent setup. + retry, _ := json.Marshal(map[string]any{"provider": kind, "expected_generation": saved.Generation, "resources": sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, "configuration": map[string]string{"zone": "west"}}) + request = httptest.NewRequest("POST", "/core/v1/sandbox/deployment", bytes.NewReader(retry)) + request.Header.Set("Authorization", "Bearer fixture-admin") + response = httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != 200 { + t.Fatal("idempotent Core-selected release", response.Code, response.Body.String()) + } var raw []byte if err = pool.QueryRow(t.Context(), "SELECT provider_config FROM runtime_deployment").Scan(&raw); err != nil || !strings.Contains(string(raw), `"zone": "west"`) { t.Fatal("native fields not persisted", err) diff --git a/services/core/internal/sandbox/providers/validation_test.go b/services/core/internal/sandbox/providers/validation_test.go index 3fe6eb981..630b75b21 100644 --- a/services/core/internal/sandbox/providers/validation_test.go +++ b/services/core/internal/sandbox/providers/validation_test.go @@ -39,7 +39,7 @@ func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) { runtime *sandbox.RuntimeRelease message string }{ - {"docker", nil, "managed nodes require a pinned Runtime release"}, + {"docker", nil, "this Core has no matching installation distribution for the selected provider"}, {"docker", &sandbox.RuntimeRelease{}, "Runtime must reference one immutable distribution"}, {"e2b", &sandbox.RuntimeRelease{}, "E2B Runtime is selected by its immutable template build"}, } { diff --git a/services/core/tests/integration/environment_installation_test.go b/services/core/tests/integration/environment_installation_test.go index e2697942a..052b9e845 100644 --- a/services/core/tests/integration/environment_installation_test.go +++ b/services/core/tests/integration/environment_installation_test.go @@ -37,7 +37,7 @@ func TestEnvironmentInstallationClaimLifetimeAndRetries(t *testing.T) { t.Fatal("authorization", err) } handler, err := publicHandler(t, s, fixtureKeyResolver{}, "codex", func(d *api.Dependencies) { - d.Execution.NativeInstaller = &api.NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build"}} + d.Execution.NativeInstaller = &api.NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build", Artifacts: map[string]nativeinstaller.Artifact{"linux-amd64": {}}}} }) if err != nil { t.Fatal(err) diff --git a/services/web/auth_test.go b/services/web/auth_test.go index ecca93c3e..35d6182d2 100644 --- a/services/web/auth_test.go +++ b/services/web/auth_test.go @@ -100,7 +100,7 @@ func TestCoreKeySignInSessionAndLogout(t *testing.T) { if w := authRequest(h, "GET", "/console/auth", "", nil); w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"mode":"login"}` { t.Fatalf("initial mode: %d %s", w.Code, w.Body) } - for _, path := range []string{"/core/v1/projects", "/console/config", "/core/v1/sandbox/nodes", "/private.txt", "/oac-mark-other.png"} { + for _, path := range []string{"/core/v1/projects", "/core/v1/installation", "/core/v1/sandbox/nodes", "/private.txt", "/oac-mark-other.png"} { if w := authRequest(h, "GET", path, "", nil); w.Code != 401 { t.Errorf("private path %s returned %d", path, w.Code) } @@ -132,13 +132,13 @@ func TestCoreKeySignInSessionAndLogout(t *testing.T) { if w := authRequest(h, "GET", "/console/auth", "", cookie); strings.TrimSpace(w.Body.String()) != `{"mode":"authenticated"}` { t.Fatalf("signed-in mode: %s", w.Body) } - for _, path := range []string{"/core/v1/projects", "/core/v1/sandbox/nodes", "/console/config"} { + for _, path := range []string{"/core/v1/projects", "/core/v1/sandbox/nodes", "/core/v1/installation"} { w := authRequest(h, "GET", path, "", cookie) if w.Code != 200 || strings.Contains(w.Body.String(), testCoreKey) { t.Errorf("authenticated path %s failed or leaked the Core key: %d", path, w.Code) } } - if calls.Load() != 2 { + if calls.Load() != 3 { t.Fatal("authenticated management requests did not reach Core") } if w := authRequest(startConsole(t, c), "GET", "/console/auth", "", cookie); !strings.Contains(w.Body.String(), `"login"`) { diff --git a/services/web/config.go b/services/web/config.go index 9d64eff35..70df5b68b 100644 --- a/services/web/config.go +++ b/services/web/config.go @@ -15,10 +15,10 @@ import ( ) type config struct { - addr, origin, dist string - coreKey, nodePayloadDir string - upstream *url.URL - log log.Config + addr, origin, dist string + coreKey string + upstream *url.URL + log log.Config } // loadConfig reads Web's settings. An unset or empty variable selects its @@ -51,10 +51,6 @@ func loadConfig() (config, error) { if utf8.RuneCountInString(c.coreKey) < minimumCoreKeyLength { return config{}, errors.New("the Core key in OAC_WEB_CORE_KEY_FILE must have at least 32 characters") } - c.nodePayloadDir = os.Getenv("OAC_WEB_NODE_PAYLOAD_DIR") - if c.nodePayloadDir != "" && !filepath.IsAbs(c.nodePayloadDir) { - return config{}, errors.New("OAC_WEB_NODE_PAYLOAD_DIR must be absolute") - } return c, nil } diff --git a/services/web/distribution_test.go b/services/web/distribution_test.go deleted file mode 100644 index 7e36414b7..000000000 --- a/services/web/distribution_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" -) - -// activeRelease publishes an empty node payload release under root the way -// `oac init` does and returns the release directory. -func activeRelease(t *testing.T, root string, manifest map[string]any) string { - t.Helper() - revision := strings.Repeat("a", 40) - release := filepath.Join(root, "releases", revision) - if err := os.MkdirAll(release, 0700); err != nil { - t.Fatal(err) - } - manifest["source_commit"] = revision - raw, _ := json.Marshal(manifest) - if os.WriteFile(filepath.Join(release, "manifest.json"), raw, 0600) != nil || - os.WriteFile(filepath.Join(root, "active.json"), []byte(`{"source_commit":"`+revision+`"}`), 0600) != nil { - t.Fatal("cannot publish the node payload") - } - return release -} - -func TestConsoleRequiresPublishedNodePayload(t *testing.T) { - payload := t.TempDir() - if err := os.WriteFile(filepath.Join(payload, "node-install.pyz"), []byte("bootstrap"), 0600); err != nil { - t.Fatal(err) - } - upstream, _ := url.Parse("http://127.0.0.1:1") - if _, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: t.TempDir(), coreKey: testCoreKey, nodePayloadDir: payload}); err == nil { - t.Fatal("console started from a node payload without active.json") - } -} - -func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { - dist, payload := t.TempDir(), t.TempDir() - release := activeRelease(t, payload, map[string]any{"artifacts": map[string]any{"native/bin/oac-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}, "native/bin/oac-selfhost": map[string]string{"filename": "retired-launcher"}}}) - for _, item := range []struct{ root, name, body string }{{dist, "index.html", "console"}, {release, "node-install.pyz", "bootstrap"}} { - if err := os.WriteFile(filepath.Join(item.root, item.name), []byte(item.body), 0600); err != nil { - t.Fatal(err) - } - } - if err := os.Mkdir(filepath.Join(release, "artifacts"), 0700); err != nil { - t.Fatal(err) - } - for _, name := range []string{"matched-node", "private-key", "undeclared", "retired-launcher"} { - if err := os.WriteFile(filepath.Join(release, "artifacts", name), []byte("payload"), 0600); err != nil { - t.Fatal(err) - } - } - upstream, _ := url.Parse("http://127.0.0.1:1") - h, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: dist, coreKey: "server-admin", nodePayloadDir: payload}) - if err != nil { - t.Fatal(err) - } - defer h.Close() - server := httptest.NewServer(h) - defer server.Close() - for _, name := range []string{"matched-node", "private-key", "undeclared", "retired-launcher"} { - req := consoleRequest(t, server, "GET", "/node-install/artifacts/"+name) - response, body := responseBody(t, server, req) - if name == "matched-node" { - if response.StatusCode != 200 || !strings.Contains(body, "payload") { - t.Fatal("matched artifact unavailable") - } - } else if response.StatusCode != 404 { - t.Fatal("unexpected artifact exposed") - } - } -} - -// /console/config names the providers whose node files this console holds, so -// Web offers Add node only when a node can download everything it needs. Node -// downloads can resume with HTTP Range. -func TestConsoleReportsServableNodeProviders(t *testing.T) { - dist, payload := t.TempDir(), t.TempDir() - write := func(path, body string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0600); err != nil { - t.Fatal(err) - } - } - artifacts := map[string]any{} - for _, logical := range []string{"native/bin/oac-node", "images/runtime.tar.gz", "native/microsandbox/msb", "runtime/seccomp.json"} { - artifacts[logical] = map[string]any{"filename": strings.ReplaceAll(logical, "/", "-"), "size": len("runtime-bytes")} - } - release := activeRelease(t, payload, map[string]any{"artifacts": artifacts}) - write(filepath.Join(dist, "index.html"), "console") - write(filepath.Join(release, "node-install.pyz"), "bootstrap") - for logical := range artifacts { - write(filepath.Join(release, "artifacts", strings.ReplaceAll(logical, "/", "-")), "runtime-bytes") - } - upstream, _ := url.Parse("http://127.0.0.1:1") - h, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: dist, coreKey: testCoreKey, nodePayloadDir: payload}) - if err != nil { - t.Fatal(err) - } - defer h.Close() - server := serveSignedIn(t, h) - if _, body := responseBody(t, server, consoleRequest(t, server, "GET", "/console/config")); !strings.Contains(body, `"node_artifacts":["docker"]`) { - t.Fatal("microsandbox reported without its helper and firmware:", body) - } - request := consoleRequest(t, server, "GET", "/node-install/artifacts/images-runtime.tar.gz") - request.Header.Set("Range", "bytes=8-") - if response, body := responseBody(t, server, request); response.StatusCode != 206 || body != "bytes" { - t.Fatal("artifact download cannot resume", response.StatusCode, body) - } - if err := os.RemoveAll(filepath.Join(release, "artifacts")); err != nil { - t.Fatal(err) - } - if _, body := responseBody(t, server, consoleRequest(t, server, "GET", "/console/config")); !strings.Contains(body, `"node_artifacts":[]`) { - t.Fatal("a console without node files offered them:", body) - } - // A console without any node payload also reports an empty list, never null. - bare, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: dist, coreKey: testCoreKey}) - if err != nil { - t.Fatal(err) - } - defer bare.Close() - bareServer := serveSignedIn(t, bare) - if _, body := responseBody(t, bareServer, consoleRequest(t, bareServer, "GET", "/console/config")); !strings.Contains(body, `"node_artifacts":[]`) { - t.Fatal("a console without a node payload did not report an empty list:", body) - } -} - -func TestRetainedPayloadsRemainHTTPReachableAfterPublication(t *testing.T) { - root := t.TempDir() - write := func(name, body string) { - t.Helper() - path := filepath.Join(root, name) - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0600); err != nil { - t.Fatal(err) - } - } - a, b := strings.Repeat("a", 40), strings.Repeat("b", 40) - for _, revision := range []string{a, b} { - prefix := "releases/" + revision + "/" - write(prefix+"manifest.json", `{"source_commit":"`+revision+`","artifacts":{"native/bin/oac-node":{"filename":"node"},"private/key":{"filename":"key"}}}`) - write(prefix+"node-install.pyz", "installer-"+revision) - write(prefix+"artifacts/node", "binary-"+revision) - write(prefix+"artifacts/key", "secret-must-not-be-served") - } - payload, err := os.OpenRoot(root) - if err != nil { - t.Fatal(err) - } - defer payload.Close() - h := &console{nodePayload: payload} - request := func(path string) (int, string) { - t.Helper() - w := httptest.NewRecorder() - h.serveNodePayload(w, httptest.NewRequest("GET", "/node-install/"+path, nil)) - return w.Code, w.Body.String() - } - for _, revision := range []string{a, b} { - write("active.json", `{"source_commit":"`+revision+`"}`) - if code, body := request("node-install.pyz"); code != 200 || body != "installer-"+revision { - t.Fatal("active release not served", code, body) - } - if code, body := request("releases/" + a + "/artifacts/node"); code != 200 || body != "binary-"+a { - t.Fatal("retained release unreachable", code, body) - } - for _, name := range []string{"active.json", "releases/" + a + "/artifacts/key", "releases/" + a + "/secrets/core.key", "releases/../../node-install.pyz"} { - if code, _ := request(name); code != 404 { - t.Fatal("non-public file exposed", name, code) - } - } - } - write("releases/"+a+"/manifest.json", `{"source_commit":"`+b+`"}`) - if code, _ := request("releases/" + a + "/node-install.pyz"); code != 404 { - t.Fatal("mismatched release identity was served") - } - write("active.json", `{"source_commit":"../../"}`) - if code, _ := request("node-install.pyz"); code != 404 { - t.Fatal("invalid pointer did not fail closed") - } -} diff --git a/services/web/node_artifacts.go b/services/web/node_artifacts.go deleted file mode 100644 index c17869b87..000000000 --- a/services/web/node_artifacts.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "io" - "net/url" - "regexp" - "strings" - "unicode" -) - -type nodeArtifact struct { - Filename string `json:"filename"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` -} - -type nodeManifest struct { - SourceCommit string `json:"source_commit"` - ArtifactBaseURL string `json:"artifact_base_url"` - Artifacts map[string]nodeArtifact `json:"artifacts"` -} - -func (h *console) readNodeManifest(prefix string) (nodeManifest, error) { - var manifest nodeManifest - f, err := h.nodePayload.Open(prefix + "manifest.json") - if err != nil { - return manifest, err - } - defer f.Close() - raw, err := io.ReadAll(io.LimitReader(f, 1024*1024+1)) - if err != nil || len(raw) > 1024*1024 || json.Unmarshal(raw, &manifest) != nil { - return manifest, errors.New("invalid node manifest") - } - if prefix != "releases/"+manifest.SourceCommit+"/" { - return manifest, errors.New("node manifest release mismatch") - } - return manifest, nil -} - -var nodeArtifactName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) -var nodeArtifactDigest = regexp.MustCompile(`^[0-9a-f]{64}$`) - -// Release locations come only from the installed, verified distribution manifest. -// Web redirects missing artifacts instead of downloading or caching them itself. -func (m nodeManifest) artifactURL(filename string) string { - if !payloadRevision.MatchString(m.SourceCommit) || !nodeArtifactName.MatchString(filename) || !strings.Contains(filename, m.SourceCommit) { - return "" - } - allowed := false - for logical, entry := range m.Artifacts { - if optionalPayloadFiles[logical] && entry.Filename == filename && entry.Size > 0 && nodeArtifactDigest.MatchString(entry.SHA256) { - allowed = true - break - } - } - if !allowed { - return "" - } - base, err := url.Parse(m.ArtifactBaseURL) - if err != nil || base.Scheme != "https" || base.Hostname() == "" || base.User != nil || base.RawQuery != "" || base.ForceQuery || base.Fragment != "" || strings.Trim(base.Path, "/") == "" || strings.Contains(m.ArtifactBaseURL, "\\") || strings.IndexFunc(m.ArtifactBaseURL, unicode.IsSpace) >= 0 { - return "" - } - for _, part := range strings.Split(strings.ToLower(base.Path), "/") { - if part == "latest" { - return "" - } - } - return strings.TrimRight(base.String(), "/") + "/" + filename -} diff --git a/services/web/node_artifacts_test.go b/services/web/node_artifacts_test.go deleted file mode 100644 index 8dc245484..000000000 --- a/services/web/node_artifacts_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOnlineNodeArtifactsRedirectWithoutLocalPayload(t *testing.T) { - root := t.TempDir() - revision := strings.Repeat("a", 40) - prefix := "releases/" + revision + "/" - write := func(name string, data []byte) { - t.Helper() - full := filepath.Join(root, name) - if err := os.MkdirAll(filepath.Dir(full), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, data, 0600); err != nil { - t.Fatal(err) - } - } - sum := sha256.Sum256([]byte("node-data")) - manifest := nodeManifest{SourceCommit: revision, ArtifactBaseURL: "https://github.com/MiniMax-AI/OpenAgentCore/releases/download/v1.2.3", Artifacts: map[string]nodeArtifact{}} - for logical := range optionalPayloadFiles { - manifest.Artifacts[logical] = nodeArtifact{Filename: "oac-" + revision + "-" + strings.ReplaceAll(logical, "/", "-"), Size: 9, SHA256: hex.EncodeToString(sum[:])} - } - raw, _ := json.Marshal(manifest) - write(prefix+"manifest.json", raw) - write("active.json", []byte(`{"source_commit":"`+revision+`"}`)) - payload, err := os.OpenRoot(root) - if err != nil { - t.Fatal(err) - } - defer payload.Close() - h := &console{nodePayload: payload} - if got := strings.Join(h.nodeArtifacts(), ","); got != "docker,microsandbox" { - t.Fatal(got) - } - name := manifest.Artifacts["images/runtime.tar.gz"].Filename - for _, method := range []string{"GET", "HEAD"} { - w := httptest.NewRecorder() - h.serveNodePayload(w, httptest.NewRequest(method, "/node-install/"+prefix+"artifacts/"+name, nil)) - if w.Code != 307 || w.Header().Get("Location") != manifest.ArtifactBaseURL+"/"+name { - t.Fatal(w.Code, w.Header()) - } - } - if _, err := os.Stat(filepath.Join(root, prefix, "artifacts")); !os.IsNotExist(err) { - t.Fatal("Web cached execution artifacts", err) - } - for _, name := range []string{"unknown", "../manifest.json", "private-key"} { - w := httptest.NewRecorder() - h.serveNodePayload(w, httptest.NewRequest("GET", "/node-install/"+prefix+"artifacts/"+name, nil)) - if w.Code != 404 { - t.Fatal(w.Code, name) - } - } - write(prefix+"artifacts/"+name, []byte("node-data")) - w := httptest.NewRecorder() - request := httptest.NewRequest("GET", "/node-install/"+prefix+"artifacts/"+name, nil) - request.Header.Set("Range", "bytes=5-") - h.serveNodePayload(w, request) - if w.Code != 206 || w.Body.String() != "data" { - t.Fatal("offline artifact did not take precedence", w.Code, w.Body.String()) - } - // A damaged local file must not be hidden by a remote availability fallback. - write(prefix+"artifacts/"+name, []byte("bad")) - if len(h.nodeArtifacts()) != 0 { - t.Fatal("damaged local Runtime was advertised") - } -} - -func TestRemoteNodeArtifactRequiresPinnedMetadata(t *testing.T) { - revision := strings.Repeat("b", 40) - name := "oac-" + revision + "-runtime.tar.gz" - m := nodeManifest{SourceCommit: revision, ArtifactBaseURL: "https://release.example/download/v1", Artifacts: map[string]nodeArtifact{"images/runtime.tar.gz": {Filename: name, Size: 1, SHA256: strings.Repeat("c", 64)}}} - if m.artifactURL(name) == "" { - t.Fatal("valid manifest rejected") - } - for _, base := range []string{"http://release.example/v1", "https://release.example/latest", "https://user:pass@release.example/v1", "https://release.example/v1?token=secret", "https://release.example/v1#fragment", "https://release.example"} { - invalid := m - invalid.ArtifactBaseURL = base - if invalid.artifactURL(name) != "" { - t.Fatal("invalid release URL accepted", base) - } - } - entry := m.Artifacts["images/runtime.tar.gz"] - entry.SHA256 = "wrong" - m.Artifacts["images/runtime.tar.gz"] = entry - if m.artifactURL(name) != "" { - t.Fatal("missing integrity metadata accepted") - } -} diff --git a/services/web/node_installation.go b/services/web/node_installation.go deleted file mode 100644 index 2747292ba..000000000 --- a/services/web/node_installation.go +++ /dev/null @@ -1,191 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "io" - "net/http" - "os" - "regexp" - "sort" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" -) - -// These are distribution artifacts, never installation configuration or secrets. -// Serving the fixed list avoids a package registry or an arbitrary file endpoint. -var nodePayloadFiles = map[string]bool{ - "node-install.pyz": true, - "manifest.json": true, "SHA256SUMS": true, "runtime/seccomp.json": true, -} - -// The generated adapter declarations bound the distribution payload endpoint. -var optionalPayloadFiles = func() map[string]bool { - files := map[string]bool{} - for _, artifacts := range providerassets.Catalog() { - for _, artifact := range artifacts { - files[artifact.Path] = true - } - } - return files -}() - -var payloadRevision = regexp.MustCompile(`^[0-9a-f]{40}$`) - -// activePayloadPrefix reads the installer's atomic release pointer once per request. -func activePayloadPrefix(root *os.Root) (string, error) { - raw, err := root.ReadFile("active.json") - if err != nil || len(raw) > 256 { - return "", errors.New("invalid active node payload") - } - var pointer struct { - SourceCommit string `json:"source_commit"` - } - if json.Unmarshal(raw, &pointer) != nil || !payloadRevision.MatchString(pointer.SourceCommit) { - return "", errors.New("invalid active node payload") - } - return "releases/" + pointer.SourceCommit + "/", nil -} - -func (h *console) resolveNodePayload(name string) (string, bool) { - var prefix string - if strings.HasPrefix(name, "releases/") { - parts := strings.SplitN(name, "/", 3) - if len(parts) != 3 || !payloadRevision.MatchString(parts[1]) { - return "", false - } - prefix, name = "releases/"+parts[1]+"/", parts[2] - } else { - var err error - prefix, err = activePayloadPrefix(h.nodePayload) - if err != nil { - return "", false - } - } - if !nodePayloadFiles[name] && (!strings.HasPrefix(name, "artifacts/") || strings.Contains(strings.TrimPrefix(name, "artifacts/"), "/")) { - return "", false - } - manifest, err := h.readNodeManifest(prefix) - if err != nil { - return "", false - } - if nodePayloadFiles[name] { - return prefix + name, true - } - for logical, entry := range manifest.Artifacts { - if optionalPayloadFiles[logical] && entry.Filename != "" && "artifacts/"+entry.Filename == name { - return prefix + name, true - } - } - return "", false -} - -// installerDigest returns the SHA-256 that Web's install commands verify -// before running the named installer from the payload. -func installerDigest(root *os.Root, name string) (string, error) { - prefix, err := activePayloadPrefix(root) - if err != nil { - return "", err - } - f, err := root.Open(prefix + name) - if err != nil { - return "", errors.New("installer " + name + " is missing from the node installation payload") - } - defer f.Close() - info, err := f.Stat() - if err != nil || !info.Mode().IsRegular() || info.Size() > 1024*1024 { - return "", errors.New("invalid installer " + name) - } - digest := sha256.New() - if _, err = io.Copy(digest, f); err != nil { - return "", errors.New("cannot read installer " + name) - } - return hex.EncodeToString(digest.Sum(nil)), nil -} - -func (h *console) serveNodePayload(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - w.Header().Set("Allow", "GET, HEAD") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - name := strings.TrimPrefix(r.URL.Path, "/node-install/") - resolved, allowed := h.resolveNodePayload(name) - if !allowed { - http.NotFound(w, r) - return - } - f, err := h.nodePayload.Open(resolved) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - if prefix, filename, ok := strings.Cut(resolved, "artifacts/"); ok { - if manifest, readErr := h.readNodeManifest(prefix); readErr == nil { - if target := manifest.artifactURL(filename); target != "" { - http.Redirect(w, r, target, http.StatusTemporaryRedirect) - return - } - } - } - } - http.NotFound(w, r) - return - } - defer f.Close() - info, err := f.Stat() - if err != nil || !info.Mode().IsRegular() { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/octet-stream") - http.ServeContent(w, r, info.Name(), info.ModTime(), f) -} - -// nodeArtifacts reports the providers whose node artifacts this console can serve: -// each is present locally or has a pinned release download. Nodes verify every -// checksum themselves. It is read per -// request, so artifacts added by rerunning the installer show without a restart. -func (h *console) nodeArtifacts() []string { - available := []string{} - if h.nodePayload == nil { - return available - } - prefix, err := activePayloadPrefix(h.nodePayload) - if err != nil { - return available - } - manifest, err := h.readNodeManifest(prefix) - if err != nil { - return available - } - for provider, artifacts := range providerassets.Catalog() { - complete := true - for _, artifact := range artifacts { - logical := artifact.Path - entry, ok := manifest.Artifacts[logical] - info, err := h.nodePayload.Stat(prefix + "artifacts/" + entry.Filename) - local := err == nil && info.Mode().IsRegular() && info.Size() == entry.Size - remote := errors.Is(err, os.ErrNotExist) && manifest.artifactURL(entry.Filename) != "" - if !ok || entry.Filename == "" || strings.Contains(entry.Filename, "/") || (!local && !remote) { - complete = false - break - } - } - if complete { - available = append(available, provider) - } - } - sort.Strings(available) - return available -} - -func (h *console) serveConsoleConfiguration(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(struct { - NodeInstaller bool `json:"node_installer"` - NodeInstallerSHA256 string `json:"node_installer_sha256"` - NodeArtifacts []string `json:"node_artifacts"` - }{h.nodePayload != nil, h.nodeInstallerDigest, h.nodeArtifacts()}) -} diff --git a/services/web/node_installation_test.go b/services/web/node_installation_test.go index 6ee331c8f..f51c52753 100644 --- a/services/web/node_installation_test.go +++ b/services/web/node_installation_test.go @@ -1,8 +1,6 @@ package main import ( - "crypto/sha256" - "encoding/hex" "io" "net/http" "net/http/httptest" @@ -22,6 +20,10 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { if r.Header.Get("Authorization") != "Bearer node-token" { t.Errorf("node credential was replaced for %s", r.URL.Path) } + } else if strings.HasPrefix(r.URL.Path, "/api/v1/sandbox-node/install/") { + if r.Header.Get("Authorization") != "" { + t.Error("anonymous installation download acquired a Core credential") + } } else if r.Header.Get("Authorization") != "Bearer server-admin" { t.Errorf("incorrect upstream authority for %s", r.URL.Path) } @@ -34,14 +36,11 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { })) defer upstream.Close() u, _ := url.Parse(upstream.URL) - dist, payload := t.TempDir(), t.TempDir() - release := activeRelease(t, payload, map[string]any{}) - for _, file := range []struct{ path, value string }{{filepath.Join(dist, "index.html"), "console"}, {filepath.Join(release, "node-install.pyz"), "print('installer')"}, {filepath.Join(release, "self-hosted-install.pyz"), "print('self-hosted')"}, {filepath.Join(release, "caller.key"), "must-not-be-served"}} { - if err := os.WriteFile(file.path, []byte(file.value), 0600); err != nil { - t.Fatal(err) - } + dist := t.TempDir() + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("console"), 0600); err != nil { + t.Fatal(err) } - h, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, coreKey: "server-admin", nodePayloadDir: payload}) + h, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, coreKey: "server-admin"}) if err != nil { t.Fatal(err) } @@ -63,12 +62,10 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { {"GET", "/core/v1/sandbox/nodes", "node", 401}, {"GET", "/core/v1/projects", "session", 200}, {"POST", "/api/v1/sandbox-node/enroll", "node", 200}, - {"GET", "/console/config", "session", 200}, - {"GET", "/console/config", "none", 401}, - {"GET", "/node-install/node-install.pyz", "none", 200}, - {"GET", "/node-install/caller.key", "none", 404}, - {"GET", "/node-install/self-hosted-install.pyz", "none", 404}, - {"POST", "/node-install/node-install.pyz", "none", 405}, + {"GET", "/core/v1/installation", "session", 200}, + {"GET", "/core/v1/installation", "none", 401}, + {"GET", "/api/v1/sandbox-node/install/releases/" + strings.Repeat("a", 40) + "/node-install.pyz", "none", 200}, + {"HEAD", "/api/v1/sandbox-node/install/releases/" + strings.Repeat("a", 40) + "/node-install.pyz", "none", 200}, } { r := consoleRequest(t, server, tc.method, tc.path) if tc.auth != "session" { @@ -87,14 +84,8 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { if strings.Contains(body, "server-admin") || strings.Contains(body, "project-token") || strings.Contains(body, "must-not-be-served") { t.Fatal("credential leaked") } - // Web verifies each downloaded installer against these digests before running it. - nodeDigest := sha256.Sum256([]byte("print('installer')")) - if tc.path == "/console/config" && tc.status == 200 && (!strings.Contains(body, `"node_installer":true`) || - !strings.Contains(body, `"node_installer_sha256":"`+hex.EncodeToString(nodeDigest[:])+`"`) || strings.Contains(body, "self_hosted_installer")) { - t.Fatalf("console configuration = %s", body) - } } - if calls.Load() != 7 { + if calls.Load() != 10 { t.Fatalf("unexpected upstream requests: %d", calls.Load()) } r := consoleRequest(t, server, "POST", "/core/v1/sandbox/deployment") diff --git a/services/web/server.go b/services/web/server.go index 01d43dd7c..7a5f11566 100644 --- a/services/web/server.go +++ b/services/web/server.go @@ -15,13 +15,11 @@ import ( type console struct { config - root *os.Root - nodePayload *os.Root - nodeInstallerDigest string - proxy, direct *httputil.ReverseProxy - transport *http.Transport - host string - auth *consoleAuth + root *os.Root + proxy, direct *httputil.ReverseProxy + transport *http.Transport + host string + auth *consoleAuth } type consoleActorContextKey struct{} @@ -41,19 +39,6 @@ func newConsole(c config) (*console, error) { } origin, _ := url.Parse(c.origin) h := &console{config: c, root: root, host: origin.Host, auth: newConsoleAuth(c)} - if c.nodePayloadDir != "" { - h.nodePayload, err = os.OpenRoot(c.nodePayloadDir) - if err != nil { - root.Close() - return nil, errors.New("cannot open node installation payload") - } - h.nodeInstallerDigest, err = installerDigest(h.nodePayload, "node-install.pyz") - if err != nil { - h.nodePayload.Close() - root.Close() - return nil, err - } - } h.transport = http.DefaultTransport.(*http.Transport).Clone() // Credentials go only to the configured Core, never an ambient HTTP proxy. h.transport.Proxy = nil @@ -112,9 +97,6 @@ func newConsole(c config) (*console, error) { func (h *console) Close() { h.transport.CloseIdleConnections() _ = h.root.Close() - if h.nodePayload != nil { - _ = h.nodePayload.Close() - } } func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -140,14 +122,6 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.direct.ServeHTTP(w, r) return } - if h.nodePayload != nil && strings.HasPrefix(r.URL.Path, "/node-install/") { - if h.requestOrigin(r) == "" || !safePath(r.URL.Path) || r.URL.IsAbs() { - http.NotFound(w, r) - return - } - h.serveNodePayload(w, r) - return - } if !h.sameOrigin(r) { if consoleCoreNamespace(r) { consoleCoreError(w, http.StatusForbidden, "console_origin_rejected", "Forbidden") @@ -180,10 +154,6 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { } return } - if r.URL.Path == "/console/config" && r.Method == http.MethodGet { - h.serveConsoleConfiguration(w, r) - return - } if r.URL.Path == "/core" || strings.HasPrefix(r.URL.Path, "/core/") { if !coreRequest(r) { http.NotFound(w, r)