diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index d537c6106..fd6379f3e 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -55,5 +55,6 @@ export function sandboxConfigurationRejection(error: unknown, locale: SupportedL export function sandboxProviderLabel(provider: SandboxProvider | "", locale: SupportedLanguage): string { if (provider === "docker") return "Docker"; if (provider === "microsandbox") return "microsandbox"; + if (provider === "smolvm") return "smolvm"; return i18n.getFixedT(locale, "sandbox")(provider === "e2b" ? "E2B cloud" : "Unknown state"); } diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 29303854d..6219bcc21 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -659,7 +659,7 @@ definitions: allOf: - $ref: '#/definitions/sandbox.Resources' description: |- - Per-sandbox limits, required for Docker and microsandbox. E2B may omit + Per-sandbox limits, required for node providers. E2B may omit them; Core then uses the validated template build's cpus and memory_mib. runtime: $ref: '#/definitions/sandbox.RuntimeRelease' @@ -680,7 +680,7 @@ definitions: allOf: - $ref: '#/definitions/sandbox.Resources' description: |- - Per-sandbox limits, required for Docker and microsandbox. E2B may omit + Per-sandbox limits, required for node providers. E2B may omit them; Core then uses the validated template build's cpus and memory_mib. runtime: $ref: '#/definitions/sandbox.RuntimeRelease' diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 41668d90e..f2dfbbeaa 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -33,13 +33,13 @@ POST and PUT take the same complete selection and require `expected_generation` | Field | Meaning | | --- | --- | | `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 | +| `provider` | Exactly one of `docker`, `microsandbox`, `smolvm`, `e2b` | +| `resources` | Per-sandbox limits, below; required for Docker, microsandbox and smolvm, optional for E2B | +| `runtime` | The immutable [Runtime release](#runtime-release); required for Docker, microsandbox and smolvm, absent for E2B | +| `configuration` | The provider's public selectors. E2B: the immutable `template` build and the optional paired `api_url` and `domain`. Docker, microsandbox and smolvm 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, microsandbox and smolvm 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. E2B guests reach Core from E2B's cloud, so an E2B selection is rejected with 409 `sandbox_configuration_error` while the public URL is loopback. Docker and microsandbox selections accept a loopback public URL, which serves only local development because a guest's loopback address does not reach its host. 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. +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. E2B guests reach Core from E2B's cloud, so an E2B selection is rejected with 409 `sandbox_configuration_error` while the public URL is loopback. Docker, microsandbox and smolvm selections accept a loopback public URL, which serves only local development because a guest's loopback address does not reach its host. 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. ### Resources @@ -47,8 +47,8 @@ The request has no Core address. Core derives the deployment's `core_url` from t | --- | --- | | `cpus` | Integer, 1 through 255 | | `memory_mib` | Integer, 512 through 1048576 MiB | -| `root_disk_mib` | microsandbox: at least 1024 MiB; Docker and E2B: omitted or zero | -| `environment_disk_mib` | microsandbox owned disk: at least 1024 MiB; external filesystem: zero requests no quota; a positive value must be at least 1024 MiB and requires enforced quotas; Docker and E2B: omitted or zero | +| `root_disk_mib` | microsandbox and smolvm: at least 1024 MiB; Docker and E2B: omitted or zero | +| `environment_disk_mib` | microsandbox and smolvm owned disk: at least 1024 MiB; microsandbox external filesystem: zero requests no quota; a positive value must be at least 1024 MiB and requires enforced quotas; Docker and E2B: omitted or zero | These limits describe each sandbox. A node's `max_active` and `max_retained` are separate reservation limits, and host measurements never permit exceeding either. Native providers may reject values that pass these bounds. @@ -62,12 +62,12 @@ To enable external storage for an existing owned deployment, select the filesyst ### Runtime release -Docker and microsandbox use every field of one verified distribution: +Docker, microsandbox and smolvm use every field of one verified distribution: | Field | Identity | | --- | --- | | `source_commit` | Lowercase 40-character commit SHA | -| `image_id` | Docker image configuration ID: `sha256:` and 64 lowercase hex characters | +| `image_id` | OCI image configuration ID: `sha256:` and 64 lowercase hex characters | | `image_manifest_digest` | OCI image manifest digest, in the same form | | `microsandbox_ref` | `oac-runtime@sha256:` and 64 lowercase hex characters | | `runtime_sha256` | SHA-256 of the native microsandbox runtime binary | @@ -81,18 +81,18 @@ E2B uses `configuration.template` in `template-id:build-uuid` form; the build UU ### Configuration discovery -`POST /core/v1/sandbox/providers/{provider}/discovery` takes exactly `configuration`, `credential` and `query` objects, at most 64 KiB, and runs for at most 30 seconds. Unknown members and null objects are rejected. The credential is used only for that request and is never stored or returned; discovery saves nothing, changes no deployment, allocates nothing and never proves that a selection will be admitted. Docker and microsandbox reject it with 400 `sandbox_operation_unsupported`. +`POST /core/v1/sandbox/providers/{provider}/discovery` takes exactly `configuration`, `credential` and `query` objects, at most 64 KiB, and runs for at most 30 seconds. Unknown members and null objects are rejected. The credential is used only for that request and is never stored or returned; discovery saves nothing, changes no deployment, allocates nothing and never proves that a selection will be admitted. Docker, microsandbox and smolvm reject it with 400 `sandbox_operation_unsupported`. For E2B, post `{"configuration": {"api_url": "…", "domain": "…"}, "credential": {"api_key": "…"}, "query": {}}` to list templates, and add `"query": {"template": "template-id"}` to list that template's ready builds; the official endpoint may omit both endpoint fields. The results are `{"templates": [{"id": "…", "names": ["…"]}]}` and `{"builds": [{"id": "build-uuid", "cpus": 2, "memory_mib": 2048}]}`, either possibly empty. The pinned SDK helper reads `GET /v2/templates` and returns at most 200 results; a limit or provider failure returns 503 with a generic message. The deployment write validates the selected build separately. ## Safe response -GET and successful writes return `installation_id`, `provider`, `core_url` (read-only: the installation public URL, present even before configuration), `mode`, `generation`, `owner_epoch`, `reset`, `rollout`, `suspension`, `resources` and `credential_configured`. A configured deployment also returns `specification`, `specification_digest`, `configuration` and `metadata`: the adapter's public projection of its selectors and of the observations it recorded, never raw stored values or secrets. E2B returns `configuration.template`, `configuration.api_url`, `configuration.domain` and, once recorded, `metadata.template_build`. Docker and microsandbox return empty `configuration` and `metadata` objects and `credential_configured: false`; an unconfigured deployment has neither object. +GET and successful writes return `installation_id`, `provider`, `core_url` (read-only: the installation public URL, present even before configuration), `mode`, `generation`, `owner_epoch`, `reset`, `rollout`, `suspension`, `resources` and `credential_configured`. A configured deployment also returns `specification`, `specification_digest`, `configuration` and `metadata`: the adapter's public projection of its selectors and of the observations it recorded, never raw stored values or secrets. E2B returns `configuration.template`, `configuration.api_url`, `configuration.domain` and, once recorded, `metadata.template_build`. Docker, microsandbox and smolvm return empty `configuration` and `metadata` objects and `credential_configured: false`; an unconfigured deployment has neither object. - `metadata.template_build` is `{status, resources: {cpus, memory_mib, root_disk_mib}}`: the build as Core read it through the pinned SDK when the selection was saved. GET never calls E2B, so it stays cheap during an E2B outage. Validation admits only a `ready` build whose CPU count and memory equal the selected values; `root_disk_mib` is the build's native disk size, which Core does not enforce. Unknown values are null, `metadata: {}` means no observation was recorded, and an identical PUT without a credential does not refresh it. - `suspension` is `{idle_seconds, retention_seconds}`, Core's [suspension policy](../../docs/sandbox-provider.md#suspension), when the selected Provider declares checkpoint support; any other deployment, including an unconfigured one, returns null. - Request `resources` and response `specification.resources` are per-sandbox limits. Response `resources.allocations` and `resources.pending` count unreleased allocations and pending hosted Environments without an allocation. -- An unconfigured deployment has an empty provider and no specification. Docker and microsandbox use `mode: nodes`; E2B uses `mode: direct`, without a synthetic node. +- An unconfigured deployment has an empty provider and no specification. Docker, microsandbox and smolvm use `mode: nodes`; E2B uses `mode: direct`, without a synthetic node. - `generation` identifies the saved selection. `owner_epoch` fences the execution owner and node connections; it does not replace `expected_generation`. - `specification_digest` is the server's identity of the provider, limits, Runtime release and derived workspace capability receipt; enrollment echoes it unchanged. @@ -102,7 +102,7 @@ The typed `SandboxAdminClient` in `packages/agents-client` checks the deployment POST validates a candidate before persisting it and creates no compute, Session or model request. At the current generation an identical selection is a no-op; an old generation returns 409 `sandbox_generation_stale`, even for the same body. Missing prerequisites or a failed preparation leave the committed provider in place. A different backend requires a reset first. POST also initializes after a completed reset, using the reset's new generation. -PUT accepts the same provider while no reset is active. Send the observed generation once and never replay an uncertain write automatically. New allocations use the newly committed specification, and existing allocations keep their immutable deployment generation. A same-provider change retires no node, token or owner epoch and drains no execution: Docker and microsandbox nodes prepare the new target independently while serving their old pin, and E2B changes apply at once. +PUT accepts the same provider while no reset is active. Send the observed generation once and never replay an uncertain write automatically. New allocations use the newly committed specification, and existing allocations keep their immutable deployment generation. A same-provider change retires no node, token or owner epoch and drains no execution: Node providers prepare the new target independently while serving their old pin, and E2B changes apply at once. ### E2B key replacement @@ -177,7 +177,7 @@ One mutation gate serializes setup, PUT, reset, cancellation and finalization. A ## Nodes and allocations -Node capacity is approved by the administrator, separately from the deployment specification. `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and `max_retained`, default 2 and 8, and returns `{token, expires_at, enrollment_id}`; `enrollment_id` is a public handle of that command, never a credential. microsandbox uses both limits. Docker never suspends, so Core replaces its `max_retained` with `max_active`, here and in PATCH. E2B has no nodes and answers 409 `sandbox_deployment_conflict`. Core stores the approval with the token and copies it to the node it registers; a node cannot submit capacity, and its local checks may refuse a deployment it cannot run but never raise the limits. [Node capacity](../../docs/configuration.md#node-capacity) explains the limits for operators. +Node capacity is approved by the administrator, separately from the deployment specification. `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and `max_retained`, default 2 and 8, and returns `{token, expires_at, enrollment_id}`; `enrollment_id` is a public handle of that command, never a credential. microsandbox uses both limits. Docker and smolvm never suspend, so Core replaces their `max_retained` with `max_active`, here and in PATCH. E2B has no nodes and answers 409 `sandbox_deployment_conflict`. Core stores the approval with the token and copies it to the node it registers; a node cannot submit capacity, and its local checks may refuse a deployment it cannot run but never raise the limits. [Node capacity](../../docs/configuration.md#node-capacity) explains the limits for operators. `GET /core/v1/sandbox/nodes` returns `{data: [...]}` with, for each node: `id`, `name`, `provider`, `online`, `last_seen_at`, `created_at`, `max_active`, `max_retained`, the counts `active`, `reserved`, `running`, `retained`, `snapshots` and `cleanup_pending`, `provider_ready`, `diagnostic`, the host measurements `cpu_count`, `available_memory_bytes` and `available_disk_bytes`, `rollout`, `enrollment_id` and `core_url`. `enrollment_id` is the handle of the command that registered the node, or null when Core has none. `core_url` is the installation public URL at enrollment; a node whose `core_url` differs from the current public URL receives no new placements. Work already placed on it finishes there, including a placed Environment that has no allocation yet, and its retained sandboxes can still resume while the old address reaches Core. Remove it and add it again. @@ -191,22 +191,22 @@ A node without generation management reports its provider's readiness itself: `p Some fields keep one name across providers but differ in meaning, or do not apply. Deployment fields come from `GET /core/v1/sandbox/deployment`, node and allocation fields from the node routes, and Runtime fields from the [Runtime telemetry API](./runtime-observability-api.md), where `disk` appears only in the observation list and history is described under [Session Runtime history](./runtime-observability-api.md#session-runtime-history). -| Field | E2B | Docker | microsandbox | -| --- | --- | --- | --- | -| Deployment `specification.resources` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`; no disk quota | `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib` | -| Deployment `specification.runtime` | Absent; `configuration.template` selects the build | The full [release](#runtime-release); nodes match `image_id` or `image_manifest_digest` | The full [release](#runtime-release); nodes match `microsandbox_ref`, `runtime_sha256` and `firmware_sha256` | -| Deployment `metadata.template_build` | The build as Core read it when the selection was saved | Absent: `metadata` is empty | Absent: `metadata` is empty | -| Deployment `suspension` | `null`; Core does not suspend E2B sandboxes | `null` | `{idle_seconds, retention_seconds}` | -| Deployment `resources.allocations`, `resources.pending` | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Totals across all nodes | Totals across all nodes | -| Enrollment-token `max_active`, `max_retained` | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | `max_retained` always equals `max_active` | Both limits apply | -| Node list and detail | Empty list; detail returns 404 | Enrolled nodes | Enrolled nodes | -| Node `retained`, `snapshots`, `max_retained` | Not applicable | Docker never suspends: `retained` equals `active`, `snapshots` is 0 and `max_retained` equals `max_active` | Suspended sandboxes are `retained` minus `active` | -| Node `host.available_disk_bytes` | Not applicable | Free space on the filesystem of the node state directory, not a container's disk | Free space on the filesystem of the node state directory; sandbox disks have their own quotas | -| Allocation `compute_phase`, `compute_phase_changed_at` | Not applicable: no node allocations | Always `disabled`, counted as running until release; the time is the allocation's creation | Includes `suspended`; its time plus `suspension.retention_seconds` tells roughly when Core reclaims the snapshot | -| Runtime observation `cpu`, `memory` | From E2B metrics: `cpu.utilization_ratio` and `capacity_cores`, memory usage and limit; no cumulative CPU time | From Docker stats: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | From the VM: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | -| Runtime observation `disk` | E2B `diskUsed` and `diskTotal`; `null` when the template does not report them | `null`: no disk quota | `null` | -| Runtime observation `lifecycle_state: sleeping` | Never | Never | While suspended | -| Runtime history CPU | Mean of the utilization ratios E2B reported in each bucket | Derived from cumulative CPU time | Derived from cumulative CPU time | +| Field | E2B | Docker | microsandbox | smolvm | +| --- | --- | --- | --- | --- | +| Deployment `specification.resources` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`; no disk quota | `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib` | `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib` | +| Deployment `specification.runtime` | Absent; `configuration.template` selects the build | The full [release](#runtime-release); nodes match `image_id` or `image_manifest_digest` | The full [release](#runtime-release); nodes match `microsandbox_ref`, `runtime_sha256` and `firmware_sha256` | The full [release](#runtime-release); the node verifies the OCI archive against `image_id` and `image_manifest_digest` | +| Deployment `metadata.template_build` | The build as Core read it when the selection was saved | Absent: `metadata` is empty | Absent: `metadata` is empty | Absent: `metadata` is empty | +| Deployment `suspension` | `null`; Core does not suspend E2B sandboxes | `null` | `{idle_seconds, retention_seconds}` | `null` | +| Deployment `resources.allocations`, `resources.pending` | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Totals across all nodes | Totals across all nodes | Totals across all nodes | +| Enrollment-token `max_active`, `max_retained` | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | `max_retained` always equals `max_active` | Both limits apply | `max_retained` always equals `max_active` | +| Node list and detail | Empty list; detail returns 404 | Enrolled nodes | Enrolled nodes | Enrolled nodes | +| Node `retained`, `snapshots`, `max_retained` | Not applicable | Docker never suspends: `retained` equals `active`, `snapshots` is 0 and `max_retained` equals `max_active` | Suspended sandboxes are `retained` minus `active` | `retained` equals `active`, `snapshots` is 0 and `max_retained` equals `max_active` | +| Node `host.available_disk_bytes` | Not applicable | Free space on the filesystem of the node state directory, not a container's disk | Free space on the filesystem of the node state directory; sandbox disks have their own quotas | Free space on the filesystem of the node state directory | +| Allocation `compute_phase`, `compute_phase_changed_at` | Not applicable: no node allocations | Always `disabled`, counted as running until release; the time is the allocation's creation | Includes `suspended`; its time plus `suspension.retention_seconds` tells roughly when Core reclaims the snapshot | Always `disabled`, counted as running until release; the time is the allocation creation time | +| Runtime observation `cpu`, `memory` | From E2B metrics: `cpu.utilization_ratio` and `capacity_cores`, memory usage and limit; no cumulative CPU time | From Docker stats: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | From the VM: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | Unavailable: native observation is unsupported | +| Runtime observation `disk` | E2B `diskUsed` and `diskTotal`; `null` when the template does not report them | `null`: no disk quota | `null` | Unavailable: native observation is unsupported | +| Runtime observation `lifecycle_state: sleeping` | Never | Never | While suspended | Never | +| Runtime history CPU | Mean of the utilization ratios E2B reported in each bucket | Derived from cumulative CPU time | Derived from cumulative CPU time | Unavailable: native observation is unsupported | ## Errors diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 05fbe1fc8..c0a0760ea 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: 537ae957a77d625b3adb7828156352a661ae09270b57b174406c6db159a957bd +source_hash: f09554e72efbda0f009587504f9b3b77e7c61156b21e63a37da9ca5e06288797 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -35,13 +35,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 拒绝该字段 | +| `provider` | 必须是 `docker`、`microsandbox`、`smolvm`、`e2b` 中恰好一个 | +| `resources` | 每个沙箱的限制,见下文;Docker、microsandbox 和 smolvm 必填,E2B 可选 | +| `runtime` | 不可变的 [Runtime 发行版](#runtime-release);Docker、microsandbox 和 smolvm 必填,E2B 必须省略 | +| `configuration` | 提供商的公开选择器。E2B:不可变的 `template` 构建以及可选且配套的 `api_url` 和 `domain`。Docker、microsandbox 和 smolvm 仅接受 `{}` 或省略 | +| `credential` | 提供商的只写凭据。E2B:`{api_key}`,首次设置时必填,在 PUT 中省略以保留当前密钥;null 或空密钥无效。Docker、microsandbox 和 smolvm 拒绝该字段 | -请求中没有 Core 地址。Core 根据安装公开 URL(`config.json` 中的 `public_url`,Core 对应 `OAC_PUBLIC_URL`)派生部署的 `core_url`:这是节点和沙箱客户机访问 Core 时使用的源地址。包含 `core_url` 的请求会像包含任何其他未知成员一样被拒绝,并返回 400 `invalid_request`。E2B 客户机从 E2B 云访问 Core,因此当公开 URL 为回环地址时,E2B 选择会被拒绝,并返回 409 `sandbox_configuration_error`。Docker 和 microsandbox 选择接受回环公开 URL,但这仅适用于本地开发,因为客户机的回环地址无法访问其主机。更改公开 URL 属于安装变更:使用旧地址注册的节点不会收到新沙箱,必须移除后重新添加。 +请求中没有 Core 地址。Core 根据安装公开 URL(`config.json` 中的 `public_url`,Core 对应 `OAC_PUBLIC_URL`)派生部署的 `core_url`:这是节点和沙箱客户机访问 Core 时使用的源地址。包含 `core_url` 的请求会像包含任何其他未知成员一样被拒绝,并返回 400 `invalid_request`。E2B 客户机从 E2B 云访问 Core,因此当公开 URL 为回环地址时,E2B 选择会被拒绝,并返回 409 `sandbox_configuration_error`。Docker、microsandbox 和 smolvm 选择接受回环公开 URL,但这仅适用于本地开发,因为客户机的回环地址无法访问其主机。更改公开 URL 属于安装变更:使用旧地址注册的节点不会收到新沙箱,必须移除后重新添加。 ### 资源 {#resources} @@ -49,8 +49,8 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 ` | --- | --- | | `cpus` | 整数,1 到 255 | | `memory_mib` | 整数,512 到 1048576 MiB | -| `root_disk_mib` | microsandbox:至少 1024 MiB;Docker 和 E2B:省略或为零 | -| `environment_disk_mib` | microsandbox 自有磁盘:至少 1024 MiB;外部文件系统:零表示不请求配额;正值必须至少为 1024 MiB 且要求强制执行配额;Docker 和 E2B:省略或为零 | +| `root_disk_mib` | microsandbox 和 smolvm:至少 1024 MiB;Docker 和 E2B:省略或为零 | +| `environment_disk_mib` | microsandbox 和 smolvm 自有磁盘:至少 1024 MiB;microsandbox 外部文件系统:零表示不请求配额;正值必须至少为 1024 MiB 且要求强制执行配额;Docker 和 E2B:省略或为零 | 这些限制描述每个沙箱。节点的 `max_active` 和 `max_retained` 是独立的预留限制,主机测量值绝不会允许超过其中任何一个。即使数值满足这些边界,原生提供商仍可能拒绝这些值。 @@ -65,12 +65,12 @@ microsandbox 会配置 CPU、内存、托管根磁盘,以及位于 `/environme ### Runtime 发行版 {#runtime-release} -Docker 和 microsandbox 使用一个经过验证发行包中的每个字段: +Docker、microsandbox 和 smolvm 使用一个经过验证发行包中的每个字段: | 字段 | 标识 | | --- | --- | | `source_commit` | 由 40 个字符组成的小写提交 SHA | -| `image_id` | Docker 镜像配置 ID:`sha256:` 加 64 个小写十六进制字符 | +| `image_id` | OCI 镜像配置 ID:`sha256:` 加 64 个小写十六进制字符 | | `image_manifest_digest` | OCI 镜像清单摘要,格式相同 | | `microsandbox_ref` | `oac-runtime@sha256:` 加 64 个小写十六进制字符 | | `runtime_sha256` | 原生 microsandbox Runtime 二进制文件的 SHA-256 | @@ -84,18 +84,18 @@ E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建 ### 配置发现 {#configuration-discovery} -`POST /core/v1/sandbox/providers/{provider}/discovery` 恰好接受 `configuration`、`credential` 和 `query` 对象,总大小最多为 64 KiB,运行时间最多为 30 秒。未知成员和 null 对象会被拒绝。凭据仅用于该请求,绝不会被存储或返回;发现操作不会保存任何内容、不会更改部署、不会分配任何资源,也绝不会证明某项选择会被准入。Docker 和 microsandbox 会以 400 `sandbox_operation_unsupported` 拒绝该操作。 +`POST /core/v1/sandbox/providers/{provider}/discovery` 恰好接受 `configuration`、`credential` 和 `query` 对象,总大小最多为 64 KiB,运行时间最多为 30 秒。未知成员和 null 对象会被拒绝。凭据仅用于该请求,绝不会被存储或返回;发现操作不会保存任何内容、不会更改部署、不会分配任何资源,也绝不会证明某项选择会被准入。Docker、microsandbox 和 smolvm 会以 400 `sandbox_operation_unsupported` 拒绝该操作。 对于 E2B,发布 `{"configuration": {"api_url": "…", "domain": "…"}, "credential": {"api_key": "…"}, "query": {}}` 可列出模板;添加 `"query": {"template": "template-id"}` 可列出该模板的就绪构建;官方端点可能会省略两个端点字段。结果为 `{"templates": [{"id": "…", "names": ["…"]}]}` 和 `{"builds": [{"id": "build-uuid", "cpus": 2, "memory_mib": 2048}]}`,两者都可能为空。固定版本 SDK 辅助工具读取 `GET /v2/templates` 并返回最多 200 条结果;达到限制或提供商失败会返回 503 和通用消息。部署写入操作会单独验证所选构建。 ## 安全响应 {#safe-response} -GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url`(只读:安装公开 URL,即使配置前也存在)、`mode`、`generation`、`owner_epoch`、`reset`、`rollout`、`suspension`、`resources` 和 `credential_configured`。已配置的部署还会返回 `specification`、`specification_digest`、`configuration` 和 `metadata`:这是适配器对其选择器及其记录的观测结果所作的公开投影,绝不会包含原始存储值或机密。E2B 返回 `configuration.template`、`configuration.api_url`、`configuration.domain`,并在记录后返回 `metadata.template_build`。Docker 和 microsandbox 返回空的 `configuration` 和 `metadata` 对象以及 `credential_configured: false`;未配置的部署则不含这两个对象。 +GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url`(只读:安装公开 URL,即使配置前也存在)、`mode`、`generation`、`owner_epoch`、`reset`、`rollout`、`suspension`、`resources` 和 `credential_configured`。已配置的部署还会返回 `specification`、`specification_digest`、`configuration` 和 `metadata`:这是适配器对其选择器及其记录的观测结果所作的公开投影,绝不会包含原始存储值或机密。E2B 返回 `configuration.template`、`configuration.api_url`、`configuration.domain`,并在记录后返回 `metadata.template_build`。Docker、microsandbox 和 smolvm 返回空的 `configuration` 和 `metadata` 对象以及 `credential_configured: false`;未配置的部署则不含这两个对象。 - `metadata.template_build` 为 `{status, resources: {cpus, memory_mib, root_disk_mib}}`:这是保存选择时 Core 通过固定版本 SDK 读取的构建。GET 绝不会调用 E2B,因此 E2B 中断期间该操作仍保持低成本。验证仅接受 CPU 数量和内存与所选值一致的 `ready` 构建;`root_disk_mib` 是构建的原生磁盘大小,Core 不会强制执行该值。未知值为 null,`metadata: {}` 表示未记录任何观测,在不提供凭据的情况下提交完全相同的 PUT 也不会刷新它。 - 所选 Provider 声明 checkpoint 支持时,`suspension` 为 `{idle_seconds, retention_seconds}`,即 Core 的 [suspension policy](../../../docs/zh/sandbox-provider.md#suspension);其他部署(包括未配置的部署)返回 null。 - 请求中的 `resources` 和响应中的 `specification.resources` 是每个沙箱的限制。响应中的 `resources.allocations` 和 `resources.pending` 分别计算尚未释放的分配,以及尚未分配沙箱的待处理托管 Environment。 -- 未配置的部署具有空的 provider 且没有 specification。Docker 和 microsandbox 使用 `mode: nodes`;E2B 使用 `mode: direct`,且没有合成节点。 +- 未配置的部署具有空的 provider 且没有 specification。Docker、microsandbox 和 smolvm 使用 `mode: nodes`;E2B 使用 `mode: direct`,且没有合成节点。 - `generation` 标识已保存的选择。`owner_epoch` 用于对执行所有者和节点连接进行栅栏隔离;它不能替代 `expected_generation`。 - `specification_digest` 是服务器对提供商、限制和 Runtime 发行版的标识;注册过程会原样回显该值。 @@ -105,7 +105,7 @@ GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url POST 会在持久保存候选配置之前对其进行验证,并且不会创建计算资源、Session 或模型请求。在当前代次上,完全相同的选择是无操作;使用旧代次则返回 409 `sandbox_generation_stale`,即使请求体相同也是如此。缺少前置条件或准备失败时,已提交的提供商保持不变。更改后端前必须先重置。POST 还会在重置完成后使用该重置的新代次进行初始化。 -仅在没有重置进行时,PUT 才接受同一提供商。只发送一次观测到的代次,绝不自动重放结果不确定的写入操作。新分配使用新提交的 specification,现有分配则保留其不可变的部署代次。同提供商更改不会停用任何节点、令牌或所有者 epoch,也不会排空任何执行:Docker 和 microsandbox 节点在继续提供旧固定版本的同时独立准备新目标,而 E2B 更改会立即生效。 +仅在没有重置进行时,PUT 才接受同一提供商。只发送一次观测到的代次,绝不自动重放结果不确定的写入操作。新分配使用新提交的 specification,现有分配则保留其不可变的部署代次。同提供商更改不会停用任何节点、令牌或所有者 epoch,也不会排空任何执行:节点在继续提供旧固定版本的同时独立准备新目标,而 E2B 更改会立即生效。 ### E2B 密钥替换 {#e2b-key-replacement} @@ -180,7 +180,7 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 ## 节点与分配 {#nodes-and-allocations} -节点容量由管理员批准,独立于部署 specification。`POST /core/v1/sandbox/enrollment-tokens` 接受可选的 `max_active` 和 `max_retained`,默认分别为 2 和 8,并返回 `{token, expires_at, enrollment_id}`;`enrollment_id` 是该命令的公共句柄,绝不是凭据。microsandbox 会使用两个限制。Docker 绝不暂停,因此 Core 会在此处和 PATCH 中用 `max_active` 替换其 `max_retained`。E2B 没有节点,并返回 409 `sandbox_deployment_conflict`。Core 将批准值与令牌一同存储,并复制到其注册的节点;节点不能提交容量,其本地检查可以拒绝其无法运行的部署,但绝不会提高限制。[节点容量](../../../docs/zh/configuration.md#node-capacity)为操作员说明了这些限制。 +节点容量由管理员批准,独立于部署 specification。`POST /core/v1/sandbox/enrollment-tokens` 接受可选的 `max_active` 和 `max_retained`,默认分别为 2 和 8,并返回 `{token, expires_at, enrollment_id}`;`enrollment_id` 是该命令的公共句柄,绝不是凭据。microsandbox 会使用两个限制。Docker 和 smolvm 绝不暂停,因此 Core 会在此处和 PATCH 中用 `max_active` 替换其 `max_retained`。E2B 没有节点,并返回 409 `sandbox_deployment_conflict`。Core 将批准值与令牌一同存储,并复制到其注册的节点;节点不能提交容量,其本地检查可以拒绝其无法运行的部署,但绝不会提高限制。[节点容量](../../../docs/zh/configuration.md#node-capacity)为操作员说明了这些限制。 `GET /core/v1/sandbox/nodes` 返回 `{data: [...]}`,其中每个节点包含 `id`、`name`、`provider`、`online`、`last_seen_at`、`created_at`、`max_active`、`max_retained`,计数项 `active`、`reserved`、`running`、`retained`、`snapshots` 和 `cleanup_pending`,以及 `provider_ready`、`diagnostic`、主机测量值 `cpu_count`、`available_memory_bytes` 和 `available_disk_bytes`、`rollout`、`enrollment_id` 和 `core_url`。`enrollment_id` 是注册该节点的命令的句柄;如果 Core 没有该句柄,则为 null。`core_url` 是注册时的安装公开 URL;`core_url` 与当前公开 URL 不同的节点不会收到新放置。已放置到该节点的工作会继续在那里完成,包括已放置但尚未获得分配的 Environment;只要旧地址仍能访问 Core,其保留沙箱仍可恢复。请移除该节点并重新添加。 @@ -194,22 +194,22 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 某些字段在不同提供商中保持同一名称,但含义不同或不适用。部署字段来自 `GET /core/v1/sandbox/deployment`,节点和分配字段来自节点路由,Runtime 字段来自 [Runtime 遥测 API](runtime-observability-api.md);其中 `disk` 仅出现在观测列表中,历史则在 [Session Runtime 历史](runtime-observability-api.md#session-runtime-history)下说明。 -| 字段 | E2B | Docker | microsandbox | -| --- | --- | --- | --- | -| 部署 `specification.resources` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`;无磁盘配额 | `cpus`、`memory_mib`、`root_disk_mib` 和 `environment_disk_mib` | -| 部署 `specification.runtime` | 不存在;`configuration.template` 用于选择构建 | 完整的[发行版](#runtime-release);节点必须与 `image_id` 或 `image_manifest_digest` 匹配 | 完整的[发行版](#runtime-release);节点必须与 `microsandbox_ref`、`runtime_sha256` 和 `firmware_sha256` 匹配 | -| 部署 `metadata.template_build` | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 | 不存在:`metadata` 为空 | -| 部署 `suspension` | `null`;Core 不暂停 E2B 沙箱 | `null` | `{idle_seconds, retention_seconds}` | -| 部署 `resources.allocations`、`resources.pending` | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | 所有节点的总数 | 所有节点的总数 | -| 注册令牌 `max_active`、`max_retained` | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | `max_retained` 始终等于 `max_active` | 两个限制均适用 | -| 节点列表和详情 | 空列表;详情返回 404 | 已注册节点 | 已注册节点 | -| 节点 `retained`、`snapshots`、`max_retained` | 不适用 | Docker 绝不暂停:`retained` 等于 `active`,`snapshots` 为 0,`max_retained` 等于 `max_active` | 已暂停沙箱数为 `retained` 减去 `active` | -| 节点 `host.available_disk_bytes` | 不适用 | 节点状态目录所在文件系统的可用空间,而不是容器的磁盘 | 节点状态目录所在文件系统的可用空间;沙箱磁盘有自己的配额 | -| 分配 `compute_phase`、`compute_phase_changed_at` | 不适用:没有节点分配 | 始终为 `disabled`,在释放前计为运行中;该时间为分配创建时间 | 包含 `suspended`;该时间加上 `suspension.retention_seconds` 可大致确定 Core 回收快照的时间 | -| Runtime 观测 `cpu`、`memory` | 来自 E2B 指标:`cpu.utilization_ratio` 和 `capacity_cores`、内存使用量和限制;无累计 CPU 时间 | 来自 Docker stats:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | 来自 VM:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | -| Runtime 观测 `disk` | E2B `diskUsed` 和 `diskTotal`;模板未报告时为 `null` | `null`:无磁盘配额 | `null` | -| Runtime 观测 `lifecycle_state: sleeping` | 从不 | 从不 | 暂停期间 | -| Runtime 历史 CPU | 每个时间桶中 E2B 所报告使用率比值的平均值 | 根据累计 CPU 时间派生 | 根据累计 CPU 时间派生 | +| 字段 | E2B | Docker | microsandbox | smolvm | +| --- | --- | --- | --- | --- | +| 部署 `specification.resources` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`;无磁盘配额 | `cpus`、`memory_mib`、`root_disk_mib` 和 `environment_disk_mib` | `cpus`、`memory_mib`、`root_disk_mib` 和 `environment_disk_mib` | +| 部署 `specification.runtime` | 不存在;`configuration.template` 用于选择构建 | 完整的[发行版](#runtime-release);节点必须与 `image_id` 或 `image_manifest_digest` 匹配 | 完整的[发行版](#runtime-release);节点必须与 `microsandbox_ref`、`runtime_sha256` 和 `firmware_sha256` 匹配 | 完整的[发行版](#runtime-release);节点依据 `image_id` 和 `image_manifest_digest` 校验 OCI 归档 | +| 部署 `metadata.template_build` | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 | 不存在:`metadata` 为空 | 不存在:`metadata` 为空 | +| 部署 `suspension` | `null`;Core 不暂停 E2B 沙箱 | `null` | `{idle_seconds, retention_seconds}` | `null` | +| 部署 `resources.allocations`、`resources.pending` | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | 所有节点的总数 | 所有节点的总数 | 所有节点的总数 | +| 注册令牌 `max_active`、`max_retained` | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | `max_retained` 始终等于 `max_active` | 两个限制均适用 | `max_retained` 始终等于 `max_active` | +| 节点列表和详情 | 空列表;详情返回 404 | 已注册节点 | 已注册节点 | 已注册节点 | +| 节点 `retained`、`snapshots`、`max_retained` | 不适用 | Docker 绝不暂停:`retained` 等于 `active`,`snapshots` 为 0,`max_retained` 等于 `max_active` | 已暂停沙箱数为 `retained` 减去 `active` | `retained` 等于 `active`,`snapshots` 为 0,`max_retained` 等于 `max_active` | +| 节点 `host.available_disk_bytes` | 不适用 | 节点状态目录所在文件系统的可用空间,而不是容器的磁盘 | 节点状态目录所在文件系统的可用空间;沙箱磁盘有自己的配额 | 节点状态目录所在文件系统的可用空间 | +| 分配 `compute_phase`、`compute_phase_changed_at` | 不适用:没有节点分配 | 始终为 `disabled`,在释放前计为运行中;该时间为分配创建时间 | 包含 `suspended`;该时间加上 `suspension.retention_seconds` 可大致确定 Core 回收快照的时间 | 始终为 `disabled`,在释放前计为运行中;该时间为分配创建时间 | +| Runtime 观测 `cpu`、`memory` | 来自 E2B 指标:`cpu.utilization_ratio` 和 `capacity_cores`、内存使用量和限制;无累计 CPU 时间 | 来自 Docker stats:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | 来自 VM:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | 不可用:不支持原生观测 | +| Runtime 观测 `disk` | E2B `diskUsed` 和 `diskTotal`;模板未报告时为 `null` | `null`:无磁盘配额 | `null` | 不可用:不支持原生观测 | +| Runtime 观测 `lifecycle_state: sleeping` | 从不 | 从不 | 暂停期间 | 从不 | +| Runtime 历史 CPU | 每个时间桶中 E2B 所报告使用率比值的平均值 | 根据累计 CPU 时间派生 | 根据累计 CPU 时间派生 | 不可用:不支持原生观测 | ## 错误 {#errors} diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py index 5cda1e2fc..f2c203020 100644 --- a/deploy/node/node_install.py +++ b/deploy/node/node_install.py @@ -1295,7 +1295,7 @@ def main(argv=None): source.add_argument("--source-url", type=origin) source.add_argument("--bundle", type=Path) parser.add_argument("--core-url", type=origin) - parser.add_argument("--provider", choices=("docker", "microsandbox"), help="Optional assertion; Core owns provider selection") + parser.add_argument("--provider", choices=provider_assets.AUTOMATIC_PROVIDERS, help="Optional assertion; Core owns provider selection") parser.add_argument("--installation-id", required=True) parser.add_argument("--checkpoint-root", type=Path, help="Private microsandbox checkpoint directory, shared across compatible nodes when configured") parser.add_argument("--enrollment-token-stdin", action="store_true", help="Read the one-time enrollment token from standard input") diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index 678f5bc26..c9faed5f3 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -7,6 +7,8 @@ import urllib.request import uuid +import provider_assets + class SpecificationError(Exception): pass @@ -27,7 +29,7 @@ def release(manifest): # BEGIN GENERATED DEPLOYMENT CONTRACT # Generated from sandbox/deployment_contract.go; do not edit. -_CONTRACT = json.loads("{\"workspace_fields\":[\"attachment\",\"user_xattr\",\"capacity_quota\"],\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"runtime\":false,\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true},\"disk\":true,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}") +_CONTRACT = json.loads("{\"workspace_fields\":[\"attachment\",\"user_xattr\",\"capacity_quota\"],\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"runtime\":false,\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true},\"disk\":true,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}},\"smolvm\":{\"mode\":\"nodes\",\"disk\":true,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}") # END GENERATED DEPLOYMENT CONTRACT @@ -89,7 +91,7 @@ def validate(data, args): raise SpecificationError(PUBLIC_URL_CHANGED) try: provider, spec = data["provider"], data["specification"] - if (provider not in ("docker", "microsandbox") or data["installation_id"] != args.installation_id + if (provider not in provider_assets.AUTOMATIC_PROVIDERS or data["installation_id"] != args.installation_id or data["core_url"] != args.core_url or type(data["generation"]) is not int or data["generation"] < 1 or getattr(args, "provider", None) not in (None, provider) or set(spec) - {"workspace"} != {"resources", "runtime"} diff --git a/deploy/node/provider_assets.py b/deploy/node/provider_assets.py index d64a3aea8..413d29306 100644 --- a/deploy/node/provider_assets.py +++ b/deploy/node/provider_assets.py @@ -1,7 +1,9 @@ # Code generated by go run ./services/core/cmd/provider-artifacts -write; DO NOT EDIT. import json -CATALOG = json.loads("{\n \"docker\": [\n {\n \"path\": \"native/bin/oac-node\",\n \"suffix\": \"sandbox-node\",\n \"role\": \"node\"\n },\n {\n \"path\": \"images/runtime.tar.gz\",\n \"suffix\": \"runtime.tar.gz\",\n \"role\": \"image\"\n },\n {\n \"path\": \"runtime/seccomp.json\",\n \"suffix\": \"seccomp.json\",\n \"role\": \"policy\"\n }\n ],\n \"microsandbox\": [\n {\n \"path\": \"native/bin/oac-node\",\n \"suffix\": \"sandbox-node\",\n \"role\": \"node\"\n },\n {\n \"path\": \"images/runtime.tar.gz\",\n \"suffix\": \"runtime.tar.gz\",\n \"role\": \"image\"\n },\n {\n \"path\": \"runtime/seccomp.json\",\n \"suffix\": \"seccomp.json\",\n \"role\": \"policy\"\n },\n {\n \"path\": \"native/bin/oac-microsandbox-provider\",\n \"suffix\": \"microsandbox-provider\",\n \"role\": \"runtime\"\n },\n {\n \"path\": \"native/microsandbox/msb\",\n \"suffix\": \"msb\",\n \"role\": \"runtime\"\n },\n {\n \"path\": \"native/microsandbox/libkrunfw.so.5.6.1\",\n \"suffix\": \"libkrunfw.so.5.6.1\",\n \"role\": \"runtime\"\n }\n ]\n}\n") +CATALOG = json.loads("{\n \"docker\": [\n {\n \"path\": \"native/bin/oac-node\",\n \"suffix\": \"sandbox-node\",\n \"role\": \"node\"\n },\n {\n \"path\": \"images/runtime.tar.gz\",\n \"suffix\": \"runtime.tar.gz\",\n \"role\": \"image\"\n },\n {\n \"path\": \"runtime/seccomp.json\",\n \"suffix\": \"seccomp.json\",\n \"role\": \"policy\"\n }\n ],\n \"microsandbox\": [\n {\n \"path\": \"native/bin/oac-node\",\n \"suffix\": \"sandbox-node\",\n \"role\": \"node\"\n },\n {\n \"path\": \"images/runtime.tar.gz\",\n \"suffix\": \"runtime.tar.gz\",\n \"role\": \"image\"\n },\n {\n \"path\": \"runtime/seccomp.json\",\n \"suffix\": \"seccomp.json\",\n \"role\": \"policy\"\n },\n {\n \"path\": \"native/bin/oac-microsandbox-provider\",\n \"suffix\": \"microsandbox-provider\",\n \"role\": \"runtime\"\n },\n {\n \"path\": \"native/microsandbox/msb\",\n \"suffix\": \"msb\",\n \"role\": \"runtime\"\n },\n {\n \"path\": \"native/microsandbox/libkrunfw.so.5.6.1\",\n \"suffix\": \"libkrunfw.so.5.6.1\",\n \"role\": \"runtime\"\n }\n ],\n \"smolvm\": [\n {\n \"path\": \"native/bin/oac-node\",\n \"suffix\": \"sandbox-node\",\n \"role\": \"node\"\n },\n {\n \"path\": \"images/runtime.tar.gz\",\n \"suffix\": \"runtime.tar.gz\",\n \"role\": \"image\"\n }\n ]\n}\n") + +AUTOMATIC_PROVIDERS = json.loads("[\n \"docker\",\n \"microsandbox\"\n]\n") def artifacts(provider, roles=None): return tuple(item["path"] for item in CATALOG[provider] if roles is None or item["role"] in roles) diff --git a/deploy/node/test_node_spec.py b/deploy/node/test_node_spec.py index ef6c7458f..207463659 100644 --- a/deploy/node/test_node_spec.py +++ b/deploy/node/test_node_spec.py @@ -93,6 +93,14 @@ def test_invalid_limits_digests_and_provider_assertions_are_rejected(self): with self.assertRaises(node_spec.SpecificationError): node_spec.validate(self.data, self.args) + def test_manual_provider_is_not_advertised_by_automatic_installer(self): + data = copy.deepcopy(self.data) + data["provider"] = "smolvm" + data["specification"]["resources"].update(root_disk_mib=8192, environment_disk_mib=8192) + data["specification_digest"] = node_spec.digest("smolvm", data["specification"]) + with self.assertRaisesRegex(node_spec.SpecificationError, "configuration differs or is invalid"): + node_spec.validate(data, self.args) + def test_capacity_requires_approved_bounded_integers(self): for key, value in (("max_active", None), ("max_active", True), ("max_active", 0), ("max_retained", 1), ("max_retained", 1000001)): diff --git a/docs/configuration.md b/docs/configuration.md index fb83fb72c..a3a1e4c42 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,7 +92,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 backend: Docker, microsandbox, smolvm or E2B | **System** → **Manage sandbox configuration**: the setup wizard for Docker, microsandbox and E2B, ending with **Save configuration**; select smolvm through the administration API | `/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 | | 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 | diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index aa02e573c..38d66b64f 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -2,9 +2,9 @@ title: "Add and manage nodes" --- -A node is a Linux host that runs sandboxes for Core-hosted Sessions when the sandbox backend is Docker or microsandbox. Core places each new Session on a node with free capacity; the node creates the sandbox, and the sandbox connects back to Core. E2B needs no nodes. Machines that applications connect for their own Sessions are [self-hosted executors](./self-hosted.md), not nodes. +A node is a Linux host that runs sandboxes for Core-hosted Sessions when the sandbox backend is Docker, microsandbox or smolvm. Core places each new Session on a node with free capacity; the node creates the sandbox, and the sandbox connects back to Core. E2B needs no nodes. Machines that applications connect for their own Sessions are [self-hosted executors](./self-hosted.md), not nodes. -You add a node by generating a command in Web and running it on the host. The [sandbox deployment contract](../../contracts/agents-api/sandbox-deployment.md) defines the sandbox settings and their change rules, and the [node protocol](../../contracts/agents-api/node-generation-protocol.md) defines how nodes prepare and keep Runtime generations. +For Docker and microsandbox, add a node by generating a command in Web and running it on the host. Register smolvm nodes manually using the steps below. The [sandbox deployment contract](../../contracts/agents-api/sandbox-deployment.md) defines the sandbox settings and their change rules, and the [node protocol](../../contracts/agents-api/node-generation-protocol.md) defines how nodes prepare and keep Runtime generations. ## Before you add a node @@ -47,6 +47,7 @@ The installer shows each phase as it runs and, once Core confirms the node, a su - SELinux not enforcing. The installer does not support hosts with enforcing SELinux. - Docker: rootful Docker Engine running, its socket `/var/run/docker.sock` owned by the `docker` group with mode `0660`, enforcing CPU and memory limits (cgroup v2). - microsandbox: `/dev/kvm` in the `kvm` group (hardware or nested virtualization), and the libraries microsandbox links (glibc). +- smolvm (manual registration): `/dev/kvm` and a persistent smolvm API service bound to a Unix socket accessible only to the node account; the packaged Runtime OCI archive must remain on the node. - CPUs and memory for at least one sandbox of the installation's size, and about 2 GB of disk for the Runtime image. - Access to the console and Core at the public URL; sandboxes reach Core too. @@ -126,12 +127,15 @@ It never deletes sandboxes, volumes or images. It keeps the Runtime image and pr Use manual registration when you manage the node's files and service yourself instead of running Web's command. A manually registered node serves only the configuration it registered with: after a size or Runtime change, **Nodes** shows it as **Node software incompatible**, and it keeps serving the old configuration until you remove it and register the host again. +For smolvm, select the backend through the [Core administration API](./operations.md#script-the-core-api): POST `/sandbox/deployment` with the observed `expected_generation`, `provider: "smolvm"`, `configuration: {}`, `resources` including both disk sizes, and the complete matching [Runtime release identity](../../contracts/agents-api/sandbox-deployment.md#runtime-release). Web's configuration wizard and **Add node** command do not set up the smolvm service. Run `smolvm serve start --listen unix:///var/lib/oac/smolvm.sock` as the same account as `oac-node`, with a durable smolvm machine store; the API socket grants control over machines and must have mode `0600` for that account. Use a smolvm build that supports persistent machine labels and local OCI archive images. + 1. Take `oac-node` from the same release as Core. 2. Get an enrollment token: the token in a command from **Add node**, or `POST /core/v1/sandbox/enrollment-tokens` with the Core key. It is single-use and carries the node's approved capacity; the response's `expires_at` says when it expires. Save it in a `0600` file on the host. 3. Read the node configuration with the token, which does not consume it: `GET /api/v1/sandbox-node/configuration` with `Authorization: Bearer `. 4. Write a private provider file. Copy `provider`, `installation_id`, `core_url`, `generation` and `specification` from the response, and add a `native` object with the host settings of that provider. The adapter reads sandbox size, the Runtime image and artifact hashes from `specification`: - Docker: the [Docker node configuration](../configuration.md#docker-node-configuration) fields, with `host` an explicit Unix socket, `image` the local ID of the imported Runtime image and `seccomp_file` absolute. - microsandbox: absolute `helper_path`, `runtime_path` and `firmware_path`, a `network` policy, an explicit `checkpoint_root` for private checkpoint archives, and `runtime_home`: a private directory, which the helper creates with mode `0700` when it is missing. microsandbox places Unix sockets under it, so keep its path within 48 bytes; the installer refuses a longer one for its own nodes. + - smolvm: `socket` as a canonical local `unix:///absolute/path.sock` API URL, `image_file` as the absolute path to the Runtime release OCI archive, and `receipt_root` as a persistent private directory (mode `0700`). The node verifies the archive against Core’s selected Runtime image and manifest digests. Use manual registration; the installer does not provision a smolvm service. Before the first microsandbox registration, initialize the empty `checkpoint_root` as the node service account using the same release's source checkout: `PYTHONPATH=deploy/node python3 -c 'from node_install import prepare_checkpoint_root; prepare_checkpoint_root("/absolute/private/checkpoints")'`. This reuses the installer's exclusive UUID-marker initialization and ownership checks. Initialize a shared store once; other nodes use the existing marker. Never replace the marker or initialize over restored or nonempty unowned storage. 5. Register, then run the node under the host's service supervisor, with real absolute paths: diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index f70b826dc..fc8c7073a 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -113,7 +113,7 @@ A new provider takes these steps: 3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. 4. For a node adapter, export from its package the `BuildLocal` constructor, the typed `native` object it decodes and the native files it adds to the shared node artifacts. Node-local settings, such as host paths, live only in that object; resources and the Runtime release are read from the node configuration's `specification`. 5. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode and deployment policy with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads them from `deploy/node/node_spec.py`, and the TypeScript client and Web from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. -6. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. +6. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. Set `AutomaticInstall` only when the bundled node installer can provision the native service: its generated provider list controls both **Add node** and the installer; other node providers use [manual registration](./getting-started/nodes.md#register-a-node-manually). **Known design gap:** Web still names providers in the setup wizard's backend choice, the Docker confirmation and E2B's configuration fields wherever Web shows or parses them (the setup step with its service presets, the deployment summary and the client's deployment projection), because the protocol declares no configuration fields yet. Exposing another provider through that surface currently requires a shared Web edit. This coupling does not meet [Complexity stays in the adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. @@ -236,8 +236,15 @@ Native acceptance proves what fixtures cannot: creation, lease behavior, owned p | --- | --- | --- | --- | | Docker (node) | [`sandbox/docker`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/docker) | Node proxy in [`sandbox/node`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/node) | [Docker adapter](#docker-adapter) | | microsandbox (node) | [`sandbox/microsandbox`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/microsandbox) | [`tools/microsandbox-provider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md) | [Nodes](./getting-started/nodes.md) | +| smolvm (node) | `sandbox/smolvm` | smolvm HTTP API on a local Unix socket | [Nodes](./getting-started/nodes.md) | | E2B (direct) | [`sandbox/e2b`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/e2b) | [`tools/e2b-provider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/e2b-provider/README.md) | [Sandbox deployment](../contracts/agents-api/sandbox-deployment.md#e2b-configuration); application-managed templates in [`deploy/e2b`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md) | +## smolvm adapter + +The node-local `smolvm` adapter boots the published Runtime OCI archive in one VM per allocation. Its `native` configuration has three fields: `socket` is a canonical `unix:///absolute/path.sock` URL for a running smolvm API service; `image_file` is the absolute path to the published Runtime OCI archive on the node; and `receipt_root` is a private, persistent directory (mode `0700`) for bootstrap receipts. Keep the receipt directory and smolvm machine store on persistent storage for the lifetime of each allocation. The API socket must be a Unix socket with mode `0600`, accessible to the node account, because it permits machine creation and exec. + +The adapter verifies installation, tenant, Environment and allocation labels before each read or mutation. It writes an ownership-bound receipt after installing the Runtime credentials and starting the guest, and treats a missing receipt as incomplete bootstrap. `RunCommand` stages binary stdin in the owned VM and runs as UID 1000. Checkpoint and external workspace operations are explicitly unsupported. + ## Docker adapter The Docker Sandbox Provider ([`sandbox/docker`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/docker)) runs every Runtime image, whichever Harness it serves, with the same container settings ([`container_options.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/docker/container_options.go)): diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 7cf303515..44ed6f990 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 6bfcaa201c332aa1907f6ab7b0e0ffde4c8f635b363dbea2d0633947615e7264 +source_hash: 80eafc82c49f28d473911bfcab0f76b4fb76b39005bf64334829620116236b93 --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -96,7 +96,7 @@ Runtime 在发现 Harness 适配器之前解析资源目录。`` | 设置 | 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) | +| 沙箱后端:Docker、microsandbox、smolvm 或 E2B | **System** → **Manage sandbox configuration**:Docker、microsandbox 和 E2B 使用设置向导,最后点击 **Save configuration**;smolvm 通过管理 API 选择 | `/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 密钥仅可写入,并且已加密 | | 节点及其容量 | **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 存储其摘要 | diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 9e7d24e05..3c298d7a3 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,12 +1,12 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: 0c62518cc740387ed03035ea4870108edeaaa82331a7509b573fb2b775ae3aba +source_hash: 7b83f6f64b4aa1b4336c8f1fe12eecb1cecf5a4fa2388198357ee776bd52036e --- -节点是一台 Linux 主机,在沙箱后端为 Docker 或 microsandbox 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 +节点是一台 Linux 主机,在沙箱后端为 Docker、microsandbox 或 smolvm 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 -添加节点的方法是在 Web 生成命令并在主机上执行。[沙箱部署协议](../../../contracts/agents-api/zh/sandbox-deployment.md)定义沙箱设置及其变更规则,[节点协议](../../../contracts/agents-api/zh/node-generation-protocol.md)定义节点如何准备和保留 Runtime 代次。 +Docker 和 microsandbox 节点通过 Web 生成命令并在主机上执行来添加。smolvm 节点按照下文步骤手动注册。[沙箱部署协议](../../../contracts/agents-api/zh/sandbox-deployment.md)定义沙箱设置及其变更规则,[节点协议](../../../contracts/agents-api/zh/node-generation-protocol.md)定义节点如何准备和保留 Runtime 代次。 ## 添加节点前 {#before-you-add-a-node} @@ -49,6 +49,7 @@ Microsandbox 的检查点执行兼容性包含主机内核和 CPU profile。因 - SELinux 不处于 enforcing 模式。安装程序不支持 enforcing SELinux 主机。 - Docker:正在运行的 rootful Docker Engine,其 `/var/run/docker.sock` 套接字属于 `docker` 组,权限为 `0660`,并强制执行 CPU 和内存限制(cgroup v2)。 - microsandbox:`/dev/kvm` 属于 `kvm` 组(硬件或嵌套虚拟化),并具有 microsandbox 链接的库(glibc)。 +- smolvm(手动注册):可访问 `/dev/kvm`,并在只有节点账户可以访问的 Unix socket 上运行持久的 smolvm API 服务;节点须保留打包的 Runtime OCI 归档。 - CPU 和内存至少足以运行一个所配置规格的沙箱,以及约 2 GB 的 Runtime 镜像磁盘空间。 - 可通过公开 URL 访问控制台和 Core;沙箱也能访问 Core。 @@ -128,12 +129,15 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 如果自行管理节点文件和服务,而不执行 Web 命令,请使用手动注册。手动注册的节点只服务注册时的配置:规格或 Runtime 修改后,**Nodes** 显示 **Node software incompatible**;它继续服务旧配置,直到移除节点并重新注册主机。 +使用 smolvm 时,通过 [Core 管理 API](operations.md#script-the-core-api)选择后端:向 `/sandbox/deployment` 发出 POST,包含观察到的 `expected_generation`、`provider: "smolvm"`、`configuration: {}`、含两项磁盘容量的 `resources`,以及匹配的完整 [Runtime 发行版标识](../../../contracts/agents-api/zh/sandbox-deployment.md#runtime-release)。Web 配置向导和 **Add node** 命令不会安装 smolvm 服务。使用与 `oac-node` 相同的账户运行 `smolvm serve start --listen unix:///var/lib/oac/smolvm.sock`,并持久保存 smolvm 机器数据;API socket 可控制机器,必须对该账户设置权限 `0600`。使用支持持久机器标签和本地 OCI 归档镜像的 smolvm 版本。 + 1. 使用与 Core 同一发行版本的 `oac-node`。 2. 获取注册令牌:使用 **Add node** 命令中的令牌,或通过 Core 密钥调用 `POST /core/v1/sandbox/enrollment-tokens`。令牌一次性使用,包含批准的节点容量;响应的 `expires_at` 给出过期时间。在主机上存入权限为 `0600` 的文件。 3. 使用令牌读取节点配置,不会消耗令牌:`GET /api/v1/sandbox-node/configuration`,带 `Authorization: Bearer `。 4. 写入私有提供商文件。从响应复制 `provider`、`installation_id`、`core_url`、`generation` 和 `specification`,并添加 `native` 对象,写入该提供商的主机设置。适配器从 `specification` 读取沙箱规格、Runtime 镜像和产物哈希: - Docker:[Docker 节点配置](../configuration.md#docker-node-configuration)中的字段,其中 `host` 是显式 Unix 套接字,`image` 是导入的 Runtime 镜像的本地 ID,`seccomp_file` 是绝对路径。 - microsandbox:绝对路径 `helper_path`、`runtime_path` 和 `firmware_path`;`network` 策略;私有检查点归档的显式 `checkpoint_root`;以及 `runtime_home` 私有目录。目录缺失时辅助程序以 `0700` 创建。microsandbox 在其中放置 Unix 套接字,因此路径不要超过 48 字节;安装程序对自管节点拒绝更长路径。 + - smolvm:`socket` 是规范的本地 `unix:///绝对路径.sock` API URL;`image_file` 是 Runtime 发行版 OCI 归档的绝对路径;`receipt_root` 是持久且私有的目录(权限 `0700`)。节点会根据 Core 选定的 Runtime 镜像及 manifest 摘要校验归档。请手动注册;安装程序不会部署 smolvm 服务。 首次注册 microsandbox 前,以 node 服务账户在相同发行版本的源码 checkout 中初始化空的 `checkpoint_root`:`PYTHONPATH=deploy/node python3 -c 'from node_install import prepare_checkpoint_root; prepare_checkpoint_root("/absolute/private/checkpoints")'`。它复用安装程序的 UUID marker 排他初始化与所有权检查。共享 store 只初始化一次,其他 node 使用已有 marker。不得替换 marker,也不得在恢复后的存储或非空且无所有权证明的目录上重新初始化。 5. 使用真实绝对路径注册,然后通过主机服务管理器运行节点: diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index ff6501786..e26181277 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: 63ad2e39e7536caee9bd43f9b643073d32b32147f1440d188d6d38752628adc7 +source_hash: 722dc88c9249a2885b414c90dede2c0e974f925c255d488f186ac76411e61c60 --- **Sandbox Provider** 为 Core 管理的 Environment 提供 Runtime daemon 运行所需的外层计算资源,以及启动 daemon 的有界引导流程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -115,7 +115,7 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` 3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node provider 仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 4. node adapter 从自己的包中导出 `BuildLocal` constructor、它解码的类型化 `native` 对象,以及它在共享 node artifact 之外添加的原生文件。node-local 设置(如主机路径)只存放在该对象中;resources 和 Runtime release 从 node 配置的 `specification` 读取。 5. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式和 deployment policy 与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取,因此 Web 读取这些声明,而不比较 provider kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 -6. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。 +6. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。只有打包的节点安装程序能够部署原生服务时,才设置 `AutomaticInstall`:生成的 provider 列表同时控制 **Add node** 和安装程序;其他节点提供商使用[手动注册](getting-started/nodes.md#register-a-node-manually)。 **已知设计缺口:** Web 仍在 setup 向导的后端选择、Docker 确认,以及所有显示或解析 E2B 配置字段的地方(带服务预设的 setup 步骤、部署摘要和客户端的部署投影)中指名 provider,因为协议尚未声明配置字段。通过该界面提供另一 provider 目前需要修改共享的 Web。此耦合不符合[复杂性留在 adapter 内](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter);新集成必须通过协议表达配置,把厂商专有行为留在 adapter。不得添加 Session 或 Turn 调度路径、厂商专有 column 或 API field,或 store 中的厂商 switch。 @@ -238,8 +238,15 @@ node 测试单独覆盖 disconnect、reconnect fencing,以及 Create response | --- | --- | --- | --- | | Docker (node) | [`sandbox/docker`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/docker) | [`sandbox/node`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/node) 中的 node proxy | [Docker adapter](#docker-adapter) | | microsandbox (node) | [`sandbox/microsandbox`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/microsandbox) | [`tools/microsandbox-provider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md) | [Node](getting-started/nodes.md) | +| smolvm (node) | `sandbox/smolvm` | 通过本地 Unix socket 访问 smolvm HTTP API | [Node](getting-started/nodes.md) | | E2B (direct) | [`sandbox/e2b`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/e2b) | [`tools/e2b-provider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/e2b-provider/README.md) | [沙箱部署](../../contracts/agents-api/zh/sandbox-deployment.md#e2b-configuration);应用管理的模板见 [`deploy/e2b`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md) | +## smolvm adapter {#smolvm-adapter} + +节点上的 `smolvm` adapter 将已发布的 Runtime OCI 归档运行在每个 allocation 专属的 VM 中。`native` 配置有三个字段:`socket` 是正在运行的 smolvm API 服务的规范 `unix:///绝对路径.sock` URL;`image_file` 是节点上已发布的 Runtime OCI 归档的绝对路径;`receipt_root` 是持久且仅允许所有者访问的引导回执目录(权限 `0700`)。每个 allocation 存活期间必须保留回执目录和 smolvm 机器数据。允许创建机器和执行命令的 API socket 必须是权限 `0600` 的 Unix socket,仅节点账户可以访问。 + +每次读取或修改前,adapter 都会核验 installation、tenant、Environment 与 allocation 标签。Runtime 凭据文件交付并启动 guest 后才写入绑定原生身份的回执;缺少回执表示引导尚未完成。`RunCommand` 在所属 VM 内暂存二进制标准输入,并以 UID 1000 运行。Checkpoint 和外部 workspace 操作明确不受支持。 + ## Docker adapter {#docker-adapter} Docker Sandbox Provider([`sandbox/docker`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/docker))对所有 Runtime image 使用相同 container setting,无论服务哪个 Harness([`container_options.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/docker/container_options.go)): diff --git a/go.mod b/go.mod index e851cd189..263ca9741 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/moby/moby/client v0.6.0 github.com/openai/openai-go/v3 v3.61.0 github.com/pressly/goose/v3 v3.27.3 + github.com/smol-machines/smolvm-sdk/smolvm-go v0.0.0-20261011012951-aa0738e5774e go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 diff --git a/go.sum b/go.sum index 3ed07c81a..4e3855c12 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,10 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/smol-machines/smolvm-sdk/smolvm-go v0.0.0-20261011005333-fa3dc1cac5c2 h1:RI50y3xAjrpA7QNBLLMAJXQ5v59HCFtHf+JlCQpvh+k= +github.com/smol-machines/smolvm-sdk/smolvm-go v0.0.0-20261011005333-fa3dc1cac5c2/go.mod h1:mqze7WaBAeRadt8J+myMV+xWk/jJnts0ow1YcVWOl+E= +github.com/smol-machines/smolvm-sdk/smolvm-go v0.0.0-20261011012951-aa0738e5774e h1:hbPn2DUqnkwZfSXkV4oRsT3MXJHpZCvVrxe5tt+mgAI= +github.com/smol-machines/smolvm-sdk/smolvm-go v0.0.0-20261011012951-aa0738e5774e/go.mod h1:mqze7WaBAeRadt8J+myMV+xWk/jJnts0ow1YcVWOl+E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/providerassets/artifacts.go b/internal/providerassets/artifacts.go index 2f7dc0482..c29cd80a1 100644 --- a/internal/providerassets/artifacts.go +++ b/internal/providerassets/artifacts.go @@ -24,3 +24,15 @@ func Catalog() map[string][]Artifact { } return catalog } + +//go:embed automatic_installers.json +var automaticInstallersJSON []byte + +// AutomaticInstallers reports the node kinds supported by the bundled installer. +func AutomaticInstallers() []string { + var kinds []string + if err := json.Unmarshal(automaticInstallersJSON, &kinds); err != nil { + panic(err) + } + return kinds +} diff --git a/internal/providerassets/automatic_installers.json b/internal/providerassets/automatic_installers.json new file mode 100644 index 000000000..7bcb4d635 --- /dev/null +++ b/internal/providerassets/automatic_installers.json @@ -0,0 +1,4 @@ +[ + "docker", + "microsandbox" +] diff --git a/internal/providerassets/catalog.json b/internal/providerassets/catalog.json index 233ed30dd..4e4717bce 100644 --- a/internal/providerassets/catalog.json +++ b/internal/providerassets/catalog.json @@ -47,5 +47,17 @@ "suffix": "libkrunfw.so.5.6.1", "role": "runtime" } + ], + "smolvm": [ + { + "path": "native/bin/oac-node", + "suffix": "sandbox-node", + "role": "node" + }, + { + "path": "images/runtime.tar.gz", + "suffix": "runtime.tar.gz", + "role": "image" + } ] } diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 66f8bd5db..a8b9bd0f2 100644 --- a/packages/agents-client/src/deployment-contract.ts +++ b/packages/agents-client/src/deployment-contract.ts @@ -1,3 +1,3 @@ // Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT. -export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"providers":{"docker":{"mode":"nodes","disk":false,"runtime":true,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"runtime":false,"default_resources":null},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"runtime":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}}},"minimum_disk":1024} as const; +export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"providers":{"docker":{"mode":"nodes","disk":false,"runtime":true,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"runtime":false,"default_resources":null},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"runtime":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}},"smolvm":{"mode":"nodes","disk":true,"runtime":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}}},"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index a50ec0e3b..b9924e829 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -53,6 +53,7 @@ const microsandbox = { ...docker, provider: "microsandbox", specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192, environment_disk_mib: 8192 }, runtime }, suspension: { idle_seconds: 300, retention_seconds: 86400 }, }; +const smolvm = { ...microsandbox, provider: "smolvm", suspension: null }; const externalWorkspace = { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false }; const externalDeployment = { ...microsandbox, specification: { ...microsandbox.specification, resources: { ...microsandbox.specification.resources, environment_disk_mib: 0 }, workspace: externalWorkspace } }; @@ -85,7 +86,7 @@ describe("strict sandbox administration projections", () => { ["nodes", "ready and unready", { data: [node, unready] }], ["nodes", "empty", { data: [] }], ["detail", "observed", detail], ["unobserved", "never observed", unobserved], ["allocations", "known and unknown phase times", { data: [allocation, { ...allocation, compute_phase: "suspended", compute_phase_changed_at: created, diagnostic: "node_unavailable" }] }], - ["deployment", "unconfigured", unconfigured], ["deployment", "Docker", docker], ["deployment", "microsandbox", microsandbox], ["deployment", "external workspace", externalDeployment], ["deployment", "E2B", e2bDeployment], + ["deployment", "unconfigured", unconfigured], ["deployment", "Docker", docker], ["deployment", "microsandbox", microsandbox], ["deployment", "smolvm", smolvm], ["deployment", "external workspace", externalDeployment], ["deployment", "E2B", e2bDeployment], ])("accepts %s as Core serializes it: %s", async (name, _, body) => { expect(await read(name, body)).toEqual(body); }); diff --git a/services/core/cmd/provider-artifacts/main.go b/services/core/cmd/provider-artifacts/main.go index 602b32afd..7d9a83be2 100644 --- a/services/core/cmd/provider-artifacts/main.go +++ b/services/core/cmd/provider-artifacts/main.go @@ -18,13 +18,22 @@ func main() { if err != nil { panic(err) } + installers, err := providers.Builtin().AutomaticInstallers() + if err != nil { + panic(err) + } + rawInstallers, err := json.MarshalIndent(installers, "", " ") + if err != nil { + panic(err) + } + rawInstallers = append(rawInstallers, '\n') raw, err := json.MarshalIndent(catalog, "", " ") if err != nil { panic(err) } raw = append(raw, '\n') - python := []byte("# Code generated by go run ./services/core/cmd/provider-artifacts -write; DO NOT EDIT.\nimport json\n\nCATALOG = json.loads(" + strconv.Quote(string(raw)) + ")\n\ndef artifacts(provider, roles=None):\n return tuple(item[\"path\"] for item in CATALOG[provider] if roles is None or item[\"role\"] in roles)\n") - for path, content := range map[string][]byte{"internal/providerassets/catalog.json": raw, "deploy/node/provider_assets.py": python} { + python := []byte("# Code generated by go run ./services/core/cmd/provider-artifacts -write; DO NOT EDIT.\nimport json\n\nCATALOG = json.loads(" + strconv.Quote(string(raw)) + ")\n\nAUTOMATIC_PROVIDERS = json.loads(" + strconv.Quote(string(rawInstallers)) + ")\n\ndef artifacts(provider, roles=None):\n return tuple(item[\"path\"] for item in CATALOG[provider] if roles is None or item[\"role\"] in roles)\n") + for path, content := range map[string][]byte{"internal/providerassets/catalog.json": raw, "internal/providerassets/automatic_installers.json": rawInstallers, "deploy/node/provider_assets.py": python} { if *write { err = os.WriteFile(path, content, 0644) } else { diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index 8b2b8f77a..c1dbe8581 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -14,7 +14,7 @@ import ( type SandboxDeploymentInput struct { ExpectedGeneration *uint64 `json:"expected_generation" binding:"required"` - // Per-sandbox limits, required for Docker and microsandbox. E2B may omit + // Per-sandbox limits, required for node providers. 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"` diff --git a/services/core/internal/sandbox/providers/artifacts.go b/services/core/internal/sandbox/providers/artifacts.go index d92cc90a0..6073e1cbf 100644 --- a/services/core/internal/sandbox/providers/artifacts.go +++ b/services/core/internal/sandbox/providers/artifacts.go @@ -4,6 +4,7 @@ import ( "fmt" "path" "regexp" + "sort" "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" @@ -67,3 +68,21 @@ func validateNodeArtifacts(items []providerassets.Artifact) error { } return nil } + +// AutomaticInstallers projects the adapters the bundled node installer knows +// how to prepare. Manually registered node providers still retain artifacts in +// ArtifactCatalog for independent distribution and validation. +func (r *Registry) AutomaticInstallers() ([]string, error) { + var result []string + for kind := range r.adapters { + adapter, err := r.Lookup(kind) + if err != nil { + return nil, err + } + if adapter.AutomaticInstall { + result = append(result, kind) + } + } + sort.Strings(result) + return result, nil +} diff --git a/services/core/internal/sandbox/providers/artifacts_test.go b/services/core/internal/sandbox/providers/artifacts_test.go index 5e29329af..9b8a0fa6e 100644 --- a/services/core/internal/sandbox/providers/artifacts_test.go +++ b/services/core/internal/sandbox/providers/artifacts_test.go @@ -19,6 +19,13 @@ func TestArtifactProjectionsMatchRegistrations(t *testing.T) { if err != nil { t.Fatal(err) } + installers, err := registry.AutomaticInstallers() + if err != nil || !reflect.DeepEqual(installers, providerassets.AutomaticInstallers()) { + t.Fatalf("stale automatic installer provider projection: %v, %v", installers, err) + } + if !reflect.DeepEqual(installers, []string{"docker", "microsandbox"}) { + t.Fatalf("manual providers unexpectedly advertised as automatic: %v", installers) + } if !reflect.DeepEqual(catalog, providerassets.Catalog()) { t.Fatal("stale Web artifact projection; regenerate provider-artifacts") } diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 5a5668d35..510ecde8e 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -24,6 +24,9 @@ func ValidateRegistration(a Adapter) error { return invalid("node constructor") } case sandbox.DeploymentDirect: + if a.AutomaticInstall { + return invalid("installer support for direct provider") + } if len(a.NodeArtifacts) != 0 { return invalid("direct node artifacts") } diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 592bdf313..be345e7fd 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -150,7 +150,7 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { } // Direct providers may legitimately need no remote credential or extra // selection state; registration must not require irrelevant callback stubs. - a.Mode, a.BuildLocal, a.NodeArtifacts = "direct", nil, nil + a.Mode, a.BuildLocal, a.NodeArtifacts, a.AutomaticInstall = "direct", nil, nil, false a.BuildDirect = func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) { calls++ return &docker.Provider{}, nil @@ -177,7 +177,7 @@ func TestRegistrationRejectsInvalidDefaultResources(t *testing.T) { func TestRegistrationCheckpointRequiresNodes(t *testing.T) { registry := Builtin() a := registry.adapters["microsandbox"] - a.Mode, a.BuildLocal, a.NodeArtifacts, a.BuildDirect = "direct", nil, nil, registry.adapters["e2b"].BuildDirect + a.Mode, a.BuildLocal, a.NodeArtifacts, a.BuildDirect, a.AutomaticInstall = "direct", nil, nil, registry.adapters["e2b"].BuildDirect, false if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) || !strings.Contains(err.Error(), "checkpoint") { t.Fatal("direct checkpoint registration accepted", err) } diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index a39c0429f..1e5942064 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -11,12 +11,14 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/smolvm" ) // Adapter describes configuration and transport independently of compute operations. // Native operation support comes from the adapter-owned complete declaration. type Adapter struct { NodeArtifacts []providerassets.Artifact + AutomaticInstall bool Policy sandbox.DeploymentPolicy Configuration sandbox.ConfigurationAdapter BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) @@ -38,17 +40,23 @@ var ErrUnknownProvider = fmt.Errorf("%w: unsupported sandbox provider", sandbox. func Builtin() *Registry { return &Registry{adapters: map[string]Adapter{ "docker": { - NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, - Policy: docker.Policy(), Operations: docker.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: docker.BuildNode, + NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, AutomaticInstall: true, + Policy: docker.Policy(), Operations: docker.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: docker.BuildNode, ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, Configuration: nodeConfigurationAdapter{docker.ValidateSpecification}, }, "microsandbox": { - NodeArtifacts: append([]providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, microsandbox.NodeArtifacts...), - Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: microsandbox.BuildNode, + NodeArtifacts: append([]providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, microsandbox.NodeArtifacts...), AutomaticInstall: true, + Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: microsandbox.BuildNode, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, }, + "smolvm": { + NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage}, + Policy: smolvm.Policy(), Operations: smolvm.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: smolvm.BuildNode, + ValidateSpecification: smolvm.ValidateSpecification, ValidateResources: smolvm.ValidateResources, + Configuration: nodeConfigurationAdapter{smolvm.ValidateSpecification}, + }, "e2b": { Policy: e2b.Policy(), Operations: e2b.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: e2b.BuildDirect, Configuration: e2b.ConfigurationAdapter{}, diff --git a/services/core/internal/sandbox/smolvm/command.go b/services/core/internal/sandbox/smolvm/command.go new file mode 100644 index 000000000..50af70873 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/command.go @@ -0,0 +1,95 @@ +package smolvm + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + sdk "github.com/smol-machines/smolvm-sdk/smolvm-go" +) + +func (p *Provider) RunCommand(ctx context.Context, ref sandbox.Reference, command sandbox.Command) (sandbox.CommandResult, error) { + var result sandbox.CommandResult + if err := validateCommand(command); err != nil { + return result, err + } + deadline, bounded := ctx.Deadline() + if !bounded { + return result, sandbox.ErrInvalid + } + info, err := p.GetInfo(ctx, ref) + if err != nil { + return result, err + } + if !info.BootstrapComplete { + return result, sandbox.ErrInvalid + } + args := command.Args + var env []sdk.EnvVar + var guestFile string + if command.Stdin != nil { + // The HTTP exec protocol carries UTF-8 stdin only. Stage byte-exact input + // under a mode-0700 directory inside this owned VM. Never put the bytes in + // argv, the environment, a host file or a log. + guestFile = "/home/runtime/.oac/stdin-" + uuid.NewString() + if _, err := p.client.UploadFile(ctx, p.name(ref), guestFile, command.Stdin); err != nil { + // Upload may have written the file before losing its response. No + // command was sent yet, so a separate, bounded context can erase it. + return result, errors.Join(sandbox.ErrCommandUnconfirmed, err, p.removeStagedInput(ref, guestFile)) + } + env = []sdk.EnvVar{{Name: "OAC_STDIN_FILE", Value: guestFile}} + args = append([]string{"/bin/sh", "-c", `trap 'rm -f -- "$OAC_STDIN_FILE"' EXIT; "$@" < "$OAC_STDIN_FILE"`, "sh"}, args...) + } + remaining := time.Until(deadline) + if remaining <= 0 { + deadlineErr := ctx.Err() + if deadlineErr == nil { + deadlineErr = context.DeadlineExceeded + } + if guestFile != "" { + deadlineErr = errors.Join(deadlineErr, p.removeStagedInput(ref, guestFile)) + } + return result, errors.Join(sandbox.ErrCommandUnconfirmed, deadlineErr) + } + seconds := int64(remaining/time.Second) + 1 + noStart := false + // The native API returns text and base64 copies of output in one JSON body. + // Bound that body before decoding so an oversized command cannot exhaust + // the node's memory while enforcing the 1 MiB command-output contract. + response, err := p.client.ExecWithResponseLimit(ctx, p.name(ref), sdk.ExecRequest{ + Command: args, Workdir: command.Directory, User: "1000:1000", AutoStart: &noStart, + Env: env, TimeoutSecs: &seconds, + }, 8<<20) + if err != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, err) + } + if response.StdoutBytes == nil || response.StderrBytes == nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, fmt.Errorf("native command returned no byte-exact output")) + } + if len(response.StdoutBytes)+len(response.StderrBytes) > 1024*1024 { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, fmt.Errorf("native command output exceeds 1 MiB")) + } + return sandbox.CommandResult{Stdout: string(response.StdoutBytes), Stderr: string(response.StderrBytes), ExitCode: response.ExitCode}, nil +} + +// The cleanup uses fresh authority only before command execution is submitted. +// Once Exec is called, its outcome may be uncertain; the caller must reclaim +// the allocation instead of racing a still-running command for its stdin. +func (p *Provider) removeStagedInput(ref sandbox.Reference, path string) error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + noStart := false + response, err := p.client.ExecWithResponseLimit(cleanupCtx, p.name(ref), sdk.ExecRequest{ + Command: []string{"/bin/rm", "-f", "--", path}, User: "1000:1000", AutoStart: &noStart, + }, 1<<20) + if err != nil { + return fmt.Errorf("remove staged command input: %w", err) + } + if response.ExitCode != 0 { + return fmt.Errorf("remove staged command input: exit %d", response.ExitCode) + } + return nil +} diff --git a/services/core/internal/sandbox/smolvm/command_test.go b/services/core/internal/sandbox/smolvm/command_test.go new file mode 100644 index 000000000..e05cad758 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/command_test.go @@ -0,0 +1,57 @@ +package smolvm + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + sdk "github.com/smol-machines/smolvm-sdk/smolvm-go" +) + +func TestRunCommandCleansStagedInputAfterCanceledUpload(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + var p *Provider + var ref sandbox.Reference + var removed atomic.Bool + nativeID := uuid.NewString() + p, ref, _ = testProvider(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(sdk.MachineInfo{Name: p.name(ref), Image: p.imageRef, Labels: p.labels(ref, nativeID), State: sdk.MachineStateRunning, CPUs: 2, MemoryMB: 1024}) + case http.MethodPut: + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Errorf("read upload: %v", err) + } + cancel() // Upload is uncertain, but Exec has not yet been submitted. + _, _ = io.WriteString(w, `{}`) + case http.MethodPost: + var request sdk.ExecRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode cleanup: %v", err) + } + if len(request.Command) != 4 || request.Command[0] != "/bin/rm" || !strings.HasPrefix(request.Command[3], "/home/runtime/.oac/stdin-") { + t.Errorf("unexpected command after upload: %v", request.Command) + } + removed.Store(true) + _, _ = io.WriteString(w, `{"exitCode":0}`) + default: + t.Errorf("unexpected native request: %s", r.Method) + } + }) + if err := p.writeReceipt(ref, nativeID); err != nil { + t.Fatal(err) + } + _, err := p.RunCommand(ctx, ref, sandbox.Command{Args: []string{"/bin/cat"}, Stdin: []byte{0xff}}) + if !errors.Is(err, sandbox.ErrCommandUnconfirmed) || !removed.Load() { + t.Fatalf("canceled upload: %v, cleanup invoked: %t", err, removed.Load()) + } +} diff --git a/services/core/internal/sandbox/smolvm/contract_test.go b/services/core/internal/sandbox/smolvm/contract_test.go new file mode 100644 index 000000000..128454114 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/contract_test.go @@ -0,0 +1,97 @@ +package smolvm + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/contracttest" + "github.com/google/uuid" + sdk "github.com/smol-machines/smolvm-sdk/smolvm-go" +) + +type nativeStep struct { + method, path string + status int + body any + fault contracttest.Fault +} + +func TestProviderFailures(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, scenario contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + var mutex sync.Mutex + var calls []string + var steps []nativeStep + p, ref, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { + call := r.Method + " " + r.URL.Path + mutex.Lock() + index := len(calls) + calls = append(calls, call) + mutex.Unlock() + if index >= len(steps) || call != steps[index].method+" "+steps[index].path { + t.Errorf("unexpected native request %q", call) + http.Error(w, "unexpected", 500) + return + } + step := steps[index] + switch step.fault { + case contracttest.Canceled: + cancel() + return + case contracttest.TransportFailure: + connection, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = connection.Close() + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(step.status) + if step.body != nil { + _ = json.NewEncoder(w).Encode(step.body) + } + }) + bootstrap := sandbox.Bootstrap{Reference: ref, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "test", NetworkAccess: "enabled"} + name := p.name(ref) + get := "/api/v1/machines/" + name + owned := sdk.MachineInfo{Name: name, Image: p.imageRef, Labels: p.labels(ref, uuid.NewString()), State: sdk.MachineStateRunning, CPUs: 2, MemoryMB: 1024} + foreign := owned + foreign.Labels = p.labels(ref, uuid.NewString()) + foreign.Labels[labelPrefix+"tenant"] = "foreign" + absent := map[string]string{"error": "not found", "code": "NOT_FOUND"} + switch scenario.Operation { + case "create": + if scenario.Fault == contracttest.ForeignOwnership { + steps = []nativeStep{{http.MethodGet, get, 200, foreign, ""}} + } else { + steps = []nativeStep{{http.MethodGet, get, 404, absent, ""}, {http.MethodPost, "/api/v1/machines", 200, nil, scenario.Fault}} + } + case "inspect", "renew": + if scenario.Fault == contracttest.ForeignOwnership { + steps = []nativeStep{{http.MethodGet, get, 200, foreign, ""}} + } else { + steps = []nativeStep{{http.MethodGet, get, 200, nil, scenario.Fault}} + } + case "kill": + if scenario.Fault == contracttest.ForeignOwnership { + steps = []nativeStep{{http.MethodGet, get, 200, foreign, ""}} + } else { + steps = []nativeStep{{http.MethodGet, get, 200, owned, ""}, {http.MethodDelete, get, 200, map[string]string{"deleted": name}, scenario.Fault}} + if scenario.Fault == contracttest.CleanupFailure { + steps = append(steps, nativeStep{http.MethodGet, get, 200, owned, ""}) + } + } + } + want := make([]string, len(steps)) + for i, step := range steps { + want[i] = step.method + " " + step.path + } + return contracttest.Fixture{Provider: p, Bootstrap: bootstrap, + Calls: func() []string { mutex.Lock(); defer mutex.Unlock(); return append([]string(nil), calls...) }, WantCalls: want} + }) +} diff --git a/services/core/internal/sandbox/smolvm/deployment.go b/services/core/internal/sandbox/smolvm/deployment.go new file mode 100644 index 000000000..c66f8e225 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/deployment.go @@ -0,0 +1,15 @@ +package smolvm + +import "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + +func Policy() sandbox.DeploymentPolicy { + return sandbox.DeploymentPolicy{ + Disk: true, Runtime: true, + DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}, + } +} + +func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("smolvm", Policy()) } +func ValidateSpecification(s sandbox.DeploymentSpec) error { + return s.ValidatePolicy("smolvm", Policy()) +} diff --git a/services/core/internal/sandbox/smolvm/image.go b/services/core/internal/sandbox/smolvm/image.go new file mode 100644 index 000000000..706278c5d --- /dev/null +++ b/services/core/internal/sandbox/smolvm/image.go @@ -0,0 +1,274 @@ +package smolvm + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "strings" +) + +const maxOCIIndexBytes = 1 << 20 + +// verifyOCIRelease binds the actual archive contents to the Runtime release +// selected by Core. The archive hash alone is insufficient: it has no place in +// the distribution specification and would accept a different Runtime image. +func verifyOCIRelease(filename, manifestDigest, imageID string) error { + if !validDigest(manifestDigest) || !validDigest(imageID) { + return errors.New("invalid expected OCI release digests") + } + manifestName := "blobs/sha256/" + strings.TrimPrefix(manifestDigest, "sha256:") + configName := "blobs/sha256/" + strings.TrimPrefix(imageID, "sha256:") + needed, err := readOCIReleaseBlobs(filename, "index.json", "manifest.json", manifestName, configName) + if err != nil { + return err + } + manifest, config, index := needed[manifestName], needed[configName], needed["index.json"] + if !matchingDigest(manifest, manifestDigest) || !matchingDigest(config, imageID) || len(index) == 0 { + return errors.New("OCI archive does not contain the selected Runtime release") + } + var top struct { + Manifests []ociDescriptor `json:"manifests"` + } + if err := json.Unmarshal(index, &top); err != nil { + return fmt.Errorf("invalid OCI index: %w", err) + } + if len(top.Manifests) != 1 || top.Manifests[0].Digest != manifestDigest { + return errors.New("OCI index does not select the expected Runtime manifest") + } + switch top.Manifests[0].MediaType { + case "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json": + var platform struct { + Manifests []ociDescriptor `json:"manifests"` + } + if err := json.Unmarshal(manifest, &platform); err != nil { + return fmt.Errorf("invalid OCI platform index: %w", err) + } + if len(platform.Manifests) != 1 || !validDigest(platform.Manifests[0].Digest) || !imageManifestType(platform.Manifests[0].MediaType) { + return errors.New("OCI release does not select exactly one image manifest") + } + inner, err := readOCIReleaseBlobs(filename, "blobs/sha256/"+strings.TrimPrefix(platform.Manifests[0].Digest, "sha256:")) + if err != nil { + return err + } + manifest = inner["blobs/sha256/"+strings.TrimPrefix(platform.Manifests[0].Digest, "sha256:")] + if !matchingDigest(manifest, platform.Manifests[0].Digest) { + return errors.New("OCI release platform manifest digest mismatch") + } + default: + if !imageManifestType(top.Manifests[0].MediaType) { + return errors.New("OCI release does not select an image manifest") + } + } + var image struct { + Config ociDescriptor `json:"config"` + Layers []ociDescriptor `json:"layers"` + } + if err := json.Unmarshal(manifest, &image); err != nil { + return fmt.Errorf("invalid OCI manifest: %w", err) + } + if image.Config.Digest != imageID { + return errors.New("OCI manifest does not select the expected Runtime image") + } + var savedLayers []string + if raw := needed["manifest.json"]; len(raw) != 0 { + var saved []struct { + Config string `json:"Config"` + Layers []string `json:"Layers"` + } + if err := json.Unmarshal(raw, &saved); err != nil || len(saved) != 1 || !safeTarMember(saved[0].Config) || len(saved[0].Layers) != len(image.Layers) { + return errors.New("Docker archive manifest does not select the expected Runtime image") + } + if saved[0].Config != configName { + alias, err := readOCIReleaseBlobs(filename, saved[0].Config) + if err != nil || !matchingDigest(alias[saved[0].Config], imageID) { + return errors.New("Docker archive configuration differs from the selected Runtime image") + } + } + savedLayers = saved[0].Layers + } + var configRootfs struct { + Rootfs struct { + DiffIDs []string `json:"diff_ids"` + } `json:"rootfs"` + } + if err := json.Unmarshal(config, &configRootfs); err != nil { + return fmt.Errorf("invalid OCI image configuration: %w", err) + } + return verifyOCIReleaseLayers(filename, image.Layers, savedLayers, configRootfs.Rootfs.DiffIDs) +} + +func safeTarMember(name string) bool { + return name != "" && name != "." && !path.IsAbs(name) && path.Clean(name) == name && !strings.ContainsRune(name, '\\') +} + +type ociDescriptor struct { + Digest string `json:"digest"` + MediaType string `json:"mediaType"` + Size int64 `json:"size"` +} + +func imageManifestType(value string) bool { + return value == "application/vnd.oci.image.manifest.v1+json" || value == "application/vnd.docker.distribution.manifest.v2+json" +} + +// Inspect only explicitly selected metadata blobs. OCI layer data can be many +// gigabytes and must never be buffered in node memory. +func readOCIReleaseBlobs(filename string, names ...string) (map[string][]byte, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + reader := bufio.NewReader(file) + var source io.Reader = reader + if header, err := reader.Peek(2); err == nil && header[0] == 0x1f && header[1] == 0x8b { + gz, err := gzip.NewReader(reader) + if err != nil { + return nil, err + } + defer gz.Close() + source = gz + } + archive := tar.NewReader(source) + needed := make(map[string][]byte, len(names)) + for _, name := range names { + needed[name] = nil + } + for { + entry, err := archive.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("read OCI archive: %w", err) + } + if _, wanted := needed[entry.Name]; !wanted { + continue + } + if (entry.Typeflag != tar.TypeReg && entry.Typeflag != tar.TypeRegA) || entry.Size < 1 || entry.Size > maxOCIIndexBytes || needed[entry.Name] != nil { + return nil, errors.New("duplicate, empty or oversized OCI release descriptor") + } + data, err := io.ReadAll(io.LimitReader(archive, maxOCIIndexBytes+1)) + if err != nil { + return nil, err + } + needed[entry.Name] = data + } + return needed, nil +} + +// Check every selected layer against the manifest. The metadata must be read +// first to learn which layer names belong to this release, so stream the layers +// in a second pass instead of retaining potentially multi-GiB blobs in memory. +func verifyOCIReleaseLayers(filename string, layers []ociDescriptor, saved, diffIDs []string) error { + if len(layers) > 4096 { + return errors.New("OCI release contains too many layers") + } + type expectedLayer struct { + ociDescriptor + diffID string + alias bool + } + wanted := make(map[string]expectedLayer, len(layers)*2) + for i, layer := range layers { + if !validDigest(layer.Digest) || layer.Size < 0 { + return errors.New("invalid OCI release layer descriptor") + } + name := "blobs/sha256/" + strings.TrimPrefix(layer.Digest, "sha256:") + if previous, exists := wanted[name]; exists && previous.ociDescriptor != layer { + return errors.New("conflicting OCI release layer descriptors") + } + wanted[name] = expectedLayer{ociDescriptor: layer} + if saved != nil { + if !safeTarMember(saved[i]) { + return errors.New("invalid Docker archive layer path") + } + if saved[i] != name { + expect := expectedLayer{ociDescriptor: layer, alias: true} + if len(diffIDs) == len(layers) && validDigest(diffIDs[i]) { + expect.diffID = diffIDs[i] + } + if previous, exists := wanted[saved[i]]; exists && previous != expect { + return errors.New("Docker archive layer differs from selected Runtime image") + } + wanted[saved[i]] = expect + } + } + } + if len(wanted) == 0 { + return nil + } + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + reader := bufio.NewReader(file) + var source io.Reader = reader + if header, err := reader.Peek(2); err == nil && header[0] == 0x1f && header[1] == 0x8b { + gz, err := gzip.NewReader(reader) + if err != nil { + return err + } + defer gz.Close() + source = gz + } + archive := tar.NewReader(source) + seen := make(map[string]bool, len(wanted)) + for { + entry, err := archive.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("read OCI release layers: %w", err) + } + layer, selected := wanted[entry.Name] + if !selected { + continue + } + if seen[entry.Name] { + return errors.New("duplicate OCI release layer blob") + } + if entry.Typeflag != tar.TypeReg && entry.Typeflag != tar.TypeRegA { + return errors.New("OCI release layer has the wrong type") + } + compressed := entry.Size == layer.Size + sha := sha256.New() + if _, err := io.Copy(sha, archive); err != nil { + return fmt.Errorf("hash OCI release layer: %w", err) + } + actual := "sha256:" + hex.EncodeToString(sha.Sum(nil)) + if !(compressed && actual == layer.Digest) && !(layer.alias && layer.diffID != "" && actual == layer.diffID) { + return errors.New("OCI release layer digest mismatch") + } + seen[entry.Name] = true + } + if len(seen) != len(wanted) { + return errors.New("OCI release layer is missing") + } + return nil +} + +func validDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + decoded, err := hex.DecodeString(value[len("sha256:"):]) + return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value) +} +func matchingDigest(blob []byte, want string) bool { + if len(blob) == 0 { + return false + } + hash := sha256.Sum256(blob) + return "sha256:"+hex.EncodeToString(hash[:]) == want +} diff --git a/services/core/internal/sandbox/smolvm/image_test.go b/services/core/internal/sandbox/smolvm/image_test.go new file mode 100644 index 000000000..6a483cfc5 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/image_test.go @@ -0,0 +1,243 @@ +package smolvm + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func digestFor(data []byte) string { + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} +func TestVerifyOCIRelease(t *testing.T) { + config := []byte(`{"architecture":"amd64","os":"linux"}`) + imageID := digestFor(config) + layer := []byte("live-data") + layerID := digestFor(layer) + manifest, _ := json.Marshal(map[string]any{"config": map[string]string{"digest": imageID}, "layers": []map[string]any{{"digest": layerID, "size": len(layer)}}}) + for _, nested := range []bool{false, true} { + manifestID := digestFor(manifest) + selected := manifest + mediaType := "application/vnd.oci.image.manifest.v1+json" + if nested { + selected, _ = json.Marshal(map[string]any{"manifests": []map[string]string{{"digest": manifestID, "mediaType": mediaType}}}) + mediaType = "application/vnd.oci.image.index.v1+json" + manifestID = digestFor(selected) + } + index, _ := json.Marshal(map[string]any{"manifests": []map[string]string{{"digest": manifestID, "mediaType": mediaType}}}) + archive := new(bytes.Buffer) + writer := tar.NewWriter(archive) + items := []struct { + name string + content []byte + }{{"blobs/sha256/" + imageID[7:], config}, {"blobs/sha256/" + manifestID[7:], selected}, {"blobs/sha256/" + layerID[7:], layer}, {"index.json", index}} + if nested { + items = append(items, struct { + name string + content []byte + }{"blobs/sha256/" + digestFor(manifest)[7:], manifest}) + } + for _, item := range items { + if err := writer.WriteHeader(&tar.Header{Name: item.name, Size: int64(len(item.content)), Mode: 0600}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write(item.content); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + altered := filepath.Join(t.TempDir(), "altered.tar") + if err := os.WriteFile(altered, bytes.Replace(archive.Bytes(), layer, []byte("dead-data"), 1), 0600); err != nil { + t.Fatal(err) + } + if err := verifyOCIRelease(altered, manifestID, imageID); err == nil { + t.Fatal("replaced release layer accepted") + } + for _, compressed := range []bool{false, true} { + data := archive.Bytes() + suffix := ".tar" + if compressed { + compressedData := new(bytes.Buffer) + gz := gzip.NewWriter(compressedData) + if _, err := gz.Write(data); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + data = compressedData.Bytes() + suffix = ".tar.gz" + } + filename := filepath.Join(t.TempDir(), "runtime"+suffix) + if err := os.WriteFile(filename, data, 0600); err != nil { + t.Fatal(err) + } + if err := verifyOCIRelease(filename, manifestID, imageID); err != nil { + t.Fatalf("verify nested=%t %s: %v", nested, suffix, err) + } + if err := verifyOCIRelease(filename, manifestID, digestFor([]byte("other"))); err == nil { + t.Fatalf("mismatched image ID accepted for %s", suffix) + } + if err := verifyOCIRelease(filename, digestFor([]byte("other")), imageID); err == nil { + t.Fatalf("mismatched manifest accepted for %s", suffix) + } + } + } +} + +func TestVerifyOCIReleaseBindsDockerSaveImage(t *testing.T) { + config := []byte(`{"architecture":"amd64","os":"linux"}`) + layer := []byte("trusted image layer") + imageID, layerID := digestFor(config), digestFor(layer) + manifest, _ := json.Marshal(map[string]any{ + "config": map[string]string{"digest": imageID}, + "layers": []map[string]any{{"digest": layerID, "size": len(layer)}}, + }) + manifestID := digestFor(manifest) + index, _ := json.Marshal(map[string]any{"manifests": []map[string]string{{"digest": manifestID, "mediaType": "application/vnd.oci.image.manifest.v1+json"}}}) + for _, tc := range []struct { + name, dockerConfig, dockerLayer string + contents []byte + valid bool + }{ + {"original", "blobs/sha256/" + imageID[7:], "blobs/sha256/" + layerID[7:], nil, true}, + {"matching alias", "config-alias.json", "layer-alias", layer, true}, + {"other config", "other-config.json", "blobs/sha256/" + layerID[7:], []byte("wrong"), false}, + {"other layer", "blobs/sha256/" + imageID[7:], "other-layer", []byte("wrong"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + saved, _ := json.Marshal([]map[string]any{{"Config": tc.dockerConfig, "Layers": []string{tc.dockerLayer}}}) + file := filepath.Join(t.TempDir(), "image.tar") + f, err := os.Create(file) + if err != nil { + t.Fatal(err) + } + writer := tar.NewWriter(f) + entries := map[string][]byte{ + "blobs/sha256/" + imageID[7:]: config, + "blobs/sha256/" + layerID[7:]: layer, + "blobs/sha256/" + manifestID[7:]: manifest, + "index.json": index, "manifest.json": saved, + } + if tc.dockerConfig != "blobs/sha256/"+imageID[7:] { + value := tc.contents + if tc.valid { + value = config + } + entries[tc.dockerConfig] = value + } + if tc.dockerLayer != "blobs/sha256/"+layerID[7:] { + entries[tc.dockerLayer] = tc.contents + } + for name, data := range entries { + if err := writer.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data))}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := verifyOCIRelease(file, manifestID, imageID); (err == nil) != tc.valid { + t.Fatalf("verified=%t want=%t: %v", err == nil, tc.valid, err) + } + }) + } +} + +func TestLiveReleaseIdentity(t *testing.T) { + archive := os.Getenv("OAC_SMOLVM_IMAGE") + manifest := os.Getenv("OAC_SMOLVM_MANIFEST") + config := os.Getenv("OAC_SMOLVM_IMAGE_ID") + if archive == "" || manifest == "" || config == "" { + t.Skip("set archive and expected release digests") + } + if err := verifyOCIRelease(archive, manifest, config); err != nil { + t.Fatal(err) + } +} + +// Docker-save may reference the unpacked tar while OCI stores a gzip layer. +// The image config's diff_id binds both representations to the same rootfs. +func TestVerifyOCIReleaseDockerUncompressedLayer(t *testing.T) { + layer := []byte("uncompressed filesystem layer") + encoded := new(bytes.Buffer) + gzipWriter := gzip.NewWriter(encoded) + if _, err := gzipWriter.Write(layer); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + compressed := encoded.Bytes() + layerID := digestFor(compressed) + config, _ := json.Marshal(map[string]any{ + "architecture": "amd64", "os": "linux", "rootfs": map[string]any{"diff_ids": []string{digestFor(layer)}}, + }) + imageID := digestFor(config) + manifest, _ := json.Marshal(map[string]any{ + "config": map[string]string{"digest": imageID}, + "layers": []map[string]any{{"digest": layerID, "size": len(compressed)}}, + }) + manifestID := digestFor(manifest) + index, _ := json.Marshal(map[string]any{"manifests": []map[string]string{{"digest": manifestID, "mediaType": "application/vnd.oci.image.manifest.v1+json"}}}) + saved, _ := json.Marshal([]map[string]any{{"Config": "blobs/sha256/" + imageID[7:], "Layers": []string{"unpacked/layer.tar"}}}) + for _, tc := range []struct { + name string + data []byte + valid bool + }{ + {"unpacked", layer, true}, + {"compressed alias", compressed, true}, + {"tampered unpacked", []byte("tampered filesystem layer"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + filename := filepath.Join(t.TempDir(), "image.tar") + file, err := os.Create(filename) + if err != nil { + t.Fatal(err) + } + writer := tar.NewWriter(file) + for _, item := range []struct { + name string + data []byte + }{ + {"index.json", index}, {"manifest.json", saved}, + {"blobs/sha256/" + manifestID[7:], manifest}, + {"blobs/sha256/" + imageID[7:], config}, + {"blobs/sha256/" + layerID[7:], compressed}, + {"unpacked/layer.tar", tc.data}, + } { + if err := writer.WriteHeader(&tar.Header{Name: item.name, Size: int64(len(item.data)), Mode: 0600}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write(item.data); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if err := verifyOCIRelease(filename, manifestID, imageID); (err == nil) != tc.valid { + t.Fatalf("verified=%t want=%t: %v", err == nil, tc.valid, err) + } + }) + } +} diff --git a/services/core/internal/sandbox/smolvm/live_test.go b/services/core/internal/sandbox/smolvm/live_test.go new file mode 100644 index 000000000..c7cf2c758 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/live_test.go @@ -0,0 +1,82 @@ +package smolvm + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + sdk "github.com/smol-machines/smolvm-sdk/smolvm-go" +) + +// TestLiveAllocation requires a local smolvm service with the machine-label and +// local OCI image APIs. It is opt-in because it boots a real VM and imports an +// OCI archive on the server host. +func TestLiveAllocation(t *testing.T) { + socket, image := os.Getenv("OAC_SMOLVM_SOCKET"), os.Getenv("OAC_SMOLVM_IMAGE") + if socket == "" || image == "" { + t.Skip("set OAC_SMOLVM_SOCKET and OAC_SMOLVM_IMAGE to test a live VM") + } + archive, err := os.Open(image) + if err != nil { + t.Fatal(err) + } + defer archive.Close() + sha := sha256.New() + if _, err := archive.WriteTo(sha); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + root := filepath.Join(t.TempDir(), "receipts") + provider, err := New(sdk.NewClient(socket), Config{ + InstallationID: uuid.NewString(), ImagePath: image, + ImageHash: hex.EncodeToString(sha.Sum(nil)), ReceiptRoot: root, + Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}, + }) + if err != nil { + t.Fatal(err) + } + bootstrap := sandbox.Bootstrap{ + Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, + SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", + Credential: "synthetic-live-test", NetworkAccess: "enabled", + } + defer func() { + cleanup, done := context.WithTimeout(context.Background(), 90*time.Second) + defer done() + if err := provider.Kill(cleanup, bootstrap.Reference); err != nil { + t.Errorf("Kill: %v", err) + } + }() + created, err := provider.Create(ctx, bootstrap) + if err != nil { + t.Fatalf("Create: %+v: %v", created, err) + } + if !created.BootstrapComplete || created.ProviderID == "" { + t.Fatalf("incomplete allocation: %+v", created) + } + observed, err := provider.GetInfo(ctx, bootstrap.Reference) + if err != nil || !observed.BootstrapComplete { + t.Fatalf("GetInfo: %+v: %v", observed, err) + } + if _, err := provider.Create(ctx, bootstrap); !errors.Is(err, sandbox.ErrExists) { + t.Fatalf("duplicate Create: %v", err) + } + stdin := []byte{'a', 'b', 'c', 0, 'z', 0xff} + result, err := provider.RunCommand(ctx, bootstrap.Reference, sandbox.Command{Args: []string{"/bin/cat"}, Stdin: stdin}) + if err != nil || result.ExitCode != 0 || !bytes.Equal([]byte(result.Stdout), stdin) { + t.Fatalf("binary RunCommand: %+v: %v", result, err) + } + result, err = provider.RunCommand(ctx, bootstrap.Reference, sandbox.Command{Args: []string{"/bin/sh", "-c", "stat -c '%a %u' /home/runtime/runtime-bootstrap.json; id -u"}}) + if err != nil || result.ExitCode != 0 || result.Stdout != "600 1000\n1000\n" { + t.Fatalf("bootstrap file and user: %+v: %v", result, err) + } +} diff --git a/services/core/internal/sandbox/smolvm/node.go b/services/core/internal/sandbox/smolvm/node.go new file mode 100644 index 000000000..b9b1bf728 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/node.go @@ -0,0 +1,97 @@ +package smolvm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + sdk "github.com/smol-machines/smolvm-sdk/smolvm-go" +) + +// Native is the node-local provider configuration. ImageFile is the unpacked +// release OCI archive, not an image name resolved from a mutable registry tag. +type Native struct { + Socket string `json:"socket"` + ImageFile string `json:"image_file"` + ReceiptRoot string `json:"receipt_root"` +} + +func canonicalSocket(value string) bool { + parsed, err := url.Parse(value) + return err == nil && parsed.Scheme == "unix" && parsed.Host == "" && parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" && parsed.RawPath == "" && + filepath.IsAbs(parsed.Path) && parsed.Path != "/" && filepath.Clean(parsed.Path) == parsed.Path && value == "unix://"+parsed.Path +} + +func privateSocket(address string) error { + socket, err := os.Lstat(address[len("unix://"):]) + if err != nil { + return fmt.Errorf("cannot inspect smolvm API socket: %w", err) + } + if socket.Mode()&os.ModeSocket == 0 || socket.Mode().Perm() != 0600 { + return fmt.Errorf("%w: smolvm API socket must be private (mode 0600)", sandbox.ErrInvalid) + } + return nil +} + +func hashFile(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", sandbox.ErrInvalid + } + sha := sha256.New() + if _, err := io.Copy(sha, file); err != nil { + return "", err + } + return hex.EncodeToString(sha.Sum(nil)), nil +} + +func BuildNode(config sandbox.NodeConfig, _ sandbox.LocalOptions, result *sandbox.Built) (func(), error) { + closeProvider := func() {} + var native Native + if sandbox.DecodeConfigurationObject(config.Native, &native, "socket", "image_file", "receipt_root") != nil || + !canonicalSocket(native.Socket) || !filepath.IsAbs(native.ImageFile) || filepath.Clean(native.ImageFile) != native.ImageFile { + return closeProvider, errors.New("invalid managed smolvm node configuration") + } + if config.Specification.Runtime == nil { + return closeProvider, sandbox.ErrInvalid + } + if err := privateSocket(native.Socket); err != nil { + return closeProvider, err + } + hash, err := hashFile(native.ImageFile) + if err != nil { + return closeProvider, fmt.Errorf("cannot verify smolvm Runtime OCI archive: %w", err) + } + if err := verifyOCIRelease(native.ImageFile, config.Specification.Runtime.ImageManifestDigest, config.Specification.Runtime.ImageID); err != nil { + return closeProvider, fmt.Errorf("smolvm Runtime release mismatch: %w", err) + } + client := sdk.NewClient(native.Socket) + provider, err := New(client, Config{InstallationID: config.InstallationID, ImagePath: native.ImageFile, ImageHash: hash, ReceiptRoot: native.ReceiptRoot, Resources: config.Specification.Resources}) + if err != nil { + return closeProvider, err + } + result.Provider = provider + result.BackendFingerprint = sandbox.BackendFingerprint(config.Provider, native.Socket) + result.Probe = func(ctx context.Context) (*sandbox.CheckpointCompatibility, error) { + if _, err := client.Health(ctx); err != nil { + return nil, fmt.Errorf("%w: smolvm service unavailable: %v", sandbox.ErrProviderUnavailable, err) + } + return nil, nil + } + return closeProvider, nil +} diff --git a/services/core/internal/sandbox/smolvm/operations.go b/services/core/internal/sandbox/smolvm/operations.go new file mode 100644 index 000000000..5eb76fdd7 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/operations.go @@ -0,0 +1,57 @@ +package smolvm + +import ( + "context" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +const noCheckpoint = "smolvm_checkpoint_lifecycle_not_qualified" + +func Operations() providercontract.Operations { + result := providercontract.Operations{} + for _, operation := range []string{"Create", "GetInfo", "Renew", "Kill", "RunCommand"} { + result[operation] = providercontract.Support{State: providercontract.Supported} + } + for _, operation := range []string{"Initial", "NewCompute", "GetCompute", "Suspend", "Resume", "KillCompute", "DeleteSnapshot", "RunCommandCompute", "ResumeCompute"} { + result[operation] = providercontract.Support{State: providercontract.Unsupported, Reason: noCheckpoint} + } + result["Observe"] = providercontract.Support{State: providercontract.Unsupported, Reason: "smolvm_resource_observation_not_qualified"} + return result +} +func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } +func unsupported(operation string) error { + return &providercontract.UnsupportedError{Operation: operation, Reason: Operations()[operation].Reason} +} +func (*Provider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { + return sandbox.Compute{}, unsupported("Initial") +} +func (*Provider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { + return sandbox.Compute{}, unsupported("NewCompute") +} +func (*Provider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, unsupported("GetCompute") +} +func (*Provider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, unsupported("Suspend") +} +func (*Provider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, unsupported("Resume") +} +func (*Provider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { + return unsupported("KillCompute") +} +func (*Provider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { + return unsupported("DeleteSnapshot") +} +func (*Provider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { + return sandbox.CommandResult{}, unsupported("RunCommandCompute") +} +func (*Provider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, unsupported("ResumeCompute") +} +func (*Provider) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { + return runtimeobs.Sample{}, unsupported("Observe") +} diff --git a/services/core/internal/sandbox/smolvm/provider.go b/services/core/internal/sandbox/smolvm/provider.go new file mode 100644 index 000000000..59eb950e8 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/provider.go @@ -0,0 +1,284 @@ +// Package smolvm adapts the local smolvm service to OpenAgentCore's managed +// sandbox lifecycle. A VM and a durable bootstrap receipt are one allocation. +package smolvm + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + sdk "github.com/smol-machines/smolvm-sdk/smolvm-go" +) + +const labelPrefix = "io.oac." + +// Config is node-private; it does not come from a Session or an Environment. +// ImagePath is the verified local OCI archive from the selected distribution. +type Config struct { + InstallationID, ImagePath, ImageHash, ReceiptRoot string + Resources sandbox.Resources +} + +type Provider struct { + client *sdk.Client + config Config + imageRef string +} + +var _ sandbox.SandboxProvider = (*Provider)(nil) + +func validID(v string) bool { + id, err := uuid.Parse(v) + return err == nil && id != uuid.Nil && id.String() == v +} +func validReference(r sandbox.Reference) bool { + return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) +} +func New(client *sdk.Client, config Config) (*Provider, error) { + if client == nil || !validID(config.InstallationID) || len(config.ImageHash) != 64 || + !filepath.IsAbs(config.ImagePath) || filepath.Clean(config.ImagePath) != config.ImagePath || + !filepath.IsAbs(config.ReceiptRoot) || filepath.Clean(config.ReceiptRoot) != config.ReceiptRoot || + ValidateResources(config.Resources) != nil { + return nil, sandbox.ErrInvalid + } + if _, err := hex.DecodeString(config.ImageHash); err != nil || strings.ToLower(config.ImageHash) != config.ImageHash { + return nil, sandbox.ErrInvalid + } + if err := os.MkdirAll(config.ReceiptRoot, 0700); err != nil { + return nil, err + } + info, err := os.Lstat(config.ReceiptRoot) + if err != nil { + return nil, err + } + if !info.IsDir() || info.Mode().Perm() != 0700 { + return nil, sandbox.ErrInvalid + } + return &Provider{client: client, config: config, imageRef: "local:" + config.ImageHash}, nil +} +func (p *Provider) name(r sandbox.Reference) string { + return resourceName(p.config.InstallationID, r) +} +func (p *Provider) labels(r sandbox.Reference, nativeID string) map[string]string { + return map[string]string{ + labelPrefix + "installation": p.config.InstallationID, + labelPrefix + "tenant": r.TenantID, + labelPrefix + "environment": r.EnvironmentID, + labelPrefix + "allocation": r.AllocationID, + labelPrefix + "native_id": nativeID, + } +} +func (p *Provider) owns(machine *sdk.MachineInfo, r sandbox.Reference) bool { + for key, want := range p.labels(r, "") { + if key == labelPrefix+"native_id" { + if !validID(machine.Labels[key]) { + return false + } + continue + } + if machine.Labels[key] != want { + return false + } + } + return machine.Name == p.name(r) +} +func (p *Provider) inspect(ctx context.Context, r sandbox.Reference) (*sdk.MachineInfo, error) { + if !validReference(r) { + return nil, sandbox.ErrInvalid + } + machine, err := p.client.GetMachine(ctx, p.name(r)) + if errors.Is(err, sdk.ErrNotFound) { + return nil, sandbox.ErrNotFound + } + if err != nil { + return nil, err + } + if !p.owns(machine, r) { + return nil, sandbox.ErrOwnership + } + return machine, nil +} +func (p *Provider) describe(ctx context.Context, r sandbox.Reference, checkConfiguration bool) (sandbox.Info, error) { + info := sandbox.Info{Reference: r} + machine, err := p.inspect(ctx, r) + if err != nil { + return info, err + } + info.ProviderID, info.State = machine.Labels[labelPrefix+"native_id"], string(machine.State) + receipt, err := p.readReceipt(r) + if err != nil { + return sandbox.Info{Reference: r}, err + } + if receipt != nil && receipt.NativeID != info.ProviderID { + return sandbox.Info{Reference: r}, sandbox.ErrOwnership + } + if checkConfiguration { + if machine.Image != p.imageRef || machine.CPUs != int(p.config.Resources.CPUs) || machine.MemoryMB != int(p.config.Resources.MemoryMiB) { + return sandbox.Info{Reference: r}, sandbox.ErrInvalid + } + } + info.BootstrapComplete = machine.State == sdk.MachineStateRunning && receipt != nil + return info, nil +} +func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.describe(ctx, r, true) +} +func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.GetInfo(ctx, r) +} + +// Create installs exactly one immutable allocation, with a receipt written +// after guest bootstrap is ready. An uncertain error retains the original VM. +func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info := sandbox.Info{Reference: b.Reference} + if b.Workspace != nil { + return info, &providercontract.UnsupportedError{Operation: "Create", Reason: "external_workspace_unsupported"} + } + policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} + if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || policy.Validate() != nil || b.RuntimeConnection().Validate() != nil { + return info, sandbox.ErrInvalid + } + if previous, err := p.readReceipt(b.Reference); err != nil { + return info, err + } else if previous != nil { + return info, sandbox.ErrExists + } + if _, err := p.inspect(ctx, b.Reference); err == nil { + return info, sandbox.ErrExists + } else if !errors.Is(err, sandbox.ErrNotFound) { + return info, err + } + nativeID := uuid.NewString() + machineName := p.name(b.Reference) + domains, err := json.Marshal(policy.Hosts()) + if err != nil { + return info, sandbox.ErrInvalid + } + // The workload waits for the final, atomic ready file before it connects. + // This keeps an interrupted upload from starting a daemon with partial keys. + command := `while [ ! -f /home/runtime/.oac/ready ]; do sleep 1; done; exec /usr/local/bin/oac-daemon connect --profile default --bootstrap-file /home/runtime/runtime-bootstrap.json` + storage := diskGiB(p.config.Resources.RootDiskMiB) + overlay := diskGiB(p.config.Resources.EnvironmentDiskMiB) + created, err := p.client.CreateMachine(ctx, sdk.CreateMachineRequest{ + Name: machineName, Labels: p.labels(b.Reference, nativeID), Image: p.config.ImagePath, + CPUs: int(p.config.Resources.CPUs), MemoryMB: int(p.config.Resources.MemoryMiB), StorageGB: &storage, OverlayGB: &overlay, + Network: true, Entrypoint: []string{"/bin/sh", "-c"}, Cmd: []string{command}, + Env: []sdk.EnvVar{ + {Name: "OAC_RUNTIME_ENVIRONMENT_ID", Value: b.EnvironmentID}, + {Name: "OAC_RUNTIME_SESSION_ID", Value: b.SessionID}, + {Name: "OAC_RUNTIME_NETWORK_ACCESS", Value: policy.Access}, + {Name: "OAC_RUNTIME_ALLOWED_DOMAINS", Value: string(domains)}, + }, + }) + if errors.Is(err, sdk.ErrConflict) { + return info, sandbox.ErrExists + } + if err != nil { + return info, err + } + info.ProviderID = nativeID + if !p.owns(created, b.Reference) { + return info, sandbox.ErrOwnership + } + if created.Image != p.imageRef || created.CPUs != int(p.config.Resources.CPUs) || created.MemoryMB != int(p.config.Resources.MemoryMiB) { + info.CreateSettled = true // create returned and no bootstrap mutation was dispatched + return info, sandbox.ErrInvalid + } + if _, err := p.client.StartMachine(ctx, machineName); err != nil { + return info, err + } + if err := p.bootstrap(ctx, b); err != nil { + return info, fmt.Errorf("runtime bootstrap: %w", err) + } + if err := p.writeReceipt(b.Reference, nativeID); err != nil { + return info, err + } + return p.GetInfo(ctx, b.Reference) +} + +func diskGiB(mib uint32) int64 { return int64((uint64(mib) + 1023) / 1024) } + +// Kill verifies the native VM and the private receipt before deleting either. +func (p *Provider) Kill(ctx context.Context, ref sandbox.Reference) error { + if !validReference(ref) { + return sandbox.ErrInvalid + } + machine, err := p.inspect(ctx, ref) + if err != nil && !errors.Is(err, sandbox.ErrNotFound) { + return err + } + receipt, receiptErr := p.readReceipt(ref) + if receiptErr != nil { + return receiptErr + } + if receipt != nil && machine != nil && receipt.NativeID != machine.Labels[labelPrefix+"native_id"] { + return sandbox.ErrOwnership + } + if machine != nil { + if _, err := p.client.DeleteMachine(ctx, machine.Name, false); err != nil && !errors.Is(err, sdk.ErrNotFound) { + return err + } + if _, err := p.inspect(ctx, ref); !errors.Is(err, sandbox.ErrNotFound) { + if err == nil { + return errors.New("smolvm VM deletion is unconfirmed") + } + return err + } + } + if err := p.removeReceipt(ref); err != nil { + return err + } + return nil +} + +func (p *Provider) bootstrap(ctx context.Context, b sandbox.Bootstrap) error { + name := p.name(b.Reference) + noStart := false + prepare, err := p.client.Exec(ctx, name, sdk.ExecRequest{Command: []string{"/bin/sh", "-c", `umask 077; mkdir -p /home/runtime/.oac /environment/workspace /environment/staging /environment/initialization /environment/packages; chown -R 1000:1000 /home/runtime /environment; chmod 0700 /home/runtime /home/runtime/.oac`}, User: "0", AutoStart: &noStart}) + if err != nil { + return err + } + if prepare.ExitCode != 0 { + return fmt.Errorf("prepare runtime directories exited %d", prepare.ExitCode) + } + credentials, err := b.RuntimeConnection().Marshal() + if err != nil { + return err + } + if _, err := p.client.UploadFile(ctx, name, "/home/runtime/runtime-bootstrap.json", credentials); err != nil { + return err + } + permissions, err := p.client.Exec(ctx, name, sdk.ExecRequest{Command: []string{"/bin/sh", "-c", "chown 1000:1000 /home/runtime/runtime-bootstrap.json && chmod 0600 /home/runtime/runtime-bootstrap.json"}, User: "0", AutoStart: &noStart}) + if err != nil { + return err + } + if permissions.ExitCode != 0 { + return fmt.Errorf("set bootstrap ownership exited %d", permissions.ExitCode) + } + // The ready file contains no credentials. It is the final guest mutation: + // the waiting workload starts the daemon without another provider RPC. + _, err = p.client.UploadFile(ctx, name, "/home/runtime/.oac/ready", []byte("ready\n")) + return err +} + +func validateCommand(command sandbox.Command) error { + if len(command.Args) == 0 || len(command.Stdin) > sandbox.MaxCommandInputBytes || command.Directory != "" && (!path.IsAbs(command.Directory) || path.Clean(command.Directory) != command.Directory) { + return sandbox.ErrInvalid + } + for _, arg := range command.Args { + if strings.ContainsRune(arg, 0) { + return sandbox.ErrInvalid + } + } + return nil +} diff --git a/services/core/internal/sandbox/smolvm/provider_test.go b/services/core/internal/sandbox/smolvm/provider_test.go new file mode 100644 index 000000000..04fb2de64 --- /dev/null +++ b/services/core/internal/sandbox/smolvm/provider_test.go @@ -0,0 +1,131 @@ +package smolvm + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + sdk "github.com/smol-machines/smolvm-sdk/smolvm-go" +) + +func testProvider(t *testing.T, handler http.HandlerFunc) (*Provider, sandbox.Reference, *int) { + t.Helper() + calls := new(int) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { *calls++; handler(w, r) })) + t.Cleanup(server.Close) + image := filepath.Join(t.TempDir(), "image.tar") + if err := os.WriteFile(image, []byte("archive"), 0600); err != nil { + t.Fatal(err) + } + p, err := New(sdk.NewClient(server.URL), Config{InstallationID: uuid.NewString(), ImagePath: image, + ImageHash: strings.Repeat("a", 64), ReceiptRoot: filepath.Join(t.TempDir(), "private"), + Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024, RootDiskMiB: 1024, EnvironmentDiskMiB: 1024}}) + if err != nil { + t.Fatal(err) + } + ref := sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} + return p, ref, calls +} + +func TestForeignMachineCannotBeObservedOrDeleted(t *testing.T) { + p, ref, calls := testProvider(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected native mutation: %s", r.Method) + } + _, _ = w.Write([]byte(`{"name":"foreign","labels":{"io.oac.tenant":"foreign"},"state":"running"}`)) + }) + if _, err := p.GetInfo(context.Background(), ref); !errors.Is(err, sandbox.ErrOwnership) { + t.Fatalf("GetInfo: %v", err) + } + if err := p.Kill(context.Background(), ref); !errors.Is(err, sandbox.ErrOwnership) { + t.Fatalf("Kill: %v", err) + } + if *calls != 2 { + t.Fatalf("native calls=%d want 2 read-only", *calls) + } +} + +func TestDiskGiBRoundsWithoutOverflow(t *testing.T) { + for _, tc := range []struct { + mib uint32 + gib int64 + }{{1, 1}, {1024, 1}, {1025, 2}, {^uint32(0), 4194304}} { + if got := diskGiB(tc.mib); got != tc.gib { + t.Fatalf("diskGiB(%d) = %d, want %d", tc.mib, got, tc.gib) + } + } +} + +func TestBootstrapReceiptNeverReplacesAnotherNativeMachine(t *testing.T) { + p, ref, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) + first, second := uuid.NewString(), uuid.NewString() + if err := p.writeReceipt(ref, first); err != nil { + t.Fatal(err) + } + if err := p.writeReceipt(ref, second); !errors.Is(err, sandbox.ErrExists) { + t.Fatalf("overwriting receipt: %v", err) + } + got, err := p.readReceipt(ref) + if err != nil || got.NativeID != first { + t.Fatalf("original receipt lost: %+v %v", got, err) + } +} + +func TestReceiptRootRejectsSymlinks(t *testing.T) { + p, _, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(p.config.ReceiptRoot, link); err != nil { + t.Fatal(err) + } + config := p.config + config.ReceiptRoot = link + if _, err := New(p.client, config); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatalf("symlink receipt root: %v", err) + } +} + +func TestPrivateAPISocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "control.sock") + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + address := "unix://" + path + if !errors.Is(privateSocket(address), sandbox.ErrInvalid) { + t.Fatal("regular file accepted as an API socket") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + if err := os.Chmod(path, 0666); err != nil { + t.Fatal(err) + } + if !errors.Is(privateSocket(address), sandbox.ErrInvalid) { + t.Fatal("public API socket accepted") + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + if err := privateSocket(address); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "linked.sock") + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if !errors.Is(privateSocket("unix://"+link), sandbox.ErrInvalid) { + t.Fatal("socket symlink accepted") + } +} diff --git a/services/core/internal/sandbox/smolvm/receipt.go b/services/core/internal/sandbox/smolvm/receipt.go new file mode 100644 index 000000000..cb1b54efa --- /dev/null +++ b/services/core/internal/sandbox/smolvm/receipt.go @@ -0,0 +1,108 @@ +package smolvm + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +type receipt struct { + Installation string `json:"installation"` + Reference sandbox.Reference `json:"reference"` + NativeID string `json:"native_id"` + ImageRef string `json:"image_ref"` +} + +func resourceName(installation string, ref sandbox.Reference) string { + key := sha256.Sum256([]byte(installation + ":" + ref.TenantID + ":" + ref.EnvironmentID + ":" + ref.AllocationID)) + return "oac-smolvm-" + hex.EncodeToString(key[:16]) +} +func (p *Provider) receiptPath(ref sandbox.Reference) string { + return filepath.Join(p.config.ReceiptRoot, resourceName(p.config.InstallationID, ref)+".json") +} +func (p *Provider) readReceipt(ref sandbox.Reference) (*receipt, error) { + raw, err := os.ReadFile(p.receiptPath(ref)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var value receipt + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("%w: invalid bootstrap receipt", sandbox.ErrOwnership) + } + if value.Installation != p.config.InstallationID || value.Reference != ref || value.NativeID == "" || value.ImageRef != p.imageRef { + return nil, sandbox.ErrOwnership + } + return &value, nil +} +func syncReceiptDir(root string) error { + if runtime.GOOS == "windows" { + return nil + } + dir, err := os.Open(root) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} +func (p *Provider) writeReceipt(ref sandbox.Reference, nativeID string) error { + if value, err := p.readReceipt(ref); err != nil { + return err + } else if value != nil { + return sandbox.ErrExists + } + value := receipt{Installation: p.config.InstallationID, Reference: ref, NativeID: nativeID, ImageRef: p.imageRef} + data, err := json.Marshal(value) + if err != nil { + return err + } + suffix := make([]byte, 16) + if _, err := rand.Read(suffix); err != nil { + return err + } + tmp := p.receiptPath(ref) + "." + hex.EncodeToString(suffix) + ".tmp" + file, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + defer os.Remove(tmp) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + // Link publishes only if no receipt exists. Rename replaces another + // writer's receipt on Unix and could silently authorize the wrong VM. + if err := os.Link(tmp, p.receiptPath(ref)); err != nil { + if errors.Is(err, os.ErrExist) { + return sandbox.ErrExists + } + return err + } + return syncReceiptDir(p.config.ReceiptRoot) +} +func (p *Provider) removeReceipt(ref sandbox.Reference) error { + value, err := p.readReceipt(ref) + if err != nil || value == nil { + return err + } + if err := os.Remove(p.receiptPath(ref)); err != nil { + return err + } + return syncReceiptDir(p.config.ReceiptRoot) +} diff --git a/services/web/node_installation.go b/services/web/node_installation.go index 2747292ba..32d41837a 100644 --- a/services/web/node_installation.go +++ b/services/web/node_installation.go @@ -160,8 +160,10 @@ func (h *console) nodeArtifacts() []string { if err != nil { return available } - for provider, artifacts := range providerassets.Catalog() { - complete := true + catalog := providerassets.Catalog() + for _, provider := range providerassets.AutomaticInstallers() { + artifacts := catalog[provider] + complete := len(artifacts) > 0 for _, artifact := range artifacts { logical := artifact.Path entry, ok := manifest.Artifacts[logical]