From 8968b9244750fbc6f66bf73f0562d4e66830b46e Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Tue, 3 Nov 2026 08:14:33 +0000
Subject: [PATCH 1/7] Add an OpenSandbox Sandbox Provider
Run Core-managed sandboxes on an operator's OpenSandbox server through its official Go SDK: a direct adapter with no helper or local state, ownership in sandbox metadata, the Runtime bootstrap through execd, and Core's egress mapped to the sidecar's dns+nft policy. Checkpoint and the preparation cache stay unmapped.
---
CONTRIBUTING.md | 1 +
README.md | 2 +-
README.zh-CN.md | 2 +-
apps/web/e2e/public-url.spec.ts | 2 +-
.../features/sandbox/SandboxSetupWizard.tsx | 2 +-
apps/web/src/i18n/locales/zh-CN/sandbox.ts | 2 +-
contracts/agents-api/core-errors.md | 2 +-
contracts/agents-api/environments.md | 5 +-
contracts/agents-api/harness-capabilities.md | 4 +-
contracts/agents-api/harness-onboarding.md | 2 +-
contracts/agents-api/machine-api.md | 2 +-
.../agents-api/runtime-observability-api.md | 4 +-
contracts/agents-api/runtime-observability.md | 19 +-
contracts/agents-api/sandbox-deployment.md | 78 ++--
deploy/node/node_spec.py | 2 +-
docs/api/public-agent-api.md | 2 +-
docs/concepts.md | 2 +-
docs/configuration.md | 4 +-
docs/getting-started/install.md | 2 +-
docs/getting-started/nodes.md | 2 +-
docs/getting-started/operations.md | 2 +-
docs/getting-started/public-address.md | 1 +
docs/getting-started/quickstart.md | 4 +-
docs/getting-started/sandbox-backends.md | 36 +-
docs/getting-started/workspace-storage.md | 2 +-
docs/runtime-bootstrap.md | 1 +
docs/sandbox-provider.md | 11 +-
docs/web/console-api-usage.md | 2 +-
docs/web/index.md | 2 +-
docs/workspace-provider.md | 1 +
go.mod | 1 +
go.sum | 2 +
.../agents-client/src/deployment-contract.ts | 2 +-
services/core/IMPLEMENTATION.md | 4 +-
services/core/deploy/opensandbox/Dockerfile | 8 +
services/core/deploy/opensandbox/README.md | 48 +++
.../sandbox/opensandbox/configuration.go | 218 ++++++++++
.../sandbox/opensandbox/configuration_test.go | 84 ++++
.../internal/sandbox/opensandbox/fake_test.go | 260 ++++++++++++
.../internal/sandbox/opensandbox/network.go | 125 ++++++
.../sandbox/opensandbox/network_test.go | 100 +++++
.../sandbox/opensandbox/observations.go | 80 ++++
.../sandbox/opensandbox/operations.go | 67 +++
.../internal/sandbox/opensandbox/provider.go | 385 ++++++++++++++++++
.../sandbox/opensandbox/provider_test.go | 213 ++++++++++
.../internal/sandbox/opensandbox/verify.go | 57 +++
.../internal/sandbox/providers/registry.go | 5 +
.../sandbox/providers/registry_test.go | 1 +
48 files changed, 1779 insertions(+), 84 deletions(-)
create mode 100644 services/core/deploy/opensandbox/Dockerfile
create mode 100644 services/core/deploy/opensandbox/README.md
create mode 100644 services/core/internal/sandbox/opensandbox/configuration.go
create mode 100644 services/core/internal/sandbox/opensandbox/configuration_test.go
create mode 100644 services/core/internal/sandbox/opensandbox/fake_test.go
create mode 100644 services/core/internal/sandbox/opensandbox/network.go
create mode 100644 services/core/internal/sandbox/opensandbox/network_test.go
create mode 100644 services/core/internal/sandbox/opensandbox/observations.go
create mode 100644 services/core/internal/sandbox/opensandbox/operations.go
create mode 100644 services/core/internal/sandbox/opensandbox/provider.go
create mode 100644 services/core/internal/sandbox/opensandbox/provider_test.go
create mode 100644 services/core/internal/sandbox/opensandbox/verify.go
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index acb60197c..ae59a7af6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,6 +17,7 @@ Every subject has one owner. Most owners are the page whose title names the subj
| Codex, Claude and MiniMax Runtime adapters and images | [Codex](services/core/deploy/codex/README.md), [Claude](services/core/deploy/claude/README.md), [MiniMax](services/core/deploy/mcode/README.md) |
| Claude private SDK bridge | [Claude SDK adapter](packages/claude-sdk-adapter/README.md) |
| E2B template construction | [E2B template builder](services/core/deploy/e2b/README.md) |
+| OpenSandbox Runtime image and adapter | [OpenSandbox adapter guide](services/core/deploy/opensandbox/README.md) |
| E2B and microsandbox Provider helper implementation | [E2B helper](services/core/tools/e2b-provider/README.md), [microsandbox helper](services/core/tools/microsandbox-provider/README.md) |
| Distribution builds, Runtime image builds, CI and publication | [Build and release OpenAgentCore](docs/maintainers.md) |
| Website: landing page, bilingual documentation maintenance, documentation site build and GitHub Pages publication | [Website guide](website/README.md) |
diff --git a/README.md b/README.md
index af5f2fcc5..673cd7179 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ On Windows, in PowerShell:
irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex
```
-In Web, sign in with the Core key the installer prints, configure the public address, set a default model, issue a Project API key and add sandbox capacity (a node or E2B). [Install Core and Web](https://openagentcore.dev/docs/getting-started/install) covers each step.
+In Web, sign in with the Core key the installer prints, configure the public address, set a default model, issue a Project API key and add sandbox capacity (a node, E2B or OpenSandbox). [Install Core and Web](https://openagentcore.dev/docs/getting-started/install) covers each step.
Then call it with the OpenAI SDK, with `OPENAI_BASE_URL` set to your API base URL (`https://core.example/v1`) and `OPENAI_API_KEY` to the Project API key:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 0a420e5e2..474cf7a50 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -33,7 +33,7 @@ curl -fsSL https://openagentcore.dev/install.sh | bash
irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex
```
-在 Web 中,用安装程序输出的 Core 密钥登录,配置公共地址,设置默认模型,签发项目 API 密钥,并添加沙箱容量(节点或 E2B)。每一步见[安装 Core 和 Web](https://openagentcore.dev/zh/docs/getting-started/install)。
+在 Web 中,用安装程序输出的 Core 密钥登录,配置公共地址,设置默认模型,签发项目 API 密钥,并添加沙箱容量(节点、E2B 或 OpenSandbox)。每一步见[安装 Core 和 Web](https://openagentcore.dev/zh/docs/getting-started/install)。
然后用 OpenAI SDK 调用,将 `OPENAI_BASE_URL` 设为你的 API 基础地址(`https://core.example/v1`),`OPENAI_API_KEY` 设为项目 API 密钥:
diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts
index b7a7ddd2a..408d2aab2 100644
--- a/apps/web/e2e/public-url.spec.ts
+++ b/apps/web/e2e/public-url.spec.ts
@@ -28,7 +28,7 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy
await selectFixtureE2BBuild(page);
await page.getByRole("button", { name: "Next" }).click();
const address = page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" });
- await expect(address).toContainText("Set a public address before connecting; E2B sandboxes need an HTTPS one.");
+ await expect(address).toContainText("Set a public address before connecting; E2B sandboxes reach Core through it.");
await page.getByRole("button", { name: "Save configuration" }).click();
const rejection = page.locator(".wizard-rejection");
await expect(rejection).toContainText("This sandbox provider needs a public HTTPS address.");
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
index c87fae85b..9b5b4ec05 100644
--- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
+++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
@@ -309,7 +309,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{address ? {address} : "—"}
{t("Managed in Settings")}
- {installation.data?.local_only ? {setup?.public_origin === "required" ? t("Set a public address before connecting; {{provider}} sandboxes need an HTTPS one.", { provider: setup.label }) : t("Set a public address before connecting remote nodes.")} : null}
+ {installation.data?.local_only ? {setup?.public_origin === "required" ? t("Set a public address before connecting; {{provider}} sandboxes reach Core through it.", { provider: setup.label }) : t("Set a public address before connecting remote nodes.")} : null}
diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts
index cfdd6814f..3c182b537 100644
--- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts
+++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts
@@ -347,7 +347,7 @@ export const sandbox = {
"Change the sandbox configuration": "修改沙箱配置",
"The address nodes and sandboxes use to reach Core.": "节点和沙箱访问 Core 使用的地址。",
"Managed in Settings": "在设置中管理",
- "Set a public address before connecting; {{provider}} sandboxes need an HTTPS one.": "连接前请先设置公开地址;{{provider}} 沙箱需要 HTTPS 地址。",
+ "Set a public address before connecting; {{provider}} sandboxes reach Core through it.": "连接前请先设置公开地址;{{provider}} 沙箱通过它连接 Core。",
"Enter the key again to save.": "请重新输入密钥后再保存。",
"Set a public address before connecting remote nodes.": "连接远程节点前,请先设置公开地址。",
"Select {{field}}": "选择{{field}}",
diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md
index bed69b0bc..b1e124266 100644
--- a/contracts/agents-api/core-errors.md
+++ b/contracts/agents-api/core-errors.md
@@ -66,7 +66,7 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf
| `harness_config_invalid` | `harness_config` | omitted | The deployment default's native parameters are unsupported or invalid |
| `invalid_sandbox_configuration` | `resources.cpus` | `min`: 1, `max`: 255 | The CPU count is outside the supported bounds |
| `invalid_sandbox_configuration` | `resources.memory_mib` | `min`: 512, `max`: 1048576 | Memory is outside the supported bounds |
-| `invalid_sandbox_configuration` | `resources.root_disk_mib` or `resources.environment_disk_mib` | `min`: 1024 for microsandbox (`environment_disk_mib` also accepts 0); `min`: 0, `max`: 0 for E2B | Disk capacity is missing or unsupported by the provider |
+| `invalid_sandbox_configuration` | `resources.root_disk_mib` or `resources.environment_disk_mib` | `min`: 1024 for microsandbox (`environment_disk_mib` also accepts 0); `min`: 0, `max`: 0 for E2B and OpenSandbox | Disk capacity is missing or unsupported by the provider |
Bounds are validation constants, never submitted values. Node names are limited in bytes; Project and key names in trimmed Unicode characters without control characters. Only the first failure is reported, in this order: model provider URL, protocol, key, limits, then the Harness's protocol; sandbox resources CPU, memory, then disk. Model-provider field errors inside a `model_provider` object keep that object's field as `param`. An unknown sandbox provider returns an error without these fields.
diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md
index 850f0fbdf..f18a3932c 100644
--- a/contracts/agents-api/environments.md
+++ b/contracts/agents-api/environments.md
@@ -8,7 +8,7 @@ Related owners:
- [Environment files](./environment-files.md): the Files API on a live workspace.
- [Executor credentials](./environment-executor-credentials.md): enrollment, the installation grant and connection status of a `self_hosted` machine.
-- [Sandbox deployment](./sandbox-deployment.md): which Sandbox Provider (E2B or microsandbox) hosts `openai_hosted` Environments.
+- [Sandbox deployment](./sandbox-deployment.md): which Sandbox Provider (microsandbox, E2B or OpenSandbox) hosts `openai_hosted` Environments.
- [Core–Runtime protocol](../../docs/runtime-protocol.md): the `runtime_prepare` transfer and every other wire message.
- [Runtime and outer isolation](../../docs/concepts.md#runtime-and-outer-isolation): the daemon runs tools with its launching user's permissions; isolation comes from the outer Environment.
@@ -71,7 +71,7 @@ Both placements run the same Runtime: the daemon, the selected Harness, native t
### Hosted (`openai_hosted`)
-The deployment's configured Sandbox Provider (E2B or microsandbox, see [sandbox deployment](./sandbox-deployment.md)) hosts the Environment. [Harness capabilities](./harness-capabilities.md) lists which Harnesses run there.
+The deployment's configured Sandbox Provider (microsandbox, E2B or OpenSandbox, see [sandbox deployment](./sandbox-deployment.md)) hosts the Environment. [Harness capabilities](./harness-capabilities.md) lists which Harnesses run there.
- Session creation, with or without initial input, commits the Session, Environment and retry identity before the Worker provisions compute. A creation interrupted before bootstrap is recovered without repeating the Provider's Create.
- Provisioning needs no caller action; the Session stays idle until a Turn starts.
@@ -313,6 +313,7 @@ Limits ([adapter rules](../../docs/sandbox-provider.md#network-egress)):
- A model provider host that the cap lists, by name or through a `*.` entry, is reachable like a listed domain, not only on its port.
- On E2B, a restricted domain is reachable only on ports 80 and 443, and a model provider on another port is reachable on every port of its public addresses.
+- On microsandbox and OpenSandbox, an allowed name is reachable on every address it resolves to, private ones included.
`disabled` and `restricted` return `sandbox_network_unsupported` when the deployment's sandbox Provider cannot restrict egress. A `self_hosted` Environment accepts only `enabled`, because Core cannot enforce a policy on a machine it does not run.
diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md
index 0e75ae3cb..6aa4a9c66 100644
--- a/contracts/agents-api/harness-capabilities.md
+++ b/contracts/agents-api/harness-capabilities.md
@@ -12,7 +12,7 @@ Anything this page does not list follows the Harness's native behavior with the
| Admitted | Core admits it through the same Runtime path, but no real-model acceptance has run on that placement |
| Rejected | Core rejects the request before execution |
-Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted (`self_hosted`). E2B and microsandbox run the same Runtime and adapters, and every Verified hosted cell counts as Admitted on both. Self-hosted acceptance ran on Linux machines; the [self-hosted guide](../../docs/getting-started/self-hosted.md#platforms) lists the supported platforms. A verified operation is verified on its own, not in every combination with other options; combinations that a profile rejects are listed in the operation's contract.
+Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted (`self_hosted`). microsandbox, E2B and OpenSandbox run the same Runtime and adapters, and every Verified hosted cell counts as Admitted on each. Self-hosted acceptance ran on Linux machines; the [self-hosted guide](../../docs/getting-started/self-hosted.md#platforms) lists the supported platforms. A verified operation is verified on its own, not in every combination with other options; combinations that a profile rejects are listed in the operation's contract.
## Execution and input
@@ -65,4 +65,4 @@ These operations need a workspace, so they apply to hosted and self-hosted place
| [Plugin MCP over stdio](./environments.md#plugin-mcp) | Verified: hosted, self-hosted | Verified: self-hosted; admitted: hosted | Verified: self-hosted; admitted: hosted |
| Plugin MCP over HTTP | Admitted, with literal headers or HTTPS bearer | Admitted, with literal headers or HTTPS bearer | Verified: self-hosted; admitted: hosted; literal headers or HTTPS bearer |
-External workspace storage additionally requires the selected Harness declaration and Runtime to support `retained_native_history`; E2B, which keeps its own disks, does not. microsandbox always uses external storage, so MiniMax Code, which does not declare the capability, cannot run on it. The [Workspace filesystem protocol](../../docs/workspace-provider.md) owns storage combination admission.
+External workspace storage additionally requires the selected Harness declaration and Runtime to support `retained_native_history`; E2B and OpenSandbox, which keep their own disks, do not. microsandbox always uses external storage, so MiniMax Code, which does not declare the capability, cannot run on it. The [Workspace filesystem protocol](../../docs/workspace-provider.md) owns storage combination admission.
diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md
index c8aa81f56..59c43844e 100644
--- a/contracts/agents-api/harness-onboarding.md
+++ b/contracts/agents-api/harness-onboarding.md
@@ -34,7 +34,7 @@ flowchart TB
| Declaration | The Harness's maximum capabilities, placements, configuration and pure admission restrictions, read by Core admission and the Runtime | `internal/harnessconfig//declaration.go` |
| Runtime binding | The declaration plus discovery, installed factories and verified capabilities | `apps/daemon/internal/agent//declaration.go` |
-An Environment supplies execution resources. Managed E2B and microsandbox machines and application-owned machines differ in provisioning and connection; the connected Runtime uses this same contract. The daemon runs on Linux, macOS and Windows, managed Providers are Linux-only, and each adapter qualifies its own platforms ([self-hosted platforms](../../docs/getting-started/self-hosted.md#platforms)). Native factories receive capabilities only after the Runtime has loaded the bound installed snapshot ([capability preparation](./environments.md#runtime-capability-preparation)). Model providers supply model communication settings, not Turn scheduling or native process ownership.
+An Environment supplies execution resources. Managed microsandbox, E2B and OpenSandbox machines and application-owned machines differ in provisioning and connection; the connected Runtime uses this same contract. The daemon runs on Linux, macOS and Windows, managed Providers are Linux-only, and each adapter qualifies its own platforms ([self-hosted platforms](../../docs/getting-started/self-hosted.md#platforms)). Native factories receive capabilities only after the Runtime has loaded the bound installed snapshot ([capability preparation](./environments.md#runtime-capability-preparation)). Model providers supply model communication settings, not Turn scheduling or native process ownership.
## Steps
diff --git a/contracts/agents-api/machine-api.md b/contracts/agents-api/machine-api.md
index 74ed481da..fb3769688 100644
--- a/contracts/agents-api/machine-api.md
+++ b/contracts/agents-api/machine-api.md
@@ -60,7 +60,7 @@ oac-daemon connect --profile default
- A new node sends its enrollment token without `X-OAC-Node-ID`. The token must be valid, unexpired, unconsumed and issued by this installation. An active reset refuses this read.
- A registered node sends its node credential and its UUID in `X-OAC-Node-ID`. Without a query it reads the current target. `?generation=N` reads only a generation this node may still need: the current target, its serving pin, or one held by an unreleased allocation or placement on it; any other generation is refused. This read stays available during a reset, for owned recovery.
-The response has `installation_id`, `provider`, `core_url` (the installation public URL), `generation`, `specification` and `specification_digest`. It never contains an administrator, Project or E2B credential, and exists only for node-backed providers. The [sandbox deployment contract](./sandbox-deployment.md#canonical-node-specification) defines the specification and its digest.
+The response has `installation_id`, `provider`, `core_url` (the installation public URL), `generation`, `specification` and `specification_digest`. It never contains an administrator, Project or Sandbox Provider credential, and exists only for node-backed providers. The [sandbox deployment contract](./sandbox-deployment.md#canonical-node-specification) defines the specification and its digest.
### Enroll a node
diff --git a/contracts/agents-api/runtime-observability-api.md b/contracts/agents-api/runtime-observability-api.md
index 79939c624..efe009f40 100644
--- a/contracts/agents-api/runtime-observability-api.md
+++ b/contracts/agents-api/runtime-observability-api.md
@@ -117,7 +117,7 @@ This returns one `RuntimeObservation`, without `disk`. It accepts no query param
### `disk`
-Only list rows carry `disk`: null, or `{usage_bytes, limit_bytes}` with the rules of `memory`. E2B fills it when the sandbox reports both its disk usage and a nonzero capacity. microsandbox returns null. A non-null `disk` appears only on an `observed` row.
+Only list rows carry `disk`: null, or `{usage_bytes, limit_bytes}` with the rules of `memory`. E2B fills it when the sandbox reports both its disk usage and a nonzero capacity. microsandbox and OpenSandbox return null. A non-null `disk` appears only on an `observed` row.
### Status and reason
@@ -190,7 +190,7 @@ There is one series per managed allocation, keyed by `allocation_id`, so a provi
Each point has the bucket bounds, the coverage counts of the allocation's samples, and nullable `cpu` and `memory` objects with a `contributor_count` of at least 1:
-- `cpu.utilization_ratio` comes from consecutive cumulative CPU counters of one compute incarnation: the CPU seconds consumed divided by the elapsed time multiplied by the capacity, over the intervals that end in the bucket. The baseline resets when the incarnation changes or a counter decreases. E2B reports no cumulative CPU time; its bucket value is the mean of the ratios sampled in it. `cpu.capacity_cores` is the last capacity in the bucket.
+- `cpu.utilization_ratio` comes from consecutive cumulative CPU counters of one compute incarnation: the CPU seconds consumed divided by the elapsed time multiplied by the capacity, over the intervals that end in the bucket. The baseline resets when the incarnation changes or a counter decreases. E2B and OpenSandbox report no cumulative CPU time; their bucket value is the mean of the ratios sampled in it. `cpu.capacity_cores` is the last capacity in the bucket.
- `memory.usage_bytes` and `memory.limit_bytes` are the last values observed in the bucket.
Disk is not kept in history.
diff --git a/contracts/agents-api/runtime-observability.md b/contracts/agents-api/runtime-observability.md
index d519abf17..f39873f00 100644
--- a/contracts/agents-api/runtime-observability.md
+++ b/contracts/agents-api/runtime-observability.md
@@ -18,7 +18,7 @@ none: tenant_id -> session_id (no Session-owned Runtime instance)
The resolver (`services/core/internal/deployment/observation.go`) reads the Session, its Environment, the current allocation and the Session's measured usage from the database. A Session, daemon connection, process, container and native Harness Session are different identities and never stand in for one another.
-Managed microsandbox and E2B allocations are observed. `none` and `self_hosted` Sessions are `unsupported`; Core never attributes shared host statistics to an `environment:none` Session.
+Managed microsandbox, E2B and OpenSandbox allocations are observed. `none` and `self_hosted` Sessions are `unsupported`; Core never attributes shared host statistics to an `environment:none` Session.
Every managed allocation is read through the deployment's selected Sandbox Provider, which verifies the allocation's installation (`provider_key`) and labels or equivalent ownership data before it returns values. Before any provider read, the allocation state decides some rows: `creating` or no allocation yet gives `allocation_pending`, and `cleanup_pending` or `released` gives `runtime_not_running`. A provider read that exceeds its deadline gives `sample_timeout`, a not-running result `runtime_not_running`, and an unavailable result `sample_unavailable`. Any other error, an ownership mismatch or an invalid sample fails the read.
@@ -32,7 +32,7 @@ A sample carries:
- `observed_at`, the provider's observation time, and `started_at`, the start of the current compute incarnation;
- cumulative CPU seconds and the configured CPU capacity in cores;
-- a provider-reported CPU utilization ratio, only from providers without cumulative CPU time (E2B);
+- a provider-reported CPU utilization ratio, only from providers without cumulative CPU time (E2B, OpenSandbox);
- current memory usage and the memory limit in bytes;
- current disk usage and capacity in bytes, only where the provider reports them (E2B).
@@ -59,6 +59,19 @@ Core reads each allocation with one helper `observe` request: E2B's metrics for
E2B reports no cumulative CPU time, so CPU seconds stay null. `observed_at` is E2B's point time; a point up to 30 seconds ahead of Core's clock is recorded at Core's time, and a larger lead is `sample_unavailable`. A sandbox missing from the running listing is `runtime_not_running`. A missing or malformed point, an ambiguous listing and an E2B API failure, a rejected key included, are `sample_unavailable`.
+### OpenSandbox
+
+Core lists the allocation's sandbox by its ownership metadata and reads execd's `GET /metrics` through the OpenSandbox server's proxy. It never renews or changes a sandbox. The [OpenSandbox adapter guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md) describes the adapter.
+
+| execd value | Sample field |
+| --- | --- |
+| `cpu_used_pct / 100` | CPU utilization ratio |
+| `cpu_count` | CPU capacity |
+| `mem_used_mib`, `mem_total_mib` | Memory usage and limit, in bytes |
+| Sandbox `createdAt` | `started_at` |
+
+execd reports no cumulative CPU time or disk, so CPU seconds and disk stay null. `observed_at` is execd's `timestamp`, with the same 30-second lead rule as E2B. A sandbox that is missing or not running is `runtime_not_running`. A failed or malformed read is `sample_unavailable`.
+
## Read budgets
A current list read handles one page of up to 100 Sessions (default 20) with at most eight concurrent provider reads. Each provider read has two seconds and the whole list request ten; beyond that the list returns 503. A single-Session read has two seconds. No provider call is retried within a request, and Core keeps no observation cache.
@@ -109,7 +122,7 @@ With an OTLP endpoint configured, Core exports every record, both `on_read` and
| --- | --- | --- |
| `agents.runtime.cpu.usage` | Monotonic cumulative sum, seconds | Cumulative CPU counter |
| `agents.runtime.cpu.capacity` | Gauge, cores | Configured CPU capacity |
-| `agents.runtime.cpu.utilization` | Gauge, ratio | Provider-reported CPU share (E2B) |
+| `agents.runtime.cpu.utilization` | Gauge, ratio | Provider-reported CPU share (E2B, OpenSandbox) |
| `agents.runtime.memory.usage` | Gauge, bytes | Memory usage |
| `agents.runtime.memory.limit` | Gauge, bytes | Memory limit |
| `agents.session.tokens.input` | Gauge, tokens | Measured Session input tokens |
diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md
index becba735a..8e5664321 100644
--- a/contracts/agents-api/sandbox-deployment.md
+++ b/contracts/agents-api/sandbox-deployment.md
@@ -34,13 +34,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 `microsandbox`, `e2b` |
-| `resources` | Per-sandbox limits, below; required for microsandbox, optional for E2B |
+| `provider` | Exactly one of `microsandbox`, `e2b`, `opensandbox` |
+| `resources` | Per-sandbox limits, below; required for microsandbox and OpenSandbox, optional for E2B |
| `network` | The egress cap, [below](#network). Omitted on POST means the default; omitted on PUT keeps the current cap |
-| `configuration` | The provider's public selectors. E2B: the immutable `template` build and the optional paired `api_url` and `domain`. microsandbox accepts 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. microsandbox rejects it |
+| `configuration` | The provider's public selectors. E2B: the immutable `template` build and the optional paired `api_url` and `domain`. OpenSandbox: `api_url` and `image`. microsandbox accepts only `{}` or omission |
+| `credential` | The provider's write-only credential. E2B and OpenSandbox: `{api_key}`, required at first setup and omitted on PUT to keep the current key; a null or empty key is invalid. microsandbox rejects it |
-The request has no Core address. Core derives the deployment's `core_url` from the installation public URL, [`OAC_PUBLIC_URL`](../../docs/configuration.md#settings): 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. A microsandbox selection accepts 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; see [Change the public URL](../../docs/getting-started/public-address.md#changing-the-public-url).
+The request has no Core address. Core derives the deployment's `core_url` from the installation public URL, [`OAC_PUBLIC_URL`](../../docs/configuration.md#settings): 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 and OpenSandbox guests from the OpenSandbox server's hosts, so an E2B or OpenSandbox selection is rejected with 409 `sandbox_configuration_error` while the public URL is loopback. A microsandbox selection accepts 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; see [Change the public URL](../../docs/getting-started/public-address.md#changing-the-public-url).
### Resources
@@ -48,16 +48,18 @@ 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; E2B: omitted or zero |
-| `environment_disk_mib` | microsandbox: each new workspace's admitted capacity on workspace storage, where zero (the default) requests no quota and a positive value must be at least 1024 MiB and requires enforced quotas; E2B: omitted or zero |
+| `root_disk_mib` | microsandbox: at least 1024 MiB; E2B and OpenSandbox: omitted or zero |
+| `environment_disk_mib` | microsandbox: each new workspace's admitted capacity on workspace storage, where zero (the default) requests no quota and a positive value must be at least 1024 MiB and requires enforced quotas; E2B and OpenSandbox: omitted or zero |
These limits describe each sandbox. A node's `max_active` and `max_retained` are separate reservation limits, set as [Node capacity](#nodes-and-allocations) describes; no host measurement permits exceeding either once set. Native providers may reject values that pass these bounds.
E2B CPU and memory must equal the exact ready template build, which Core validates through the pinned SDK before saving; disk capacity stays part of the template. E2B accepts no disk quota it cannot enforce. An E2B selection may omit `resources`: Core then stores the build's CPU count and memory as `cpus` and `memory_mib`, returned in `specification.resources` without disk fields. On restart Core loads the committed E2B selection without validating the template build again, so an E2B outage never blocks inspection or cleanup; new selections still require validation.
+OpenSandbox applies `cpus` and `memory_mib` as the sandbox's `cpu` and `memory` resource limits; its runtime sets the disk.
+
microsandbox configures the CPUs, memory and a managed root disk. It runs every Environment on [workspace storage](../../docs/workspace-provider.md) mounted at `/environment`. The [microsandbox helper](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md) describes how restore handles these limits.
-The optional response `specification.workspace` is an immutable capability receipt derived from the selected [workspace filesystem](../../docs/workspace-provider.md), not an independently writable setting. It contains only `attachment`, `user_xattr` and `capacity_quota`; filesystem configuration, paths and credentials stay outside the sandbox specification. Core validates the combined declarations before saving, and retained generation validation and node construction use this receipt. microsandbox always has the receipt, and a deployment saved before workspace storage is selected returns `workspace_storage_required`. E2B has no receipt and keeps its own disks. Create and Resume provide a binding exactly when the frozen generation has the receipt.
+The optional response `specification.workspace` is an immutable capability receipt derived from the selected [workspace filesystem](../../docs/workspace-provider.md), not an independently writable setting. It contains only `attachment`, `user_xattr` and `capacity_quota`; filesystem configuration, paths and credentials stay outside the sandbox specification. Core validates the combined declarations before saving, and retained generation validation and node construction use this receipt. microsandbox always has the receipt, and a deployment saved before workspace storage is selected returns `workspace_storage_required`. E2B and OpenSandbox have no receipt and keep their own disks. Create and Resume provide a binding exactly when the frozen generation has the receipt.
Select the filesystem configuration before the microsandbox deployment. Selecting the filesystem does not alter existing generations or allocations; the next sandbox update derives the receipt. A filesystem selection must support the provider's declared requirements. An existing deployment requires the same `attachment` and `user_xattr`; the selection may change `capacity_quota`, after which new workspaces are refused until a sandbox update sets a matching `environment_disk_mib`. Existing workspaces keep the capacity they were admitted with.
@@ -72,15 +74,19 @@ Select the filesystem configuration before the microsandbox deployment. Selectin
The default is `{"public": true, "domains": []}`. Under every cap, loopback, link-local and cloud-metadata addresses and all other private addresses stay blocked, except that E2B cannot block the metadata address 169.254.169.254 and its sandboxes cannot reach private networks at all. A sandbox keeps the cap of the generation that created it.
-Each Provider declares what it enforces ([Sandbox Provider protocol](../../docs/sandbox-provider.md#network-egress)): microsandbox supports both fields, E2B supports `public: false` but not `domains` because E2B sandboxes cannot reach private networks. A cap the selected Provider does not declare returns 400 `invalid_sandbox_configuration` with `param` `network.public` or `network.domains`.
+Each Provider declares what it enforces ([Sandbox Provider protocol](../../docs/sandbox-provider.md#network-egress)): microsandbox supports both fields, E2B supports `public: false` but not `domains` because E2B sandboxes cannot reach private networks, and OpenSandbox supports `public: false` but not `domains` because its deny rules for private ranges win over a domain's private answers. A cap the selected Provider does not declare returns 400 `invalid_sandbox_configuration` with `param` `network.public` or `network.domains`.
### E2B configuration
E2B uses `configuration.template` in `template-id:build-uuid` form; the build UUID must be canonical and nonzero, and a mutable template alias alone is refused. The API key is encrypted in PostgreSQL and never appears in a response, bootstrap configuration, command argument or log. By default Core uses `https://api.e2b.app` and `e2b.app`. For a compatible service, set both `configuration.api_url` (an HTTPS origin without path, port, query, fragment or credentials) and `configuration.domain` (the sandbox data-plane DNS suffix); the API host must equal the domain or be a subdomain of it. Core rejects a sandbox whose data-plane domain lies outside the selected suffix before sending daemon credentials or using envd.
+### OpenSandbox configuration
+
+OpenSandbox uses `configuration.api_url`, the HTTP or HTTPS origin of the OpenSandbox server without path, query, fragment or credentials, and `configuration.image`, the Runtime image built for OpenSandbox as its [adapter guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md#build-the-runtime-image) describes. The API key is encrypted in PostgreSQL and never appears in a response, bootstrap configuration, command argument or log. Core checks neither the image nor the server when it saves the selection; each Create fails when the image cannot start or the server's egress sidecar does not run in `dns+nft` mode.
+
### 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. microsandbox rejects 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. microsandbox and OpenSandbox reject it with 400 `sandbox_operation_unsupported`.
Post `{"configuration": {…}, "credential": {…}, "query": {"field": "…", "path": […]}}`, where `field` is a field the provider declares as discovered and `path` holds the values selected at the earlier levels. The result is `{"options": [{"value": "…", "label": "…", "resources": {"cpus": 2, "memory_mib": 2048}, "complete": true}]}`, possibly empty. An option with `complete: true` can be saved as the field's value; the others open the next level. Any other field returns 400 `invalid_request_error`.
@@ -88,12 +94,12 @@ E2B declares `template` as discovered. An empty `path` lists the key's templates
## 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`. microsandbox returns 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`. OpenSandbox returns `configuration.api_url`, `configuration.image` and an empty `metadata` object. microsandbox returns 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; `resources.running` counts the unreleased allocations whose compute is running, so it excludes suspended ones.
-- An unconfigured deployment has an empty provider and no specification. microsandbox uses `mode: nodes`; E2B uses `mode: direct`, without a synthetic node.
+- An unconfigured deployment has an empty provider and no specification. microsandbox uses `mode: nodes`; E2B and OpenSandbox use `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 and derived workspace capability receipt; enrollment echoes it unchanged.
@@ -103,7 +109,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: 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: microsandbox nodes prepare the new target independently while serving their old pin, and E2B and OpenSandbox changes apply at once.
### E2B key replacement
@@ -121,6 +127,10 @@ Initial setup requires the selected template to appear in the key's team-owned t
No provider text or credential is returned. The write and its `change` or `replace_credential` audit entry share one transaction. A replacement briefly fences provider calls, waits for helper processes to actually exit even after the caller cancelled, and verifies again before committing; a helper's exit does not prove that a remote Create settled. The fence and reads are bounded, and a failure keeps the old key and lifecycles. After the successful response, all retained-generation management uses the committed key; only then revoke the old key in E2B, never before cleanup. Template and resource changes do not drain lifecycles.
+### OpenSandbox key replacement
+
+The request rules are those of E2B key replacement. An OpenSandbox server has one key, so the check is access: the candidate key must authenticate with the server of every retained generation and list the sandboxes of each unreleased allocation. A 401 or 403 returns 400 `sandbox_credential_invalid`, and any other failure 503 `sandbox_verification_unconfirmed`. Server URL and image changes do not drain lifecycles.
+
## Suspension policy
`PATCH /core/v1/sandbox/deployment` accepts `{"idle_seconds": 300}`. `idle_seconds` is a required integer from 30 to 86400 seconds, defaulting to 300, stored in Core's database. Only configured Providers declaring checkpoint support accept this change; unconfigured, unsupported or resetting deployments reject it. Success returns the deployment view with the saved `suspension.idle_seconds`.
@@ -129,7 +139,7 @@ Changes apply to existing and new running allocations on the next lifecycle pass
## Generation ownership and rollout
-`runtime_deployment` holds the current specification. Superseded rows keep only immutable specification, build and endpoint metadata, never another E2B key. An E2B allocation binds its generation at reservation; a node placement binds when the scheduler reserves compatible capacity, and its allocation copies that generation, even across later updates. Inspection, renewal, commands and cleanup use the allocation's original specification and endpoint with the current key; a missing generation never falls back to the current specification. Released generation identifiers stay reserved.
+`runtime_deployment` holds the current specification. Superseded rows keep only immutable specification, build and endpoint metadata, never another provider key. A direct allocation (E2B, OpenSandbox) binds its generation at reservation; a node placement binds when the scheduler reserves compatible capacity, and its allocation copies that generation, even across later updates. Inspection, renewal, commands and cleanup use the allocation's original specification and endpoint with the current key; a missing generation never falls back to the current specification. Released generation identifiers stay reserved.
A generation is retained while it is current, referenced by an unreleased allocation or placement, or pinned by a node that is not removed. A node's durable serving pin survives offline periods and zero resources and is separate from its current readiness. Collection shares the deployment lock with updates and admission and deletes at most 32 eligible generation rows per pass. Reset retires pins and clears superseded rows only after confirmed release.
@@ -139,7 +149,7 @@ Every deployment response includes `rollout`:
"rollout": {"state": "settled", "previous_generation_sandboxes": 3, "nodes": null}
```
-`previous_generation_sandboxes` counts, from the same snapshot as the resource totals, unreleased allocations and allocation-less pending placements of older generations, never one resource twice. E2B and unconfigured deployments return null `nodes`. A node deployment returns `nodes` as counts `{ready, preparing, failed, unknown}`, and every node that is not removed falls in exactly one bucket:
+`previous_generation_sandboxes` counts, from the same snapshot as the resource totals, unreleased allocations and allocation-less pending placements of older generations, never one resource twice. Direct (E2B, OpenSandbox) and unconfigured deployments return null `nodes`. A node deployment returns `nodes` as counts `{ready, preparing, failed, unknown}`, and every node that is not removed falls in exactly one bucket:
- a node that is offline on its current connection is `unknown`;
- otherwise the exact target observation on the current connection and owner epoch gives `ready`, `preparing` or `failed`, and a missing observation gives `unknown`.
@@ -194,7 +204,7 @@ Starting again at the same generation and mode keeps the original deadline. `aut
"offline_nodes": [{"node_id": "node-uuid", "name": "worker", "resources": 2}]}}
```
-One database snapshot partitions every unreleased allocation and every pending hosted Environment without an allocation into `cleanup` first, then `busy` or `idle`, so `busy + idle + cleanup == resources.allocations + resources.pending`. Deleted or expired Sessions with unreleased receipts count as cleanup. `offline_nodes` is the complete, ID-sorted list of offline nodes that hold resources by allocation or active placement, and its sum equals `on_offline_nodes`; presence is the [online](./node-generation-protocol.md#connection) test, not provider readiness. Direct E2B resources have no node. Offline resources stay blockers until cleanup confirms their release.
+One database snapshot partitions every unreleased allocation and every pending hosted Environment without an allocation into `cleanup` first, then `busy` or `idle`, so `busy + idle + cleanup == resources.allocations + resources.pending`. Deleted or expired Sessions with unreleased receipts count as cleanup. `offline_nodes` is the complete, ID-sorted list of offline nodes that hold resources by allocation or active placement, and its sum equals `on_offline_nodes`; presence is the [online](./node-generation-protocol.md#connection) test, not provider readiness. Direct E2B and OpenSandbox resources have no node. Offline resources stay blockers until cleanup confirms their release.
When both held counts reach zero, the owner drains and atomically clears the provider, mode, specification, provider configuration, credential and metadata and provider policy, retires nodes and unused enrollment tokens, increments the generation and owner epoch, and records `reset_complete`. The installation identity and history remain. Core immediately publishes the unconfigured state and keeps the new generation even with no provider, so a delayed load cannot revive the old one. Configure again with POST and the returned generation; no restart is needed.
@@ -206,7 +216,7 @@ One mutation gate serializes setup, PUT, reset, cancellation and finalization. A
## Nodes and allocations
-Node capacity is set separately from the deployment specification. `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and `max_retained` and returns `{token, expires_at, enrollment_id}`; `enrollment_id` is a public handle of that command, never a credential. Core stores the token with the limits as sent, and an omitted limit stays unset. When the node enrolls, Core copies each explicit limit to it unchanged and sets each unset limit to the [recommended capacity](#recommended-capacity) of the host measurements in the enrollment request and the current specification; an unset limit with no host measurements fails with 409 `sandbox_node_host_unmeasured` and leaves the token unused. When only `max_retained` is set, the derived `max_active` never exceeds it; when only `max_active` is set, the derived `max_retained` is raised to at least `max_active`. microsandbox uses both limits. E2B has no nodes and answers 409 `sandbox_deployment_conflict`. A node cannot submit limits, but an unset limit follows the host measurements the node reports at enrollment, bounded by 1 and 1000000. Its local checks may refuse a deployment it cannot run but never raise the limits. Explicit values and later PATCH edits bind: once a node is enrolled, Core derives nothing again. [Node capacity](../../docs/configuration.md#node-capacity) explains the limits for operators.
+Node capacity is set separately from the deployment specification. `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and `max_retained` and returns `{token, expires_at, enrollment_id}`; `enrollment_id` is a public handle of that command, never a credential. Core stores the token with the limits as sent, and an omitted limit stays unset. When the node enrolls, Core copies each explicit limit to it unchanged and sets each unset limit to the [recommended capacity](#recommended-capacity) of the host measurements in the enrollment request and the current specification; an unset limit with no host measurements fails with 409 `sandbox_node_host_unmeasured` and leaves the token unused. When only `max_retained` is set, the derived `max_active` never exceeds it; when only `max_active` is set, the derived `max_retained` is raised to at least `max_active`. microsandbox uses both limits. E2B and OpenSandbox have no nodes and answer 409 `sandbox_deployment_conflict`. A node cannot submit limits, but an unset limit follows the host measurements the node reports at enrollment, bounded by 1 and 1000000. Its local checks may refuse a deployment it cannot run but never raise the limits. Explicit values and later PATCH edits bind: once a node is enrolled, Core derives nothing again. [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`, the host measurements `cpu_count`, `available_memory_bytes` and `available_disk_bytes`, `rollout`, `recommended_capacity`, `enrollment_id` and `core_url`. `recommended_capacity` is `{max_active, max_retained}`, the [recommended capacity](#recommended-capacity) of the node's latest health and the current specification, or null until the node has reported a CPU count or effective cores and its total memory, and while the current specification does not validate. `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. See [Change the public URL](../../docs/getting-started/public-address.md#changing-the-public-url).
@@ -229,23 +239,23 @@ The recommendation is absent unless the host reports a CPU count or effective co
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 | microsandbox | E2B |
-| --- | --- | --- |
-| Independent workspace filesystem | Required: [Kernel NFS adapter](../../docs/workspace-provider.md#kernel-nfs-adapter), configured separately from compute | Unsupported |
-| Deployment `specification.network` | `public` and `domains` | `public` only; `domains` must be empty and private networks are unreachable |
-| Deployment `specification.resources` | `cpus`, `memory_mib`, `root_disk_mib` and, as a quota request, `environment_disk_mib` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields |
-| Deployment `metadata.template_build` | Absent: `metadata` is empty | The build as Core read it when the selection was saved |
-| Deployment `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`; Core pauses an idle E2B sandbox with the SDK's native pause |
-| Deployment `resources.allocations`, `resources.pending` | Totals across all nodes | Core's unreleased E2B sandboxes, and hosted Environments waiting for one |
-| Enrollment-token `max_active`, `max_retained` | Both limits apply; an omitted limit is derived at enrollment | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes |
-| Node list and detail | Enrolled nodes | Empty list; detail returns 404 |
-| Node `retained`, `snapshots`, `max_retained` | Suspended sandboxes are `retained` minus `active`; `snapshots` also includes retained snapshots during restore or cleanup | Not applicable |
-| Node `host.available_disk_bytes` | Free space on the filesystem of the node state directory; sandbox disks have their own quotas | Not applicable |
-| Allocation `compute_phase`, `compute_phase_changed_at` | Includes `suspended`; the timestamp describes the current phase, not snapshot expiry | Not applicable: no node allocations |
-| Runtime observation `memory` | From the VM: memory limit and usage | From E2B metrics: memory usage and limit |
-| Runtime observation `disk` | `null` | E2B `diskUsed` and `diskTotal`; `null` when the template does not report them |
-| Runtime observation `lifecycle_state: sleeping` | While suspended | Never |
-| Runtime history CPU | Derived from cumulative CPU time | Mean of the utilization ratios E2B reported in each bucket |
+| Field | microsandbox | E2B | OpenSandbox |
+| --- | --- | --- | --- |
+| Independent workspace filesystem | Required: [Kernel NFS adapter](../../docs/workspace-provider.md#kernel-nfs-adapter), configured separately from compute | Unsupported | Unsupported |
+| Deployment `specification.network` | `public` and `domains` | `public` only; `domains` must be empty and private networks are unreachable | `public` only; `domains` must be empty |
+| Deployment `specification.resources` | `cpus`, `memory_mib`, `root_disk_mib` and, as a quota request, `environment_disk_mib` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`, applied as resource limits; no disk fields |
+| Deployment `metadata.template_build` | Absent: `metadata` is empty | The build as Core read it when the selection was saved | Absent: `metadata` is empty |
+| Deployment `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`; Core pauses an idle E2B sandbox with the SDK's native pause | `null`: no checkpoint support, so an idle sandbox keeps running |
+| Deployment `resources.allocations`, `resources.pending` | Totals across all nodes | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Core's unreleased OpenSandbox sandboxes, and hosted Environments waiting for one |
+| Enrollment-token `max_active`, `max_retained` | Both limits apply; an omitted limit is derived at enrollment | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; OpenSandbox has no nodes |
+| Node list and detail | Enrolled nodes | Empty list; detail returns 404 | Empty list; detail returns 404 |
+| Node `retained`, `snapshots`, `max_retained` | Suspended sandboxes are `retained` minus `active`; `snapshots` also includes retained snapshots during restore or cleanup | Not applicable | Not applicable |
+| Node `host.available_disk_bytes` | Free space on the filesystem of the node state directory; sandbox disks have their own quotas | Not applicable | Not applicable |
+| Allocation `compute_phase`, `compute_phase_changed_at` | Includes `suspended`; the timestamp describes the current phase, not snapshot expiry | Not applicable: no node allocations | Not applicable: no node allocations |
+| Runtime observation `memory` | From the VM: memory limit and usage | From E2B metrics: memory usage and limit | From execd metrics: memory usage and limit |
+| Runtime observation `disk` | `null` | E2B `diskUsed` and `diskTotal`; `null` when the template does not report them | `null` |
+| Runtime observation `lifecycle_state: sleeping` | While suspended | Never | Never |
+| Runtime history CPU | Derived from cumulative CPU time | Mean of the utilization ratios E2B reported in each bucket | Mean of the utilization ratios execd reported in each bucket |
## Errors
diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py
index d030c901a..8c57a0522 100644
--- a/deploy/node/node_spec.py
+++ b/deploy/node/node_spec.py
@@ -24,7 +24,7 @@ class UnrecordedIdentity(SpecificationError):
# 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}],\"network\":{\"max_domains\":100,\"max_domain_length\":253,\"domain_pattern\":\"^(?:\\\\*\\\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$\"},\"providers\":{\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"default_resources\":null,\"network\":{\"restrict\":true,\"private_domains\":false}},\"microsandbox\":{\"mode\":\"nodes\",\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true},\"disk\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192},\"network\":{\"restrict\":true,\"private_domains\":true}}},\"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}],\"network\":{\"max_domains\":100,\"max_domain_length\":253,\"domain_pattern\":\"^(?:\\\\*\\\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$\"},\"providers\":{\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"default_resources\":null,\"network\":{\"restrict\":true,\"private_domains\":false}},\"microsandbox\":{\"mode\":\"nodes\",\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true},\"disk\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192},\"network\":{\"restrict\":true,\"private_domains\":true}},\"opensandbox\":{\"mode\":\"direct\",\"disk\":false,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096},\"network\":{\"restrict\":true,\"private_domains\":false}}},\"minimum_disk\":1024}")
# END GENERATED DEPLOYMENT CONTRACT
diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md
index eea9b7267..945d141ce 100644
--- a/docs/api/public-agent-api.md
+++ b/docs/api/public-agent-api.md
@@ -257,7 +257,7 @@ Returns 201 with the Session:
| `environment.type` | Runs on | Notes |
| --- | --- | --- |
-| `openai_hosted` | A sandbox Core creates on a node or E2B; the administrator provides the capacity | Optional `network`, `packages`, `files`, `skills`, `plugins`, `env`, `capability_directories`, `setup_commands`, or a template |
+| `openai_hosted` | A sandbox Core creates on a node, E2B or OpenSandbox; the administrator provides the capacity | Optional `network`, `packages`, `files`, `skills`, `plugins`, `env`, `capability_directories`, `setup_commands`, or a template |
| `self_hosted` | Your own Linux, macOS or Windows machine | Requires an absolute `workspace_directory`. Skills, packages, files or a template go in `x_agents_core.environment`. The response carries install commands in `x_agents_core.installation`; see [Run self-hosted Sessions](../getting-started/self-hosted.md). The Session brings its own `model_provider` |
| `none` | A device connection an operator registered, with no workspace | `input` required. The model comes from the installation default, or from the device when no default is configured |
diff --git a/docs/concepts.md b/docs/concepts.md
index e83e2215f..7097026f3 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -26,7 +26,7 @@ Creating or editing application assets, starting Sessions and submitting input r
The Runtime daemon runs on Linux, macOS and Windows. Native platform behavior belongs to the Runtime and its Harness adapters; managed Sandbox Providers run Linux environments.
-Tools run with the permissions of the account that launches the daemon. The daemon adds no filesystem, permission or network isolation. Use the outer Environment for isolation: a managed E2B or microsandbox environment, or a container or VM around a self-hosted machine's Runtime. Authentication, private storage, locks and process cleanup protect the connection and lifecycle, but tools running as the same user can access Runtime data.
+Tools run with the permissions of the account that launches the daemon. The daemon adds no filesystem, permission or network isolation. Use the outer Environment for isolation: a managed microsandbox, E2B or OpenSandbox environment, or a container or VM around a self-hosted machine's Runtime. Authentication, private storage, locks and process cleanup protect the connection and lifecycle, but tools running as the same user can access Runtime data.
## Secrets and audit
diff --git a/docs/configuration.md b/docs/configuration.md
index 5acb12ec2..20d7662fc 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -55,8 +55,8 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa
| Setting | Where in Web | Core API | Notes |
| --- | --- | --- | --- |
-| Sandbox backend: microsandbox or E2B | **Settings** → **Sandbox and scheduling**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation, chosen after the first sign-in. Another backend needs **Reset deployment** first; see [change the sandbox configuration](./getting-started/nodes.md#change-the-sandbox-configuration) |
-| Sandbox size, E2B key and template build | **Settings** → **Sandbox and scheduling** → **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. The E2B key is write-only and encrypted |
+| Sandbox backend: microsandbox, E2B or OpenSandbox | **Settings** → **Sandbox and scheduling**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation, chosen after the first sign-in. Another backend needs **Reset deployment** first; see [change the sandbox configuration](./getting-started/nodes.md#change-the-sandbox-configuration) |
+| Sandbox size and the E2B or OpenSandbox connection | **Settings** → **Sandbox and scheduling** → **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. The E2B and OpenSandbox keys are write-only and encrypted |
| Nodes and their capacity | **Nodes**: **Add node**, **Edit node** and removal | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and [Add and manage nodes](./getting-started/nodes.md) |
| Sandbox network cap | **Settings** → **Sandbox and scheduling** → **Network**: **Allow public internet**, **Extra allowed domains** → **Save** | `/core/v1/sandbox/deployment` | `network.public` and `network.domains`; see [Network](../contracts/agents-api/sandbox-deployment.md#network). Existing sandboxes keep the cap they started with. A control the Provider does not support is disabled |
| Idle suspension | **Settings** → **Sandbox and scheduling** → **Automatic suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | Deployment-wide; see [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) |
diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md
index 2f865495a..2371f847b 100644
--- a/docs/getting-started/install.md
+++ b/docs/getting-started/install.md
@@ -76,7 +76,7 @@ To move to a newer release, reinstall: [uninstall](./operations.md#uninstall) th
## Add sandbox capacity
-The installer selects no sandbox backend. [Choose a sandbox backend](./sandbox-backends.md): microsandbox on your own Linux nodes with workspace storage from [Set up workspace storage](./workspace-storage.md) is the self-managed deployment, and E2B needs no nodes. Applications can also run [self-hosted Sessions](./self-hosted.md) on their own machines.
+The installer selects no sandbox backend. [Choose a sandbox backend](./sandbox-backends.md): microsandbox on your own Linux nodes with workspace storage from [Set up workspace storage](./workspace-storage.md) is the self-managed deployment, and E2B and OpenSandbox need no nodes. Applications can also run [self-hosted Sessions](./self-hosted.md) on their own machines.
## Set a default model
diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md
index 80cb1e19e..5cb186e6d 100644
--- a/docs/getting-started/nodes.md
+++ b/docs/getting-started/nodes.md
@@ -96,7 +96,7 @@ Run `sudo journalctl -u oac-node-.service`. In a root shell, om
Sandbox settings apply to the whole installation; nodes follow them.
-- **Size, E2B key or template build.** Open **Settings** → **Sandbox and scheduling** → **Change resources**, edit and save. Existing sandboxes keep their configuration. Each node prepares the new one while it keeps serving the old one, and **Nodes** shows its progress: **Preparing target**, **Ready for target** or **Preparation failed** with the [reason](#readiness-codes). Core places new Sessions on nodes ready for the new configuration first, and on nodes still serving an older one when those have no room. E2B changes apply at once.
+- **Size, or the E2B or OpenSandbox connection.** Open **Settings** → **Sandbox and scheduling** → **Change resources**, edit and save. Existing sandboxes keep their configuration. Each node prepares the new one while it keeps serving the old one, and **Nodes** shows its progress: **Preparing target**, **Ready for target** or **Preparation failed** with the [reason](#readiness-codes). Core places new Sessions on nodes ready for the new configuration first, and on nodes still serving an older one when those have no room. E2B and OpenSandbox changes apply at once.
- **Capacity and idle suspension.** On **Nodes**, use **Edit node** for a node's active and retained limits, or **Use recommended** to fill in the recommendation. In **Settings** → **Sandbox and scheduling**, use **Automatic suspension** → **Idle before suspension (seconds)** → **Save** for the deployment-wide [idle policy](../../contracts/agents-api/sandbox-deployment.md#suspension-policy).
- **Backend.** On the same page, choose **Reset deployment**. Auto reset archives idle hosted Sessions at once and lets running work finish until the deadline you set; force cancels it now. Offline nodes must come back so Core can confirm their cleanup. When the reset completes, Core has retired every node and unused command: set up the new backend, then add nodes again. **Cancel reset** stops the remaining work; archived Sessions stay archived.
diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md
index 21caee1ec..61c66d3a5 100644
--- a/docs/getting-started/operations.md
+++ b/docs/getting-started/operations.md
@@ -207,7 +207,7 @@ On Windows, run `& "$HOME/.oac/core/oac.exe" uninstall`. The command shows the C
All data goes with it: Projects and API keys, Session history, stored credentials and the Core key. To keep the data, stop the installation with `docker compose stop` instead, or [back it up](#back-up) first.
-Uninstall stops no sandbox: node sandboxes keep running on their nodes, and E2B sandboxes keep running, and billing, at E2B. While Core is still up, archive their Sessions or [reset the deployment](./nodes.md#change-the-sandbox-configuration) and let it complete.
+Uninstall stops no sandbox: node sandboxes keep running on their nodes, E2B sandboxes keep running, and billing, at E2B, and OpenSandbox sandboxes keep running until their one-hour lease ends. While Core is still up, archive their Sessions or [reset the deployment](./nodes.md#change-the-sandbox-configuration) and let it complete.
If Core is already gone, run the node uninstall command with `--force` on each node host, as described there, using the installation ID that `oac uninstall` printed or the one in the node's unit name `oac-node-.service`.
diff --git a/docs/getting-started/public-address.md b/docs/getting-started/public-address.md
index de37ced82..7ce8c28dd 100644
--- a/docs/getting-started/public-address.md
+++ b/docs/getting-started/public-address.md
@@ -14,6 +14,7 @@ Set the one origin that browsers, nodes, sandboxes and applications use to reach
| Local network | `http://:8080`, which the installer picks when `--host` is `0.0.0.0` and the default route has a private-network address |
| Beyond the local network | The HTTPS origin your reverse proxy serves, for example `https://core.example` |
| E2B backend | An HTTPS origin reachable from the internet and not loopback, because E2B's sandboxes call Core from E2B's cloud |
+| OpenSandbox backend | An origin that the OpenSandbox server's sandboxes reach and that is not loopback |
Pass the origin as `--public-url` when you install, or change it later as described in [Change the public URL](#changing-the-public-url). To install with a flag, see [Installation options](./install-options.md#pass-flags).
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index d20363f93..2a1c891b5 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -7,7 +7,7 @@ Run an agent through Core with the official OpenAI Python SDK: connect with a Pr
Before you start, have:
- a Project API key and the API base URL ([Issue a Project API key](./install.md#issue-a-project-api-key));
-- a ready node or E2B backend, so Core has somewhere to run the agent ([Add and manage nodes](./nodes.md));
+- a ready node, or an E2B or OpenSandbox backend, so Core has somewhere to run the agent ([Add and manage nodes](./nodes.md));
- Python 3.9 or newer;
- a model: the installation's default model, or your own provider's model ID, base URL and API key. [Model execution](../../contracts/agents-api/model-execution.md#saved-defaults-and-precedence) says which one a Session uses and which protocols each Harness speaks.
@@ -52,7 +52,7 @@ An empty list (`[]`) means you are connected.
## 2. Start a Session
-A Session is one agent conversation with its own workspace. With `openai_hosted`, Core creates a sandbox for it on a node or E2B and starts the Harness inside.
+A Session is one agent conversation with its own workspace. With `openai_hosted`, Core creates a sandbox for it on a node, E2B or OpenSandbox and starts the Harness inside.
To use your own provider instead of the installation's default model, export its settings and run the second tab:
diff --git a/docs/getting-started/sandbox-backends.md b/docs/getting-started/sandbox-backends.md
index 1a1b02f04..00f065246 100644
--- a/docs/getting-started/sandbox-backends.md
+++ b/docs/getting-started/sandbox-backends.md
@@ -2,30 +2,30 @@
title: "Choose a sandbox backend"
---
-Compare microsandbox and E2B, then select one in Web. This guide is for operators who finished [Install Core and Web](./install.md) and need compute for Core-hosted Sessions.
+Compare microsandbox, E2B and OpenSandbox, then select one in Web. This guide is for operators who finished [Install Core and Web](./install.md) and need compute for Core-hosted Sessions.
The installer selects no backend. One installation uses one backend. To change it later, reset the deployment first; see [Change the sandbox configuration](./nodes.md#change-the-sandbox-configuration).
## Compare the backends
-| | microsandbox | E2B |
-| --- | --- | --- |
-| Where sandboxes run | A microVM on your Linux nodes | E2B's cloud |
-| Nodes to add | Yes | None |
-| You provide | Linux amd64 hosts with systemd and `/dev/kvm` | An E2B API key and a template; a public HTTPS URL for Core |
-| Isolation | Each sandbox has its own kernel | E2B's |
-| Idle sandboxes suspend and resume | Yes, with checkpoints | Yes, paused in place by E2B |
-| Workspace storage | Required, on NFS | Own disk |
-| Disk limits | Root disk and workspace quota | Set by the template |
+| | microsandbox | E2B | OpenSandbox |
+| --- | --- | --- | --- |
+| Where sandboxes run | A microVM on your Linux nodes | E2B's cloud | Your OpenSandbox server, on Docker or Kubernetes |
+| Nodes to add | Yes | None | None |
+| You provide | Linux amd64 hosts with systemd and `/dev/kvm` | An E2B API key and a template; a public HTTPS URL for Core | The server's URL and API key and a Runtime image built for it; a Core address its sandboxes reach |
+| Isolation | Each sandbox has its own kernel | E2B's | OpenSandbox's runtime: containers, or a secure container runtime |
+| Idle sandboxes suspend and resume | Yes, with checkpoints | Yes, paused in place by E2B | No; they keep running |
+| Workspace storage | Required, on NFS | Own disk | Own disk |
+| Disk limits | Root disk and workspace quota | Set by the template | Set by OpenSandbox's runtime |
The field-level differences are in the [deployment contract](../../contracts/agents-api/sandbox-deployment.md#what-each-field-means-per-sandbox-provider). Host requirements for nodes are in [Add and manage nodes](./nodes.md#host-requirements).
-Sandbox nodes run on Linux amd64. When Core runs on macOS or Windows, connect a Linux node or use E2B. Applications can also run [self-hosted Sessions](./self-hosted.md) on their own machines, with no backend.
+Sandbox nodes run on Linux amd64. When Core runs on macOS or Windows, connect a Linux node or use E2B or OpenSandbox. Applications can also run [self-hosted Sessions](./self-hosted.md) on their own machines, with no backend.
## Select the backend in Web
-1. Open **Settings → Sandbox and scheduling**. The setup wizard asks **Where should sandboxes run?**: **Own machines** (microsandbox) or **E2B**.
-2. For **Own machines**, choose the sandbox size. Web proposes the [default size the Provider declares](../sandbox-provider.md#register-the-provider-kind) as **Standard**, and **Advanced settings** on the review page holds the exact CPU, memory and disk values. E2B declares no default size, so its review page has no **Advanced settings**.
+1. Open **Settings → Sandbox and scheduling**. The setup wizard asks **Where should sandboxes run?**: **Own machines** (microsandbox), **E2B** or **OpenSandbox**.
+2. For **Own machines**, choose the sandbox size. Web proposes the [default size the Provider declares](../sandbox-provider.md#register-the-provider-kind) as **Standard**, and **Advanced settings** on the review page holds the exact CPU, memory and disk values. E2B declares no default size, so its review page has no **Advanced settings**; OpenSandbox's review page proposes 2 CPUs and 4 GiB of memory and has **Advanced settings**.
3. Choose **Save configuration**. The choice is stored in Core's database.
For **Own machines**, continue with [Add and manage nodes](./nodes.md). For microsandbox, first [Set up workspace storage](./workspace-storage.md). For checkpoint recovery across microsandbox nodes, see [Checkpoint recovery across nodes](./nodes.md#checkpoint-recovery-across-nodes).
@@ -42,6 +42,16 @@ When you edit a saved E2B configuration, leave the **API key** blank to keep the
The key and template rules, including key replacement, are in [Initial setup and same-provider changes](../../contracts/agents-api/sandbox-deployment.md#initial-setup-and-same-provider-changes).
+## Use OpenSandbox
+
+OpenSandbox needs no nodes. Its sandboxes call Core's public address, which must not be loopback; see [Configure the public address](./public-address.md).
+
+1. Prepare the server and build the Runtime image as the [OpenSandbox adapter guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md) describes.
+2. In the setup wizard, choose **OpenSandbox**. Under **Connect OpenSandbox**, enter the **Server URL**, the **Runtime image** and the **API key**.
+3. On **Review and save**, keep the proposed size or change it under **Advanced settings**, then choose **Save configuration**. Core stores the key encrypted and never returns it.
+
+Core does not contact the server when it saves. A wrong URL, key or image shows up when the first Session's sandbox is created. When you edit a saved OpenSandbox configuration, leave the **API key** blank to keep the saved key.
+
## Next steps
- microsandbox: [Add and manage nodes](./nodes.md).
diff --git a/docs/getting-started/workspace-storage.md b/docs/getting-started/workspace-storage.md
index f00e6f6ef..6a6aaf058 100644
--- a/docs/getting-started/workspace-storage.md
+++ b/docs/getting-started/workspace-storage.md
@@ -4,7 +4,7 @@ title: "Set up workspace storage"
Keep Session workspaces on an NFS filesystem outside the sandbox nodes, with or without per-workspace quotas. microsandbox runs every Environment on this storage, so select it before you configure the microsandbox backend.
-The supported combination is microsandbox with the [kernel NFS adapter](../workspace-provider.md#kernel-nfs-adapter). E2B keeps its own disks and does not support it; see [Choose a sandbox backend](./sandbox-backends.md).
+The supported combination is microsandbox with the [kernel NFS adapter](../workspace-provider.md#kernel-nfs-adapter). E2B and OpenSandbox keep their own disks and do not support it; see [Choose a sandbox backend](./sandbox-backends.md).
```mermaid
flowchart LR
diff --git a/docs/runtime-bootstrap.md b/docs/runtime-bootstrap.md
index 68884f5f2..d6c94e404 100644
--- a/docs/runtime-bootstrap.md
+++ b/docs/runtime-bootstrap.md
@@ -74,6 +74,7 @@ When `suspend_control` is true, the daemon publishes its suspend control at `run
| Provider | Delivery |
| --- | --- |
| microsandbox, E2B | Deliver the file before launching the same command. |
+| OpenSandbox | Uploads the file through execd, then launches the same command. |
## Verification
diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md
index afff50e4f..70f6c9612 100644
--- a/docs/sandbox-provider.md
+++ b/docs/sandbox-provider.md
@@ -2,12 +2,12 @@
title: "Sandbox Provider protocol"
---
-The Sandbox Provider protocol connects Core to the adapter that supplies compute for Core-managed (`openai_hosted`) Environments and runs the bounded bootstrap that starts the Runtime daemon in it. This page is for adapter authors: it says what a Provider implements and what it may rely on Core to do. microsandbox and E2B implement it.
+The Sandbox Provider protocol connects Core to the adapter that supplies compute for Core-managed (`openai_hosted`) Environments and runs the bounded bootstrap that starts the Runtime daemon in it. This page is for adapter authors: it says what a Provider implements and what it may rely on Core to do. microsandbox, E2B and OpenSandbox implement it.
| | |
| --- | --- |
| Caller | Core's runtime manager and execution Worker |
-| Implementations | microsandbox (node adapter) and E2B (direct adapter); see [Reference adapters](#reference-adapters) |
+| Implementations | microsandbox (node adapter), E2B and OpenSandbox (direct adapters); see [Reference adapters](#reference-adapters) |
| Code | [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go) in `sandbox_provider.go`, with the setup-time `ConfigurationAdapter` |
| Transport | A direct adapter is called in process. A node adapter runs on a sandbox node and receives the same calls over the [sandbox node protocol](../contracts/agents-api/node-generation-protocol.md#provider-requests) |
| Version rule | The in-process interface has no wire version. The sandbox node protocol and each helper protocol require an exact version match |
@@ -154,7 +154,7 @@ Preview and persistence use `providers.Normalize` and `providers.Describe`. `pro
A direct adapter with a credential verifies all retained generations and allocation references before a key is replaced. The common `sandbox.CallFence` excludes native calls and waits for helper completion, including calls whose callers timed out; execution invokes the prepared verification and fencing callbacks without branching on a vendor.
-Vendor deployment validation and SDK setup stay at the construction boundary, and construction never creates an Environment. For node-local adapters `sandbox.Built` returns the provider, probe, installation identity, backend fingerprint and specification digest, and a `Quiescent` check when a helper can outlive its caller; generation collection waits for it. The factory also returns its close function. `execution.RuntimeProvider` binds the adapter to its kind, installation ID, backend fingerprint, generation, mode and node ownership; the database owns the selection, and the in-memory copy is never another authority. microsandbox runs on nodes, and E2B is constructed directly. The node proxy exposes checkpoint operations only for a backend whose registered declaration supports them, and common lifecycle code admits suspension through the checkpoint declaration, never through a provider name.
+Vendor deployment validation and SDK setup stay at the construction boundary, and construction never creates an Environment. For node-local adapters `sandbox.Built` returns the provider, probe, installation identity, backend fingerprint and specification digest, and a `Quiescent` check when a helper can outlive its caller; generation collection waits for it. The factory also returns its close function. `execution.RuntimeProvider` binds the adapter to its kind, installation ID, backend fingerprint, generation, mode and node ownership; the database owns the selection, and the in-memory copy is never another authority. microsandbox runs on nodes, and E2B and OpenSandbox are constructed directly. The node proxy exposes checkpoint operations only for a backend whose registered declaration supports them, and common lifecycle code admits suspension through the checkpoint declaration, never through a provider name.
The backend fingerprint identifies a native resource namespace, not capacity. Core keeps deployment generations so that owned allocations keep resolving to their original backend; never repoint retained allocations at a replacement backend.
@@ -169,7 +169,7 @@ The launcher supplies `sandbox.ProcessPaths` from the [derived process environme
An adapter implements none of this, but it relies on it. The code-level mechanics are in [Core implementation constraints](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/IMPLEMENTATION.md#hosted-sandboxes).
- Core commits the allocation, its daemon credential digest and the Session binding before it calls `Create`. Only a fresh allocation receipt permits `Create`; retries and a Core restart observe the same `Reference` and never replay `Create` or rotate the credential.
-- Each node has one serial lifecycle worker, so calls for one node never run concurrently; E2B allocations share one worker. A stuck Provider on one node never stalls another.
+- Each node has one serial lifecycle worker, so calls for one node never run concurrently; a direct Provider's allocations share one worker. A stuck Provider on one node never stalls another.
- Core never infers bootstrap completion from an engine or provider name; the adapter qualifies it through `Info.BootstrapComplete`.
- Core calls `Kill` only after it has revoked the device's authority, settled pending input and requested cancellation. Temporary provider outages, unknown `Create` results and stopped compute never prove a permanent failure.
- Core releases an allocation only after confirmed cleanup of its compute and volumes and proof that the original `Create` settled. Until then it keeps cleanup ownership, also after an absence observation, and bounded scans continue to catch late resources without another `Create`. This applies after public Session deletion too.
@@ -206,6 +206,8 @@ The Provider enforces exactly this on top of the always-allowed connection to Co
microsandbox applies the rules with first-match order and default deny, on create and restore alike, with strict hostname enforcement and DNS rebind protection off. The sandbox reaches only the host's DNS server: DNS to any other server and every other host port are refused, so only the host's answers bind names to addresses. That server answers every name whatever the policy, so a refused name still resolves and only its connections fail. A name becomes a port-less domain allow for the addresses those answers resolve, private ones included, and a `*.example.com` entry a subdomain allow that never matches `example.com`. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all IPv4 and IPv6 traffic except the Core host, `Endpoints` and `Domains`. E2B matches a host name only on ports 80 and 443, so the adapter also allows the global IPv4 addresses of the Core host and of each Endpoint on any other port. An IP literal is allowed only as a global address range of one address, and a private literal gets no rule.
+OpenSandbox also declares `Restrict` only and requires its egress sidecar in `dns+nft` mode ([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)), whose deny rules win over every allow. `Public` therefore allows by default and denies the private, shared and link-local ranges with the Core host's addresses cut out, as its host resolves them at Create; for the same reason it cannot honor `PrivateDomains`. Not `Public` denies by default and allows the Core host, `Endpoints` and `Domains`. A name becomes a domain rule, which OpenSandbox applies to every port of the addresses its answers resolve, private ones included, and a `*.example.com` entry matches only subdomains. An IP literal is allowed on its port: Core's always, an Endpoint's only when it is a global address. Loopback stays open because inside the sandbox it is the sandbox's own network namespace.
+
## Checkpoint lifecycle
A Provider that declares the checkpoint operations (`Initial`, `NewCompute`, `GetCompute`, `RenewCompute`, `Suspend`, `Resume`, `ResumeCompute`, `KillCompute`, `DeleteSnapshot`, `RunCommandCompute`) lets Core suspend an idle allocation to a snapshot and restore it on the same or another compatible node. Core records the allocation's `compute_phase`; it is `disabled` for a Provider that does not declare checkpoint support, and the other phases move as follows. Deletion, archive, reset and retention expiry release the allocation from any phase after the cleanup described in [What Core guarantees to a Provider](#what-core-guarantees-to-a-provider).
@@ -325,3 +327,4 @@ Native acceptance proves what fixtures cannot: creation, lease behavior, owned p
| --- | --- | --- | --- |
| 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) | [Add and manage 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) |
+| OpenSandbox (direct) | [`sandbox/opensandbox`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/opensandbox) | [`deploy/opensandbox`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md) | [Choose a sandbox backend](./getting-started/sandbox-backends.md#use-opensandbox) |
diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md
index d30022387..da477ddc7 100644
--- a/docs/web/console-api-usage.md
+++ b/docs/web/console-api-usage.md
@@ -107,7 +107,7 @@ The list carries each Harness's configuration, so the console does not read `GET
| Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion, and a Clean up the host dialog then gives the host's uninstall command (requiring root or sudo; for a node enrolled with another address than the deployment's, also with `--force`, which skips the installer's confirmation with Core) |
| Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) |
-An E2B deployment has no nodes; its API key is write-only. Overview and Sandbox metrics count its running and starting sandboxes from the deployment's `resources.running` and `resources.pending`, while the hosted Runtime rows come from Runtime observations. The two sources refresh independently, so the console does not infer retention or cleanup from their difference.
+An E2B or OpenSandbox deployment has no nodes; its API key is write-only. Overview and Sandbox metrics count its running and starting sandboxes from the deployment's `resources.running` and `resources.pending`, while the hosted Runtime rows come from Runtime observations. The two sources refresh independently, so the console does not infer retention or cleanup from their difference.
## Writes
diff --git a/docs/web/index.md b/docs/web/index.md
index 2ab13806e..824ce2db5 100644
--- a/docs/web/index.md
+++ b/docs/web/index.md
@@ -35,7 +35,7 @@ Missing data is shown as missing (—), never as zero. [Console API usage](./con
| Task | Where |
| --- | --- |
-| Choose the sandbox backend (microsandbox or E2B), the sandbox size and Runtime, or reset the backend | **Settings → Sandbox and scheduling**; see [change the sandbox configuration](../getting-started/nodes.md#change-the-sandbox-configuration) |
+| Choose the sandbox backend (microsandbox, E2B or OpenSandbox), the sandbox size and Runtime, or reset the backend | **Settings → Sandbox and scheduling**; see [change the sandbox configuration](../getting-started/nodes.md#change-the-sandbox-configuration) |
| Add, resize or remove execution nodes | **Nodes**: **Add node**, **Edit node** and removal; see [Add and manage nodes](../getting-started/nodes.md) |
| Set the default model of a Harness | **Settings → Models and credentials**; see [default models](../configuration.md#default-models) |
| Create a Project and issue its API keys | **Projects and keys**; see [Projects and API keys](../getting-started/operations.md#projects-and-api-keys) |
diff --git a/docs/workspace-provider.md b/docs/workspace-provider.md
index 6d79cd950..67f4ff553 100644
--- a/docs/workspace-provider.md
+++ b/docs/workspace-provider.md
@@ -17,6 +17,7 @@ Core selects and validates the combination of a Sandbox Provider and a filesyste
| --- | --- |
| microsandbox | Required (`host_directory`, `user_xattr`) |
| E2B | Not supported; the Provider's own Environment disk |
+| OpenSandbox | Not supported; the Provider's own Environment disk |
## Implement an adapter
diff --git a/go.mod b/go.mod
index b1a3b0e34..4bb14469b 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/MiniMax-AI/OpenAgentCore
go 1.26.8
require (
+ github.com/alibaba/OpenSandbox/sdks/sandbox/go v1.2.0
github.com/go-chi/chi/v5 v5.3.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
diff --git a/go.sum b/go.sum
index 60f18aee9..268c01244 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/alibaba/OpenSandbox/sdks/sandbox/go v1.2.0 h1:v7RtBCO4cO2Xc60wcxhtjRJSjeZbTn2Bm8ql2GHXrpI=
+github.com/alibaba/OpenSandbox/sdks/sandbox/go v1.2.0/go.mod h1:c3C+dMDq0EdnZAgui4ot4hiZhQKUb57/tK97TQO5aHk=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts
index aa20bcd37..2fe61858a 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}],"network":{"max_domains":100,"max_domain_length":253,"domain_pattern":"^(?:\\*\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$"},"providers":{"e2b":{"mode":"direct","disk":false,"default_resources":null,"network":{"restrict":true,"private_domains":false},"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"network":{"restrict":true,"private_domains":true},"setup":{"label":"microsandbox","summary":{"en":"Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}}},"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}],"network":{"max_domains":100,"max_domain_length":253,"domain_pattern":"^(?:\\*\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$"},"providers":{"e2b":{"mode":"direct","disk":false,"default_resources":null,"network":{"restrict":true,"private_domains":false},"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"network":{"restrict":true,"private_domains":true},"setup":{"label":"microsandbox","summary":{"en":"Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}},"opensandbox":{"mode":"direct","disk":false,"default_resources":{"cpus":2,"memory_mib":4096},"network":{"restrict":true,"private_domains":false},"setup":{"label":"OpenSandbox","summary":{"en":"Runs sandboxes on your own OpenSandbox server, on Docker or Kubernetes.","zh-CN":"沙箱运行在你自己的 OpenSandbox 服务上,支持 Docker 或 Kubernetes。"},"recommended":false,"fields":[{"name":"api_url","secret":false,"optional":false,"discovered":false,"label":{"en":"Server URL","zh-CN":"服务地址"},"help":{"en":"","zh-CN":""}},{"name":"image","secret":false,"optional":false,"discovered":false,"label":{"en":"Runtime image","zh-CN":"Runtime 镜像"},"help":{"en":"Build it from the OpenAgentCore Runtime image with deploy/opensandbox/Dockerfile.","zh-CN":"用 deploy/opensandbox/Dockerfile 从 OpenAgentCore Runtime 镜像构建。"}},{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}}],"presets":[],"credential":"required","public_origin":"required","discovery":false}}},"minimum_disk":1024} as const;
diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md
index ea8f437b1..cb54c39ea 100644
--- a/services/core/IMPLEMENTATION.md
+++ b/services/core/IMPLEMENTATION.md
@@ -140,7 +140,7 @@ Provider input validation uses the adapter rules in `internal/harnessconfig`: on
- Credential deletion is one mutation checked by tenant, Vault and ID; Vault deletion removes the parent and its Credentials through the foreign-key cascade in one SQL statement, without decrypting, needing the key or calling providers.
- `credentialcrypto` ciphertext is a format version byte followed by the standard AEAD nonce, ciphertext and tag. The authenticated data holds a fixed domain and version plus the binding (tenant, Vault, Credential, auth type, exact destination). Keep the domain string unchanged: existing rows must still decrypt.
-- Random-nonce GCM allows at most 2^32 encryptions per key. `secrets/core/credential.key` also seals model providers, the E2B key, Skills, initial files and environment setup, so every sealed write counts toward that bound; there is no rotation or re-encryption path.
+- Random-nonce GCM allows at most 2^32 encryptions per key. `secrets/core/credential.key` also seals model providers, the E2B and OpenSandbox keys, Skills, initial files and environment setup, so every sealed write counts toward that bound; there is no rotation or re-encryption path.
- OAuth dispatch refresh holds the Credential row lock and the external exchange under one 20-second context (`vaults.oauthRefreshTimeout`). The refresh HTTP client has a 10-second overall timeout and 5-second TLS handshake and response-header timeouts, uses no proxy and treats any redirect as failure.
## MCP
@@ -210,7 +210,7 @@ The check between Turns changes nothing and never revives a cleanup request. Ter
### Per-node lifecycle workers
-Each registered node has one serial lifecycle worker that owns its gate, allocation and pending cursors, connections and wake hints; E2B allocations share one serial lifecycle without a node. A thin coordinator discovers nodes and shuts workers down, and never holds its map mutex during database, provider or wait operations. Offline workers stay, so their retained resources remain observable after reconnection.
+Each registered node has one serial lifecycle worker that owns its gate, allocation and pending cursors, connections and wake hints; a direct Provider's allocations (E2B, OpenSandbox) share one serial lifecycle without a node. A thin coordinator discovers nodes and shuts workers down, and never holds its map mutex during database, provider or wait operations. Offline workers stay, so their retained resources remain observable after reconnection.
Allocation scans filter by node before their 32-row page limit, and pending scans join the unreleased committed placement. Each node advances its own cursor, including past failed observations, and wraps once at the end. Direct provisioning resolves the tenant-scoped placement before entering that node's gate, and an active allocation must agree with it.
diff --git a/services/core/deploy/opensandbox/Dockerfile b/services/core/deploy/opensandbox/Dockerfile
new file mode 100644
index 000000000..b090afd79
--- /dev/null
+++ b/services/core/deploy/opensandbox/Dockerfile
@@ -0,0 +1,8 @@
+# The OpenAgentCore Runtime image for OpenSandbox: OpenSandbox runs the image's
+# user, so the Runtime layout is prepared here and the sandbox needs no root.
+ARG RUNTIME_IMAGE
+FROM ${RUNTIME_IMAGE}
+USER root
+RUN install -d -o 1000 -g 1000 -m 0700 /home/runtime /environment /environment/workspace \
+ && rmdir /workspace && ln -s /environment/workspace /workspace
+USER 1000:1000
diff --git a/services/core/deploy/opensandbox/README.md b/services/core/deploy/opensandbox/README.md
new file mode 100644
index 000000000..6c5d566bb
--- /dev/null
+++ b/services/core/deploy/opensandbox/README.md
@@ -0,0 +1,48 @@
+# OpenSandbox Runtime image and adapter
+
+Core's OpenSandbox Sandbox Provider ([`sandbox/opensandbox`](../../internal/sandbox/opensandbox)) runs Core-managed sandboxes on your own [OpenSandbox](https://open-sandbox.ai/) server, on Docker or Kubernetes, through the official OpenSandbox Go SDK 1.2.0. Core needs no nodes and runs no helper process. [Sandbox Provider protocol](../../../../docs/sandbox-provider.md) owns the contract this adapter implements, and [Choose a sandbox backend](../../../../docs/getting-started/sandbox-backends.md#use-opensandbox) the steps in Web. OpenSandbox's own documentation covers its installation, runtimes and security; this page covers only what OpenAgentCore needs.
+
+## Server requirements
+
+| Setting | Requirement |
+| --- | --- |
+| Version | OpenSandbox server 1.2.0 or newer |
+| `server.api_key` | Set; Core sends it as `OPEN-SANDBOX-API-KEY` ([Configuration](https://open-sandbox.ai/getting-started/configuration)) |
+| `[egress] mode` | `dns+nft`. Core's network policy needs packet-level enforcement ([Egress](https://open-sandbox.ai/architecture/network/egress)), and Create fails on a server that reports another mode |
+| `server.max_sandbox_timeout_seconds` | Unset or at least 3600: Core creates each sandbox with a one-hour lease and renews it |
+| Network | Core reaches the server URL; sandboxes reach Core's [public address](../../../../docs/getting-started/public-address.md) |
+
+The isolation is OpenSandbox's: its Docker runtime runs plain containers, and a [secure container runtime](https://open-sandbox.ai/guides/secure-container) gives each sandbox its own kernel. Core reaches each sandbox's execd through the server's proxy, so restrict who can reach the server and its sandbox ports as OpenSandbox's [Secure Access](https://open-sandbox.ai/guides/secure-access) and [Single-Host Network](https://open-sandbox.ai/architecture/network/single-host-network) describe.
+
+## Build the Runtime image
+
+OpenSandbox runs a sandbox as its image's user, and the Runtime runs as UID 1000. [`Dockerfile`](Dockerfile) gives that user the Runtime directories and links `/workspace` to `/environment/workspace`, so the sandbox needs no root. Build it from the Runtime image of the release your Core runs and push it where your OpenSandbox server pulls images:
+
+```sh
+docker load -i oac--linux-amd64-runtime.tar.gz # prints the loaded image ID
+docker tag oac-runtime:
+docker build --build-arg RUNTIME_IMAGE=oac-runtime: \
+ -t registry.example.com/oac/runtime-opensandbox: services/core/deploy/opensandbox
+docker push registry.example.com/oac/runtime-opensandbox:
+```
+
+Rebuild it with every Core release and select the new image in the deployment. The image is deployment configuration, not a public Environment Template.
+
+## Operations
+
+| Protocol operation | OpenSandbox call |
+| --- | --- |
+| `Create` | `POST /sandboxes` with the image, entrypoint `sleep infinity`, a one-hour timeout, the CPU and memory limits, the ownership metadata and the network policy. Through execd it then writes the [Runtime bootstrap](../../../../docs/runtime-bootstrap.md) file to `/home/runtime/runtime-bootstrap.json` (mode 0600), runs `oac-daemon connect --profile default --bootstrap-file … -b` and records `io.oac.bootstrap: complete` in the metadata |
+| `GetInfo` | Lists sandboxes by the allocation's metadata and checks the image |
+| `Renew` | `POST /sandboxes/{id}/renew-expiration` to one hour from now, only while the sandbox is running |
+| `Kill` | Deletes every sandbox with the allocation's metadata and lists again to confirm |
+| `RunCommand` | execd `POST /command` as the Runtime user; confidential input goes through a private file that the command reads and removes |
+| `Observe` | execd `GET /metrics`; see [Runtime observability](../../../../contracts/agents-api/runtime-observability.md) |
+
+Every sandbox carries `io.oac.installation`, `io.oac.tenant`, `io.oac.environment` and `io.oac.allocation`, and the adapter acts only on a sandbox whose four values match. It keeps no local state. One create request yields at most one sandbox, every later step of the attempt runs inside it, and Kill removes it, so an owned sandbox observed in any state but `Pending` settles the creation; Core records that before cleanup. A create that OpenSandbox rejects with a 4xx status settles as absent. Any other unknown outcome stays unsettled until a sandbox appears, and the one-hour timeout ends a late one.
+
+The checkpoint operations are not mapped. OpenSandbox's [pause](https://open-sandbox.ai/guides/pause-resume) differs by runtime: Docker freezes the container in place, Kubernetes keeps only the root filesystem, and only Fast Sandbox checkpoints memory. The preparation cache is not mapped either. [Network egress](../../../../docs/sandbox-provider.md#network-egress) describes how Core's egress maps to the network policy.
+
+## Tests
+
+`go test ./services/core/internal/sandbox/opensandbox` runs the shared contract suite against a local fake of the lifecycle and execd routes, together with the network-policy, configuration, command and observation tests. Native acceptance needs a real OpenSandbox server.
diff --git a/services/core/internal/sandbox/opensandbox/configuration.go b/services/core/internal/sandbox/opensandbox/configuration.go
new file mode 100644
index 000000000..8d9c971e8
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/configuration.go
@@ -0,0 +1,218 @@
+// Package opensandbox runs Core-managed sandboxes on an operator's OpenSandbox
+// server through its official Go SDK. Sandboxes are found by their ownership
+// metadata, so the adapter keeps no receipts or state of its own.
+package opensandbox
+
+import (
+ "encoding/json"
+ "net/url"
+ "strings"
+ "unicode"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+func Policy() sandbox.DeploymentPolicy {
+ return sandbox.DeploymentPolicy{
+ DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096},
+ Network: sandbox.NetworkSupport{Restrict: true},
+ }
+}
+
+// Setup declares how the console connects an OpenSandbox server. The adapter
+// reads its configuration and credential field names from here.
+func Setup() sandbox.SetupDeclaration {
+ return sandbox.SetupDeclaration{
+ Label: "OpenSandbox",
+ Summary: sandbox.Text{
+ EN: "Runs sandboxes on your own OpenSandbox server, on Docker or Kubernetes.",
+ ZHCN: "沙箱运行在你自己的 OpenSandbox 服务上,支持 Docker 或 Kubernetes。",
+ },
+ Fields: []sandbox.SetupField{
+ {Name: "api_url", Label: sandbox.Text{EN: "Server URL", ZHCN: "服务地址"}},
+ {Name: "image", Label: sandbox.Text{EN: "Runtime image", ZHCN: "Runtime 镜像"},
+ Help: sandbox.Text{EN: "Build it from the OpenAgentCore Runtime image with deploy/opensandbox/Dockerfile.", ZHCN: "用 deploy/opensandbox/Dockerfile 从 OpenAgentCore Runtime 镜像构建。"}},
+ {Name: "api_key", Secret: true, Label: sandbox.Text{EN: "API key", ZHCN: "API 密钥"}},
+ },
+ }
+}
+
+// fieldNames lists the declared secret or non-secret field names.
+func fieldNames(secret bool) []string {
+ var names []string
+ for _, f := range Setup().Fields {
+ if f.Secret == secret {
+ names = append(names, f.Name)
+ }
+ }
+ return names
+}
+
+// DeploymentConfiguration keeps the API key out of JSON and diagnostics;
+// ConfigurationAdapter.Encode owns the public projection. APIURL is the server
+// origin: the adapter appends /v1.
+type DeploymentConfiguration struct {
+ CredentialSupplied bool `json:"-"`
+ APIKey string `json:"-"`
+ APIURL string `json:"api_url"`
+ Image string `json:"image"`
+}
+
+func (c *DeploymentConfiguration) HasCredential() bool { return c != nil && c.APIKey != "" }
+func (c *DeploymentConfiguration) ReplacesCredential() bool { return c != nil && c.CredentialSupplied }
+func (c *DeploymentConfiguration) String() string {
+ return "OpenSandbox deployment configuration (private)"
+}
+
+// MarshalJSON blocks accidental serialization.
+func (*DeploymentConfiguration) MarshalJSON() ([]byte, error) { return []byte(`{}`), nil }
+
+type publicConfiguration struct {
+ APIURL string `json:"api_url"`
+ Image string `json:"image"`
+}
+
+// normalizeURL returns the server origin without a trailing slash.
+func normalizeURL(raw string) (string, error) {
+ u, err := url.Parse(raw)
+ if err != nil || len(raw) > 512 || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Opaque != "" ||
+ strings.ContainsAny(raw, "?#") || (u.Path != "" && u.Path != "/") || strings.ContainsFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) {
+ return "", sandbox.ErrInvalid
+ }
+ return u.Scheme + "://" + u.Host, nil
+}
+
+func validImage(image string) bool {
+ return image != "" && len(image) <= 512 && !strings.ContainsFunc(image, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) })
+}
+
+func validKey(key string) bool {
+ return key != "" && len(key) <= 4096 && !strings.ContainsFunc(key, func(r rune) bool { return unicode.IsSpace(r) || r == 0 })
+}
+
+// validate normalizes the endpoint and checks every field of a complete configuration.
+func validate(c *DeploymentConfiguration) (*DeploymentConfiguration, error) {
+ if c == nil || !validKey(c.APIKey) || !validImage(c.Image) {
+ return nil, sandbox.ErrInvalid
+ }
+ normalized := *c
+ var err error
+ if normalized.APIURL, err = normalizeURL(c.APIURL); err != nil {
+ return nil, err
+ }
+ return &normalized, nil
+}
+
+func configuration(s sandbox.Selection) *DeploymentConfiguration {
+ c, _ := s.Configuration.(*DeploymentConfiguration)
+ return c
+}
+
+type ConfigurationAdapter struct{}
+
+func (ConfigurationAdapter) Requirements() sandbox.ConfigurationRequirements {
+ const reason = "opensandbox_has_no_catalog"
+ return sandbox.ConfigurationRequirements{
+ Credential: sandbox.Required, PublicOrigin: sandbox.Required,
+ Discovery: providercontract.Support{State: providercontract.Unsupported, Reason: reason},
+ SelectionDiscovery: providercontract.Support{State: providercontract.Unsupported, Reason: reason},
+ CredentialVerification: providercontract.Support{State: providercontract.Supported},
+ }
+}
+
+func (ConfigurationAdapter) DecodeInput(public, secret json.RawMessage) (sandbox.Configuration, error) {
+ var p publicConfiguration
+ if err := sandbox.DecodeConfigurationObject(public, &p, fieldNames(false)...); err != nil {
+ return nil, err
+ }
+ c := &DeploymentConfiguration{APIURL: p.APIURL, Image: p.Image}
+ if len(secret) > 0 {
+ var credential struct {
+ APIKey string `json:"api_key"`
+ }
+ if err := sandbox.DecodeConfigurationObject(secret, &credential, fieldNames(true)...); err != nil {
+ return nil, err
+ }
+ if credential.APIKey == "" {
+ return nil, sandbox.ErrInvalid
+ }
+ c.APIKey, c.CredentialSupplied = credential.APIKey, true
+ }
+ return c, nil
+}
+
+func (ConfigurationAdapter) Encode(value sandbox.Configuration) (sandbox.ConfigurationRecord, error) {
+ c, ok := value.(*DeploymentConfiguration)
+ if !ok || c == nil {
+ return sandbox.ConfigurationRecord{}, sandbox.ErrInvalid
+ }
+ public, err := json.Marshal(publicConfiguration{APIURL: c.APIURL, Image: c.Image})
+ if err != nil {
+ return sandbox.ConfigurationRecord{}, sandbox.ErrInvalid
+ }
+ return sandbox.ConfigurationRecord{Public: public, Secret: []byte(c.APIKey)}, nil
+}
+
+func (ConfigurationAdapter) Decode(record sandbox.ConfigurationRecord) (sandbox.Configuration, error) {
+ var p publicConfiguration
+ if len(record.Metadata) > 0 && string(record.Metadata) != "{}" || sandbox.DecodeConfigurationObject(record.Public, &p, fieldNames(false)...) != nil {
+ return nil, sandbox.ErrInvalid
+ }
+ // A retained generation may hold no key; WithCredential composes the current one.
+ apiURL, err := normalizeURL(p.APIURL)
+ if err != nil || !validImage(p.Image) {
+ return nil, sandbox.ErrInvalid
+ }
+ return &DeploymentConfiguration{APIURL: apiURL, Image: p.Image, APIKey: string(record.Secret)}, nil
+}
+
+func (ConfigurationAdapter) Normalize(s sandbox.Selection) (sandbox.Selection, error) {
+ if s.Workspace != nil {
+ return s, sandbox.ErrInvalid
+ }
+ if err := s.DeploymentSpec.ValidatePolicy("opensandbox", Policy()); err != nil {
+ return s, err
+ }
+ c, err := validate(configuration(s))
+ if err != nil {
+ return s, err
+ }
+ s.Configuration = c
+ return s, nil
+}
+
+// ResolveChange inherits the previous key when the change supplies none.
+func (a ConfigurationAdapter) ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) {
+ if n, p := configuration(next), configuration(previous); n != nil && p != nil {
+ c := *n
+ c.CredentialSupplied = c.CredentialSupplied || c.APIKey != ""
+ if c.APIKey == "" && !c.CredentialSupplied {
+ c.APIKey = p.APIKey
+ }
+ next.Configuration = &c
+ }
+ return a.Normalize(next)
+}
+
+func (ConfigurationAdapter) WithCredential(owner, candidate sandbox.Configuration) (sandbox.Configuration, error) {
+ a, ok := owner.(*DeploymentConfiguration)
+ b, ok2 := candidate.(*DeploymentConfiguration)
+ if !ok || !ok2 || a == nil || b == nil || !b.HasCredential() {
+ return nil, sandbox.ErrInvalid
+ }
+ c := *a
+ c.APIKey = b.APIKey
+ return &c, nil
+}
+
+func (ConfigurationAdapter) Equal(a, b sandbox.Configuration) (bool, error) {
+ x, ok := a.(*DeploymentConfiguration)
+ y, ok2 := b.(*DeploymentConfiguration)
+ if !ok || !ok2 || x == nil || y == nil {
+ return false, sandbox.ErrInvalid
+ }
+ xc, yc := *x, *y
+ xc.CredentialSupplied, yc.CredentialSupplied = false, false
+ return xc == yc, nil
+}
diff --git a/services/core/internal/sandbox/opensandbox/configuration_test.go b/services/core/internal/sandbox/opensandbox/configuration_test.go
new file mode 100644
index 000000000..fd95cda25
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/configuration_test.go
@@ -0,0 +1,84 @@
+package opensandbox
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+func TestConfigurationRoundTripKeepsKeyOutOfPublicForms(t *testing.T) {
+ a := ConfigurationAdapter{}
+ c, err := a.DecodeInput(json.RawMessage(`{"api_url":"https://sandbox.example/","image":"registry.example/oac:1"}`), json.RawMessage(`{"api_key":"k-secret"}`))
+ if err != nil || !c.ReplacesCredential() {
+ t.Fatal(err)
+ }
+ s, err := a.Normalize(sandbox.Selection{DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 4096}, Network: sandbox.DefaultNetwork()}, Configuration: c})
+ if err != nil {
+ t.Fatal(err)
+ }
+ record, err := a.Encode(s.Configuration)
+ if err != nil || string(record.Public) != `{"api_url":"https://sandbox.example","image":"registry.example/oac:1"}` || string(record.Secret) != "k-secret" {
+ t.Fatalf("record = %s %q, %v", record.Public, record.Secret, err)
+ }
+ raw, _ := json.Marshal(s.Configuration)
+ if text := string(raw) + fmt.Sprint(s.Configuration) + fmt.Sprintf("%+v", s.Configuration); strings.Contains(text, "k-secret") {
+ t.Fatal("key leaked:", text)
+ }
+ back, err := a.Decode(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if same, err := a.Equal(s.Configuration, back); err != nil || !same {
+ t.Fatal("round trip differs", err)
+ }
+ // A retained generation's record holds no key until WithCredential composes it.
+ record.Secret = nil
+ if _, err := a.Decode(record); err != nil {
+ t.Fatal("keyless record:", err)
+ }
+}
+
+func TestConfigurationRejectsInvalidInput(t *testing.T) {
+ a := ConfigurationAdapter{}
+ if _, err := a.DecodeInput(json.RawMessage(`{"api_url":"https://s.example","image":"i","extra":"x"}`), nil); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal("unknown field accepted")
+ }
+ if _, err := a.DecodeInput(json.RawMessage(`{"api_url":"https://s.example","image":"i"}`), json.RawMessage(`{"api_key":"k","extra":"x"}`)); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal("unknown secret field accepted")
+ }
+ spec := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 4096}, Network: sandbox.DefaultNetwork()}
+ for _, c := range []DeploymentConfiguration{
+ {APIURL: "ftp://s.example", Image: "i", APIKey: "k"}, {APIURL: "https://u:p@s.example", Image: "i", APIKey: "k"},
+ {APIURL: "https://s.example/x", Image: "i", APIKey: "k"}, {APIURL: "https://s.example?x=1", Image: "i", APIKey: "k"},
+ {APIURL: "https://s.example", Image: "has space", APIKey: "k"}, {APIURL: "https://s.example", Image: "i", APIKey: "a b"},
+ {APIURL: "https://s.example", Image: "", APIKey: "k"}, {APIURL: "https://s.example", Image: "i"},
+ } {
+ if _, err := a.Normalize(sandbox.Selection{DeploymentSpec: spec, Configuration: &c}); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatalf("%+v accepted", c)
+ }
+ }
+ if _, err := a.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}); !errors.Is(err, providercontract.ErrUnsupported) {
+ t.Fatal(err)
+ }
+ if _, err := a.DiscoverSelection(t.Context(), sandbox.DirectConfig{}); !errors.Is(err, providercontract.ErrUnsupported) {
+ t.Fatal(err)
+ }
+}
+
+func TestResolveChangeInheritsKey(t *testing.T) {
+ a := ConfigurationAdapter{}
+ spec := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 4096}, Network: sandbox.DefaultNetwork()}
+ previous := sandbox.Selection{DeploymentSpec: spec, Configuration: &DeploymentConfiguration{APIURL: "https://s.example", Image: "i", APIKey: "old"}}
+ next, err := a.ResolveChange(sandbox.Selection{DeploymentSpec: spec, Configuration: &DeploymentConfiguration{APIURL: "https://s.example", Image: "j"}}, previous)
+ if c := configuration(next); err != nil || c.APIKey != "old" || c.ReplacesCredential() {
+ t.Fatal("key not inherited", err)
+ }
+ if _, err := a.ResolveChange(sandbox.Selection{DeploymentSpec: spec, Configuration: &DeploymentConfiguration{APIURL: "https://s.example", Image: "j", CredentialSupplied: true}}, previous); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal("explicit empty key inherited")
+ }
+}
diff --git a/services/core/internal/sandbox/opensandbox/fake_test.go b/services/core/internal/sandbox/opensandbox/fake_test.go
new file mode 100644
index 000000000..016013950
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/fake_test.go
@@ -0,0 +1,260 @@
+package opensandbox
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+// fake is an OpenSandbox server: the lifecycle routes and the execd routes
+// behind the server proxy. It records every call and injects one fault.
+type fake struct {
+ t *testing.T
+ server *httptest.Server
+ mu sync.Mutex
+ calls []string
+ items map[string]map[string]any // id -> sandbox
+ next int
+ state string // native state of created sandboxes
+ mode string // enforcementMode
+ fault func(call string, w http.ResponseWriter) bool
+ keepOn bool // DELETE succeeds without removing
+ created map[string]any
+ // execd
+ uploads map[string]uploaded
+ commands [][]string
+ cwds []string
+ exit func(argv []string) (stdout, stderr string, exit *int)
+ metrics map[string]any
+}
+
+type uploaded struct {
+ Mode int
+ Data string
+}
+
+func newFake(t *testing.T) *fake {
+ f := &fake{t: t, items: map[string]map[string]any{}, state: "Running", mode: "dns+nft", uploads: map[string]uploaded{}}
+ f.server = httptest.NewServer(http.HandlerFunc(f.serve))
+ t.Cleanup(f.server.Close)
+ return f
+}
+
+func (f *fake) record(call string) { f.calls = append(f.calls, call) }
+
+func (f *fake) Calls() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.calls...)
+}
+
+func (f *fake) add(r sandbox.Reference, installation string, extra map[string]string) string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.addLocked(map[string]string{keyInstallation: installation, keyTenant: r.TenantID, keyEnvironment: r.EnvironmentID, keyAllocation: r.AllocationID}, extra)
+}
+
+func (f *fake) addLocked(metadata map[string]string, extra map[string]string) string {
+ f.next++
+ id := "sb-" + string(rune('a'+f.next))
+ for k, v := range extra {
+ metadata[k] = v
+ }
+ f.items[id] = map[string]any{
+ "id": id, "image": map[string]any{"uri": "runtime:test"}, "status": map[string]any{"state": f.state},
+ "metadata": metadata, "createdAt": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), "entrypoint": []string{"sleep"},
+ }
+ return id
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func (f *fake) serve(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ path := strings.TrimPrefix(r.URL.Path, "/v1/sandboxes")
+ parts := strings.Split(strings.Trim(path, "/"), "/")
+ id := parts[0]
+ call := ""
+ switch {
+ case path == "" && r.Method == http.MethodGet:
+ call = "list"
+ case path == "" && r.Method == http.MethodPost:
+ call = "create"
+ case len(parts) == 1 && r.Method == http.MethodGet:
+ call = "get"
+ case len(parts) == 1 && r.Method == http.MethodDelete:
+ call = "delete"
+ case len(parts) == 2 && parts[1] == "metadata":
+ call = "patch"
+ case len(parts) == 2 && parts[1] == "renew-expiration":
+ call = "renew"
+ case len(parts) == 2 && parts[1] == "networkpolicy":
+ call = "networkpolicy"
+ case len(parts) == 3 && parts[1] == "endpoints":
+ call = "endpoint"
+ case len(parts) >= 4 && parts[1] == "proxy":
+ call = "execd" + strings.TrimPrefix(path, "/"+id+"/proxy/44772")
+ }
+ f.record(call)
+ if f.fault != nil && f.fault(call, w) {
+ return
+ }
+ switch call {
+ case "list":
+ want, _ := url.ParseQuery(r.URL.Query().Get("metadata"))
+ var items []map[string]any
+ for _, item := range f.items {
+ match := true
+ for k := range want {
+ match = match && item["metadata"].(map[string]string)[k] == want.Get(k)
+ }
+ if match {
+ items = append(items, item)
+ }
+ }
+ writeJSON(w, 200, map[string]any{"items": items, "pagination": map[string]any{"page": 1, "pageSize": 100, "totalItems": len(items), "totalPages": 1, "hasNextPage": false}})
+ case "create":
+ var body map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ f.created = body
+ metadata := map[string]string{}
+ for k, v := range body["metadata"].(map[string]any) {
+ metadata[k] = v.(string)
+ }
+ newID := f.addLocked(metadata, nil)
+ writeJSON(w, 201, f.items[newID])
+ case "get":
+ if item, ok := f.items[id]; ok {
+ writeJSON(w, 200, item)
+ return
+ }
+ writeJSON(w, 404, map[string]string{"code": "SANDBOX::NOT_FOUND", "message": "missing"})
+ case "delete":
+ if !f.keepOn {
+ delete(f.items, id)
+ }
+ w.WriteHeader(204)
+ case "patch":
+ var patch map[string]string
+ _ = json.NewDecoder(r.Body).Decode(&patch)
+ for k, v := range patch {
+ f.items[id]["metadata"].(map[string]string)[k] = v
+ }
+ writeJSON(w, 200, f.items[id])
+ case "renew":
+ writeJSON(w, 200, map[string]any{"expiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
+ case "networkpolicy":
+ writeJSON(w, 200, map[string]any{"status": "ok", "enforcementMode": f.mode})
+ case "endpoint":
+ writeJSON(w, 200, map[string]any{"endpoint": strings.TrimPrefix(f.server.URL, "http://") + "/v1/sandboxes/" + id + "/proxy/44772"})
+ case "execd/files/upload":
+ f.upload(w, r)
+ case "execd/command":
+ f.command(w, r)
+ case "execd/metrics":
+ writeJSON(w, 200, f.metrics)
+ default:
+ writeJSON(w, 404, map[string]string{"code": "NOT_FOUND", "message": r.Method + " " + r.URL.Path})
+ }
+}
+
+func (f *fake) upload(w http.ResponseWriter, r *http.Request) {
+ reader, err := r.MultipartReader()
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ var meta struct {
+ Path string
+ Mode int
+ }
+ for {
+ part, err := reader.NextPart()
+ if err != nil {
+ break
+ }
+ data, _ := io.ReadAll(part)
+ if part.FormName() == "metadata" {
+ _ = json.Unmarshal(data, &meta)
+ } else {
+ f.uploads[meta.Path] = uploaded{meta.Mode, string(data)}
+ }
+ }
+ w.WriteHeader(200)
+}
+
+func (f *fake) command(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Argv []string
+ Cwd string
+ }
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ f.commands, f.cwds = append(f.commands, body.Argv), append(f.cwds, body.Cwd)
+ stdout, stderr, exit := "", "", new(int)
+ if f.exit != nil {
+ stdout, stderr, exit = f.exit(body.Argv)
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ event := func(v map[string]any) {
+ raw, _ := json.Marshal(v)
+ _, _ = w.Write([]byte("data: " + string(raw) + "\n\n"))
+ }
+ event(map[string]any{"type": "init", "text": "c1"})
+ if stdout != "" {
+ event(map[string]any{"type": "stdout", "text": stdout})
+ }
+ if stderr != "" {
+ event(map[string]any{"type": "stderr", "text": stderr})
+ }
+ switch {
+ case exit == nil:
+ case *exit == 0:
+ event(map[string]any{"type": "execution_complete", "exit_code": 0})
+ default:
+ event(map[string]any{"type": "error", "error": map[string]any{"ename": "CommandExecError", "evalue": "x", "exit_code": *exit}})
+ }
+}
+
+func testProvider(t *testing.T, f *fake) (*Provider, sandbox.Reference) {
+ t.Helper()
+ installation := uuid.NewString()
+ p, err := newProvider(sandbox.DirectConfig{InstallationID: installation, Selection: sandbox.Selection{
+ Provider: "opensandbox",
+ DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 4096}, Network: sandbox.DefaultNetwork()},
+ Configuration: &DeploymentConfiguration{APIURL: f.server.URL, Image: "runtime:test", APIKey: "synthetic-key"},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.resolve = func(context.Context, string) ([]netip.Addr, error) {
+ return []netip.Addr{netip.MustParseAddr("203.0.113.5")}, nil
+ }
+ return p, sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}
+}
+
+func bootstrap(r sandbox.Reference) sandbox.Bootstrap {
+ return sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic",
+ Egress: sandbox.Egress{Public: true, PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{}}}
+}
+
+func bounded(t *testing.T) context.Context {
+ ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+ t.Cleanup(cancel)
+ return ctx
+}
diff --git a/services/core/internal/sandbox/opensandbox/network.go b/services/core/internal/sandbox/opensandbox/network.go
new file mode 100644
index 000000000..0283e0643
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/network.go
@@ -0,0 +1,125 @@
+package opensandbox
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+
+ osdk "github.com/alibaba/OpenSandbox/sdks/sandbox/go"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+// privatePrefixes are the addresses a public-internet sandbox must not reach.
+// Loopback is not among them: inside the sandbox's network namespace it is the
+// sandbox itself (execd and the sidecar's DNS proxy), never the host.
+var privatePrefixes = []netip.Prefix{
+ netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"),
+ netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"),
+}
+
+type resolver func(ctx context.Context, host string) ([]netip.Addr, error)
+
+// networkPolicy maps Egress to the OpenSandbox egress policy. The sidecar lets a
+// deny rule win over any allow, so a public sandbox denies the private ranges
+// minus Core's own addresses, and any other sandbox denies by default and
+// allows Core, the model provider endpoints and the named domains.
+func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) (*osdk.NetworkPolicy, error) {
+ core, err := url.Parse(b.CoreURL)
+ if err != nil || core.Hostname() == "" {
+ return nil, sandbox.ErrInvalid
+ }
+ coreHost, corePort := strings.ToLower(core.Hostname()), 443
+ if core.Scheme == "http" {
+ corePort = 80
+ }
+ if port := core.Port(); port != "" {
+ if corePort, err = strconv.Atoi(port); err != nil {
+ return nil, sandbox.ErrInvalid
+ }
+ }
+ var rules []osdk.NetworkRule
+ if b.Egress.Public {
+ addrs, err := hostAddrs(ctx, coreHost, resolve)
+ if err != nil {
+ return nil, err
+ }
+ for _, prefix := range privatePrefixes {
+ remaining := []netip.Prefix{prefix}
+ for _, addr := range addrs {
+ var next []netip.Prefix
+ for _, p := range remaining {
+ next = append(next, without(p, addr)...)
+ }
+ remaining = next
+ }
+ for _, p := range remaining {
+ rules = append(rules, osdk.NetworkRule{Action: "deny", Target: p.String()})
+ }
+ }
+ return &osdk.NetworkPolicy{DefaultAction: "allow", Egress: rules}, nil
+ }
+ // allow adds a domain rule, or an IP rule on the TCP port. Only Core's own
+ // address may be non-global.
+ allow := func(host string, port int, isCore bool) {
+ rule := osdk.NetworkRule{Action: "allow", Target: host}
+ if addr, err := netip.ParseAddr(host); err == nil {
+ if !isCore && !isGlobal(addr) {
+ return
+ }
+ rule.Ports = []int{port}
+ }
+ if !slices.ContainsFunc(rules, func(r osdk.NetworkRule) bool { return r.Target == rule.Target && slices.Equal(r.Ports, rule.Ports) }) {
+ rules = append(rules, rule)
+ }
+ }
+ allow(coreHost, corePort, true)
+ for _, endpoint := range b.Egress.Endpoints {
+ host, port, err := sandbox.SplitEndpoint(endpoint)
+ if err != nil {
+ return nil, err
+ }
+ allow(strings.ToLower(host), port, false)
+ }
+ for _, domain := range b.Egress.Domains {
+ allow(strings.ToLower(domain), 0, false)
+ }
+ return &osdk.NetworkPolicy{DefaultAction: "deny", Egress: rules}, nil
+}
+
+func hostAddrs(ctx context.Context, host string, resolve resolver) ([]netip.Addr, error) {
+ if addr, err := netip.ParseAddr(host); err == nil {
+ return []netip.Addr{addr.Unmap()}, nil
+ }
+ addrs, err := resolve(ctx, host)
+ if err != nil || len(addrs) == 0 {
+ return nil, fmt.Errorf("%w: the Core host cannot be resolved", sandbox.ErrInvalid)
+ }
+ for i := range addrs {
+ addrs[i] = addrs[i].Unmap()
+ }
+ return addrs, nil
+}
+
+func isGlobal(addr netip.Addr) bool {
+ return addr.IsGlobalUnicast() && !slices.ContainsFunc(privatePrefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
+}
+
+// without returns the minimal prefixes that cover p except addr.
+func without(p netip.Prefix, addr netip.Addr) []netip.Prefix {
+ if !p.Contains(addr) {
+ return []netip.Prefix{p}
+ }
+ var out []netip.Prefix
+ for bits := p.Bits() + 1; bits <= addr.BitLen(); bits++ {
+ raw := netip.PrefixFrom(addr, bits).Masked().Addr().AsSlice()
+ raw[(bits-1)/8] ^= 0x80 >> ((bits - 1) % 8)
+ sibling, _ := netip.AddrFromSlice(raw)
+ out = append(out, netip.PrefixFrom(sibling, bits))
+ }
+ return out
+}
diff --git a/services/core/internal/sandbox/opensandbox/network_test.go b/services/core/internal/sandbox/opensandbox/network_test.go
new file mode 100644
index 000000000..09c5c72ee
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/network_test.go
@@ -0,0 +1,100 @@
+package opensandbox
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/netip"
+ "strings"
+ "testing"
+
+ osdk "github.com/alibaba/OpenSandbox/sdks/sandbox/go"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+func policyFor(t *testing.T, coreURL string, egress sandbox.Egress, resolved ...string) *osdk.NetworkPolicy {
+ t.Helper()
+ resolve := func(context.Context, string) ([]netip.Addr, error) {
+ var out []netip.Addr
+ for _, a := range resolved {
+ out = append(out, netip.MustParseAddr(a))
+ }
+ return out, nil
+ }
+ policy, err := networkPolicy(t.Context(), sandbox.Bootstrap{CoreURL: coreURL, Egress: egress}, resolve)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return policy
+}
+
+func targets(policy *osdk.NetworkPolicy) []string {
+ var out []string
+ for _, r := range policy.Egress {
+ s := r.Action + " " + r.Target
+ for _, p := range r.Ports {
+ s += fmt.Sprintf(":%d", p)
+ }
+ out = append(out, s)
+ }
+ return out
+}
+
+func TestPublicNetworkDeniesPrivateRangesExceptCore(t *testing.T) {
+ for name, tc := range map[string]struct {
+ url string
+ resolved []string
+ }{"literal": {"https://10.1.2.3:8443/api/v1", nil}, "resolved name": {"https://core.example/api/v1", []string{"10.1.2.3"}}} {
+ t.Run(name, func(t *testing.T) {
+ policy := policyFor(t, tc.url, sandbox.Egress{Public: true}, tc.resolved...)
+ if policy.DefaultAction != "allow" {
+ t.Fatal(policy.DefaultAction)
+ }
+ core := netip.MustParseAddr("10.1.2.3")
+ var covered []string
+ for _, r := range policy.Egress {
+ if r.Action != "deny" || len(r.Ports) != 0 {
+ t.Fatalf("rule %+v", r)
+ }
+ if netip.MustParsePrefix(r.Target).Contains(core) {
+ t.Fatalf("deny %s covers Core", r.Target)
+ }
+ covered = append(covered, r.Target)
+ }
+ // The other private ranges stay denied whole, and the carve-out leaves a sibling of Core's /32.
+ joined := " " + strings.Join(covered, " ") + " "
+ for _, want := range []string{" 172.16.0.0/12 ", " 192.168.0.0/16 ", " 100.64.0.0/10 ", " 169.254.0.0/16 ", " fc00::/7 ", " fe80::/10 ", " 10.1.2.2/32 ", " 10.128.0.0/9 "} {
+ if !strings.Contains(joined, want) {
+ t.Fatalf("missing %q in %s", want, joined)
+ }
+ }
+ })
+ }
+}
+
+func TestPublicNetworkLeavesPublicCoreUntouched(t *testing.T) {
+ policy := policyFor(t, "https://core.example/api/v1", sandbox.Egress{Public: true}, "203.0.113.5")
+ if len(policy.Egress) != len(privatePrefixes) || policy.Egress[0].Target != "10.0.0.0/8" {
+ t.Fatalf("rules = %v", targets(policy))
+ }
+}
+
+func TestRestrictedNetworkAllowsOnlyNamedDestinations(t *testing.T) {
+ egress := sandbox.Egress{Domains: []string{"Docs.Example.com"}, Endpoints: []string{"api.model.example:443", "203.0.113.9:8443", "10.0.0.7:9000", "api.model.example:8443"}}
+ got := targets(policyFor(t, "https://core.example/api/v1", egress))
+ want := []string{"allow core.example", "allow api.model.example", "allow 203.0.113.9:8443", "allow docs.example.com"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("rules = %v, want %v", got, want)
+ }
+ if policy := policyFor(t, "http://10.2.3.4:8080/api/v1", sandbox.Egress{}); policy.DefaultAction != "deny" || strings.Join(targets(policy), ",") != "allow 10.2.3.4:8080" {
+ t.Fatalf("core literal rules = %v", targets(policy))
+ }
+}
+
+func TestUnresolvableCoreFailsBeforeDispatch(t *testing.T) {
+ fail := func(context.Context, string) ([]netip.Addr, error) { return nil, errors.New("no such host") }
+ if _, err := networkPolicy(t.Context(), sandbox.Bootstrap{CoreURL: "https://core.example/api/v1", Egress: sandbox.Egress{Public: true}}, fail); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal(err)
+ }
+}
diff --git a/services/core/internal/sandbox/opensandbox/observations.go b/services/core/internal/sandbox/opensandbox/observations.go
new file mode 100644
index 000000000..d0f99f0cf
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/observations.go
@@ -0,0 +1,80 @@
+package opensandbox
+
+import (
+ "context"
+ "errors"
+ "math"
+ "time"
+
+ osdk "github.com/alibaba/OpenSandbox/sdks/sandbox/go"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+const bytesPerMiB = 1 << 20
+
+// Observe reads execd's metrics for one running, owned sandbox. It never
+// writes, renews or restarts anything.
+func (p *Provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) {
+ r := sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID}
+ if target.Mode != runtimeobs.ModeManaged || !validReference(r) {
+ return runtimeobs.Sample{}, sandbox.ErrInvalid
+ }
+ if target.Instance.ProviderKey != p.installation {
+ return runtimeobs.Sample{}, sandbox.ErrOwnership
+ }
+ if _, ok := ctx.Deadline(); !ok {
+ return runtimeobs.Sample{}, sandbox.ErrInvalid
+ }
+ got, item, err := p.lookup(ctx, r)
+ switch {
+ case errors.Is(err, sandbox.ErrNotFound):
+ return runtimeobs.Sample{}, runtimeobs.ErrNotRunning
+ case errors.Is(err, sandbox.ErrOwnership):
+ return runtimeobs.Sample{}, err
+ case err != nil:
+ return runtimeobs.Sample{}, runtimeobs.ErrUnavailable
+ case got.State != "running":
+ return runtimeobs.Sample{}, runtimeobs.ErrNotRunning
+ }
+ live, err := osdk.ConnectSandbox(ctx, p.connection, item.ID)
+ if err != nil {
+ return runtimeobs.Sample{}, runtimeobs.ErrUnavailable
+ }
+ metrics, err := live.GetMetrics(ctx)
+ if err != nil {
+ return runtimeobs.Sample{}, runtimeobs.ErrUnavailable
+ }
+ return sample(*metrics, item.CreatedAt, p.now())
+}
+
+// sample maps execd's metrics. Malformed provider data leaves the sample
+// unavailable; execd reports no cumulative CPU time, so usage seconds stay nil.
+func sample(m osdk.Metrics, startedAt, now time.Time) (runtimeobs.Sample, error) {
+ if m.Timestamp <= 0 || !finite(m.CPUCount) || m.CPUCount <= 0 || !finite(m.CPUUsedPct) || m.CPUUsedPct < 0 ||
+ !finite(m.MemUsedMB) || m.MemUsedMB < 0 || !finite(m.MemTotalMB) || m.MemTotalMB <= 0 {
+ return runtimeobs.Sample{}, runtimeobs.ErrUnavailable
+ }
+ // execd's clock is the provider's: record a small lead at Core's time so a
+ // fresh point is not rejected as a future sample.
+ observedAt := time.UnixMilli(m.Timestamp)
+ if lead := observedAt.Sub(now); lead > 0 {
+ if lead > maxClockLead {
+ return runtimeobs.Sample{}, runtimeobs.ErrUnavailable
+ }
+ observedAt = now
+ }
+ if startedAt.IsZero() || startedAt.After(observedAt) {
+ return runtimeobs.Sample{}, runtimeobs.ErrUnavailable
+ }
+ ratio, capacity := m.CPUUsedPct/100, m.CPUCount
+ used, total := uint64(m.MemUsedMB*bytesPerMiB), uint64(m.MemTotalMB*bytesPerMiB)
+ return runtimeobs.Sample{
+ ObservedAt: observedAt, StartedAt: &startedAt,
+ CPUUtilizationRatio: &ratio, CPUCapacityCores: &capacity,
+ MemoryUsageBytes: &used, MemoryLimitBytes: &total,
+ }, nil
+}
+
+func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
diff --git a/services/core/internal/sandbox/opensandbox/operations.go b/services/core/internal/sandbox/opensandbox/operations.go
new file mode 100644
index 000000000..f991201cc
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/operations.go
@@ -0,0 +1,67 @@
+package opensandbox
+
+import (
+ "context"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+// Operations is this adapter's complete authored resource contract. OpenSandbox
+// pause is specific to its runtime, so the checkpoint lifecycle stays unsupported.
+func Operations() providercontract.Operations {
+ operations := providercontract.Operations{}
+ for _, name := range []string{"Create", "GetInfo", "Renew", "Kill", "RunCommand", "Observe"} {
+ operations[name] = providercontract.Support{State: providercontract.Supported}
+ }
+ for _, name := range []string{"Initial", "NewCompute", "GetCompute", "Suspend", "Resume", "KillCompute", "DeleteSnapshot", "RunCommandCompute", "ResumeCompute", "RenewCompute"} {
+ operations[name] = providercontract.Support{State: providercontract.Unsupported, Reason: "opensandbox_pause_is_runtime_specific"}
+ }
+ for _, name := range []string{"RestorePreparation", "SavePreparation"} {
+ operations[name] = providercontract.Support{State: providercontract.Unsupported, Reason: "opensandbox_has_no_preparation_cache"}
+ }
+ return operations
+}
+
+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) RenewCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) {
+ return sandbox.ComputeState{}, unsupported("RenewCompute")
+}
+func (*Provider) RestorePreparation(context.Context, sandbox.Reference, string) (bool, error) {
+ return false, unsupported("RestorePreparation")
+}
+func (*Provider) SavePreparation(context.Context, sandbox.Reference, string) error {
+ return unsupported("SavePreparation")
+}
diff --git a/services/core/internal/sandbox/opensandbox/provider.go b/services/core/internal/sandbox/opensandbox/provider.go
new file mode 100644
index 000000000..565da2e5d
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/provider.go
@@ -0,0 +1,385 @@
+package opensandbox
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/netip"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ osdk "github.com/alibaba/OpenSandbox/sdks/sandbox/go"
+ "github.com/google/uuid"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+const (
+ // Ownership metadata on every sandbox; the bootstrap marker is patched on
+ // last, after the Runtime daemon started.
+ keyInstallation = "io.oac.installation"
+ keyTenant = "io.oac.tenant"
+ keyEnvironment = "io.oac.environment"
+ keyAllocation = "io.oac.allocation"
+ keyBootstrap = "io.oac.bootstrap"
+
+ // lease is the native expiry set at Create and extended by Renew.
+ lease = time.Hour
+ // Listing reads at most maxListPages pages of listPageSize sandboxes.
+ listPageSize = 100
+ maxListPages = 10
+ maxOutput = 1 << 20
+ bootstrapFile = "/home/runtime/runtime-bootstrap.json"
+ stdinDir = "/home/runtime/.oac"
+ // maxClockLead tolerates a metrics timestamp slightly ahead of Core's clock.
+ maxClockLead = 30 * time.Second
+)
+
+// Provider runs sandboxes on one OpenSandbox server. A sandbox is identified by
+// its ownership metadata alone.
+type Provider struct {
+ installation string
+ image string
+ resources sandbox.Resources
+ connection osdk.ConnectionConfig
+ lifecycle *osdk.LifecycleClient
+ resolve resolver
+ now func() time.Time
+}
+
+var _ sandbox.SandboxProvider = (*Provider)(nil)
+
+// BuildDirect builds the Provider for one direct-mode selection.
+func BuildDirect(c sandbox.DirectConfig) (sandbox.SandboxProvider, error) { return newProvider(c) }
+
+func newProvider(c sandbox.DirectConfig) (*Provider, error) {
+ deployment, err := validate(configuration(c.Selection))
+ if err != nil || !validID(c.InstallationID) || c.Selection.Workspace != nil || c.Selection.DeploymentSpec.ValidatePolicy("opensandbox", Policy()) != nil {
+ return nil, errors.New("OpenSandbox deployment configuration is unavailable")
+ }
+ scheme, _, _ := strings.Cut(deployment.APIURL, "://")
+ return &Provider{
+ installation: c.InstallationID, image: deployment.Image, resources: c.Selection.Resources,
+ connection: osdk.ConnectionConfig{Domain: deployment.APIURL, Protocol: scheme, APIKey: deployment.APIKey, UseServerProxy: true},
+ lifecycle: osdk.NewLifecycleClient(deployment.APIURL+"/"+osdk.APIVersion, deployment.APIKey),
+ resolve: func(ctx context.Context, host string) ([]netip.Addr, error) {
+ return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
+ },
+ now: time.Now,
+ }, nil
+}
+
+func validID(value string) bool {
+ id, err := uuid.Parse(value)
+ return err == nil && id != uuid.Nil && id.String() == value
+}
+
+func validReference(r sandbox.Reference) bool {
+ return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID)
+}
+
+func (p *Provider) ownerMetadata(r sandbox.Reference) map[string]string {
+ return map[string]string{keyInstallation: p.installation, keyTenant: r.TenantID, keyEnvironment: r.EnvironmentID, keyAllocation: r.AllocationID}
+}
+
+// nativeErr maps a failed native call. It never exposes SDK text.
+func nativeErr(err error) error {
+ if statusOf(err) == http.StatusUnauthorized || statusOf(err) == http.StatusForbidden {
+ return sandbox.ErrCredentialRejected
+ }
+ return sandbox.ErrComputeUnconfirmed
+}
+
+// statusOf is the HTTP status of a native error response, or 0.
+func statusOf(err error) int {
+ var api *osdk.APIError
+ if errors.As(err, &api) {
+ return api.StatusCode
+ }
+ return 0
+}
+
+// owned lists the sandboxes carrying the allocation's four ownership keys and
+// checks each item's values exactly.
+func (p *Provider) owned(ctx context.Context, r sandbox.Reference) ([]osdk.SandboxInfo, error) {
+ want := p.ownerMetadata(r)
+ var items []osdk.SandboxInfo
+ for page := 1; page <= maxListPages; page++ {
+ list, err := p.lifecycle.ListSandboxes(ctx, osdk.ListOptions{Metadata: want, Page: page, PageSize: listPageSize})
+ if err != nil {
+ return nil, nativeErr(err)
+ }
+ for _, item := range list.Items {
+ if !owns(item, want) {
+ return nil, sandbox.ErrOwnership
+ }
+ items = append(items, item)
+ }
+ if !list.Pagination.HasNextPage {
+ return items, nil
+ }
+ }
+ return nil, sandbox.ErrComputeUnconfirmed
+}
+
+func owns(item osdk.SandboxInfo, want map[string]string) bool {
+ for key, value := range want {
+ if got, ok := item.Metadata[key]; !ok || got != value {
+ return false
+ }
+ }
+ return true
+}
+
+// info reports one owned sandbox. One create request yields at most one
+// sandbox, every later effect of the attempt is inside it, and Kill removes it,
+// so an observed non-Pending sandbox proves the attempt can no longer leave
+// resources behind; Core records it before Kill.
+func info(r sandbox.Reference, item osdk.SandboxInfo) sandbox.Info {
+ settled := item.Status.State != osdk.StatePending
+ return sandbox.Info{
+ Reference: r, ProviderID: item.ID, State: strings.ToLower(string(item.Status.State)),
+ BootstrapComplete: settled && item.Metadata[keyBootstrap] == "complete", CreateSettled: settled,
+ }
+}
+
+// lookup finds the allocation's one sandbox. An image other than the configured
+// one returns the Info with ErrInvalid, so cleanup still proceeds.
+func (p *Provider) lookup(ctx context.Context, r sandbox.Reference) (sandbox.Info, osdk.SandboxInfo, error) {
+ items, err := p.owned(ctx, r)
+ if err == nil {
+ switch len(items) {
+ case 0:
+ err = sandbox.ErrNotFound
+ case 1:
+ if item := items[0]; item.Image != nil && item.Image.URI != p.image {
+ return info(r, item), item, sandbox.ErrInvalid
+ }
+ return info(r, items[0]), items[0], nil
+ default:
+ err = sandbox.ErrComputeUnconfirmed
+ }
+ }
+ return sandbox.Info{Reference: r}, osdk.SandboxInfo{}, err
+}
+
+func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) {
+ if !validReference(r) {
+ return sandbox.Info{}, sandbox.ErrInvalid
+ }
+ got, _, err := p.lookup(ctx, r)
+ return got, err
+}
+
+// Renew extends the native lease of a running sandbox. Any other state is
+// reported, never renewed.
+func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) {
+ if !validReference(r) {
+ return sandbox.Info{}, sandbox.ErrInvalid
+ }
+ got, item, err := p.lookup(ctx, r)
+ if err != nil {
+ return got, err
+ }
+ if item.Status.State != osdk.StateRunning {
+ return got, sandbox.ErrComputeUnconfirmed
+ }
+ if _, err := p.lifecycle.RenewExpiration(ctx, item.ID, p.now().Add(lease)); err != nil {
+ return sandbox.Info{Reference: r}, nativeErr(err)
+ }
+ got, _, err = p.lookup(ctx, r)
+ return got, err
+}
+
+// Kill deletes every sandbox of the allocation after verifying that all of them
+// are owned, then confirms none is left.
+func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error {
+ if !validReference(r) {
+ return sandbox.ErrInvalid
+ }
+ items, err := p.owned(ctx, r)
+ if err != nil {
+ return err
+ }
+ for _, item := range items {
+ if err := p.lifecycle.DeleteSandbox(ctx, item.ID); err != nil && statusOf(err) != http.StatusNotFound {
+ return nativeErr(err)
+ }
+ }
+ if items, err = p.owned(ctx, r); err != nil {
+ return err
+ }
+ if len(items) > 0 {
+ return sandbox.ErrComputeUnconfirmed
+ }
+ return nil
+}
+
+func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) {
+ r := b.Reference
+ // Nothing was dispatched before the native create: the allocation is absent.
+ rejected := func(err error) (sandbox.Info, error) {
+ if !validReference(r) {
+ return sandbox.Info{Reference: r}, err
+ }
+ return sandbox.Info{Reference: r, State: "absent", CreateSettled: true}, err
+ }
+ if b.Workspace != nil {
+ return rejected(unsupported("Create"))
+ }
+ connection := b.RuntimeConnection()
+ if !validReference(r) || !validID(b.SessionID) || !validID(b.DeviceID) || connection.Validate() != nil || b.Egress.Validate() != nil || len(b.Egress.PrivateDomains) > 0 {
+ return rejected(sandbox.ErrInvalid)
+ }
+ policy, err := networkPolicy(ctx, b, p.resolve)
+ if err != nil {
+ return rejected(err)
+ }
+ existing, err := p.owned(ctx, r)
+ switch {
+ case errors.Is(err, sandbox.ErrOwnership):
+ return sandbox.Info{Reference: r}, err
+ case err != nil:
+ return rejected(err)
+ case len(existing) > 0:
+ return sandbox.Info{Reference: r}, sandbox.ErrExists
+ }
+
+ timeout := int(lease / time.Second)
+ created, err := p.lifecycle.CreateSandbox(ctx, osdk.CreateSandboxRequest{
+ Image: &osdk.ImageSpec{URI: p.image}, Entrypoint: []string{"sleep", "infinity"}, Timeout: &timeout,
+ ResourceLimits: osdk.ResourceLimits{"cpu": strconv.Itoa(int(p.resources.CPUs)), "memory": strconv.Itoa(int(p.resources.MemoryMiB)) + "Mi"},
+ Metadata: p.ownerMetadata(r), NetworkPolicy: policy,
+ })
+ if err != nil {
+ switch status := statusOf(err); {
+ case status == http.StatusUnauthorized || status == http.StatusForbidden:
+ return rejected(sandbox.ErrCredentialRejected)
+ case status >= 400 && status < 500:
+ return rejected(sandbox.ErrInvalid)
+ }
+ return sandbox.Info{Reference: r}, sandbox.ErrComputeUnconfirmed
+ }
+ unconfirmed := sandbox.Info{Reference: r}
+ item, err := p.lifecycle.GetSandbox(ctx, created.ID)
+ if err != nil {
+ return unconfirmed, nativeErr(err)
+ }
+ got := info(r, *item)
+ if item.ID != created.ID || !owns(*item, p.ownerMetadata(r)) {
+ return got, sandbox.ErrOwnership
+ }
+ status, err := p.lifecycle.GetNetworkPolicy(ctx, item.ID)
+ if err != nil {
+ return unconfirmed, nativeErr(err)
+ }
+ if status.EnforcementMode != "dns+nft" {
+ return got, fmt.Errorf("%w: the OpenSandbox egress sidecar must run in dns+nft mode", sandbox.ErrInvalid)
+ }
+
+ data, err := connection.Marshal()
+ if err != nil {
+ return unconfirmed, sandbox.ErrInvalid
+ }
+ live, err := osdk.ConnectSandbox(ctx, p.connection, item.ID)
+ if err != nil {
+ return unconfirmed, nativeErr(err)
+ }
+ if err := upload(ctx, live, bootstrapFile, data); err != nil {
+ return unconfirmed, nativeErr(err)
+ }
+ initialization, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+ result, err := run(initialization, live, []string{"/usr/local/bin/oac-daemon", "connect", "--profile", "default", "--bootstrap-file", bootstrapFile, "-b"}, "/environment/workspace")
+ if err != nil {
+ return unconfirmed, err
+ }
+ if result.ExitCode != 0 {
+ return got, sandbox.ErrCommandUnconfirmed
+ }
+ marker := "complete"
+ if _, err := p.lifecycle.PatchSandboxMetadata(ctx, item.ID, osdk.MetadataPatch{keyBootstrap: &marker}); err != nil {
+ return unconfirmed, nativeErr(err)
+ }
+ got, _, err = p.lookup(ctx, r)
+ if err == nil && !got.BootstrapComplete {
+ err = sandbox.ErrCommandUnconfirmed
+ }
+ return got, err
+}
+
+// upload writes a private file through execd.
+func upload(ctx context.Context, live *osdk.Sandbox, path string, data []byte) error {
+ return live.UploadFile(ctx, bytes.NewReader(data), osdk.UploadFileOptions{FileName: filepath.Base(path), Metadata: osdk.FileMetadata{Path: path, Mode: osdk.OctalMode(0o600)}})
+}
+
+var errOutputLimit = errors.New("command output exceeds the limit")
+
+// run executes argv and collects its output and real exit code. A stream that
+// ends without an exit code, or output over the limit, is unconfirmed.
+func run(ctx context.Context, live *osdk.Sandbox, argv []string, directory string) (sandbox.CommandResult, error) {
+ var stdout, stderr strings.Builder
+ collect := func(out *strings.Builder) func(osdk.OutputMessage) error {
+ return func(m osdk.OutputMessage) error {
+ if out.Len()+len(m.Text) > maxOutput {
+ return errOutputLimit
+ }
+ out.WriteString(m.Text)
+ return nil
+ }
+ }
+ request := osdk.RunCommandRequest{Argv: argv, Cwd: directory}
+ if deadline, ok := ctx.Deadline(); ok {
+ request.Timeout = max(time.Until(deadline).Milliseconds(), 1)
+ }
+ execution, err := live.RunCommandWithOpts(ctx, request, &osdk.ExecutionHandlers{SkipAccumulation: true, OnStdout: collect(&stdout), OnStderr: collect(&stderr)})
+ if err != nil || execution.ExitCode == nil {
+ return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed
+ }
+ return sandbox.CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: *execution.ExitCode}, nil
+}
+
+func validCommand(c sandbox.Command) error {
+ if len(c.Args) == 0 || len(c.Stdin) > sandbox.MaxCommandInputBytes || c.Directory != "" && !filepath.IsAbs(c.Directory) {
+ return sandbox.ErrInvalid
+ }
+ for _, arg := range c.Args {
+ if strings.ContainsRune(arg, 0) {
+ return sandbox.ErrInvalid
+ }
+ }
+ return nil
+}
+
+// RunCommand runs once and never replays. Stdin is staged in a private file so
+// that confidential bytes never enter argv.
+func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) {
+ if err := validCommand(c); err != nil || !validReference(r) {
+ return sandbox.CommandResult{}, sandbox.ErrInvalid
+ }
+ got, item, err := p.lookup(ctx, r)
+ if err != nil {
+ return sandbox.CommandResult{}, err
+ }
+ if got.State != "running" || !got.BootstrapComplete {
+ return sandbox.CommandResult{}, sandbox.ErrInvalid
+ }
+ live, err := osdk.ConnectSandbox(ctx, p.connection, item.ID)
+ if err != nil {
+ return sandbox.CommandResult{}, nativeErr(err)
+ }
+ argv := c.Args
+ if len(c.Stdin) > 0 {
+ file := stdinDir + "/opensandbox-stdin-" + uuid.NewString()
+ if err := upload(ctx, live, file, c.Stdin); err != nil {
+ return sandbox.CommandResult{}, nativeErr(err)
+ }
+ argv = append([]string{"/bin/sh", "-c", `f=$1; shift; "$@" <"$f"; s=$?; rm -f "$f"; exit $s`, "sh", file}, c.Args...)
+ }
+ return run(ctx, live, argv, c.Directory)
+}
diff --git a/services/core/internal/sandbox/opensandbox/provider_test.go b/services/core/internal/sandbox/opensandbox/provider_test.go
new file mode 100644
index 000000000..72d4ceb09
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/provider_test.go
@@ -0,0 +1,213 @@
+package opensandbox
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/contracttest"
+)
+
+func TestProviderContract(t *testing.T) {
+ contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ want := []string{"list"}
+ switch s.Operation {
+ case "create":
+ // The fault hits the native create; a foreign item hits the lookup before it.
+ if s.Fault != contracttest.ForeignOwnership {
+ want = []string{"list", "create"}
+ }
+ case "kill":
+ if s.Fault == contracttest.CleanupFailure {
+ want = []string{"list", "delete", "list"}
+ f.keepOn = true
+ f.add(r, p.installation, nil)
+ }
+ }
+ target := "list"
+ if s.Operation == "create" && s.Fault != contracttest.ForeignOwnership {
+ target = "create"
+ }
+ f.fault = func(call string, w http.ResponseWriter) bool {
+ if call != target || s.Fault == contracttest.CleanupFailure {
+ return false
+ }
+ switch s.Fault {
+ case contracttest.Canceled:
+ cancel()
+ time.Sleep(50 * time.Millisecond)
+ case contracttest.ForeignOwnership:
+ writeJSON(w, 200, map[string]any{"items": []map[string]any{{"id": "x", "status": map[string]any{"state": "Running"},
+ "metadata": map[string]string{keyInstallation: p.installation, keyTenant: r.TenantID, keyEnvironment: r.EnvironmentID, keyAllocation: "foreign"}}},
+ "pagination": map[string]any{"hasNextPage": false}})
+ return true
+ }
+ writeJSON(w, 500, map[string]string{"code": "INTERNAL", "message": "injected"})
+ return true
+ }
+ return contracttest.Fixture{Provider: p, Bootstrap: bootstrap(r), Calls: f.Calls, WantCalls: want}
+ })
+}
+
+func TestCreateBootstrapsOnce(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ b := bootstrap(r)
+ got, err := p.Create(bounded(t), b)
+ contracttest.AssertObservation(t, got, err, r, "", "running")
+ if !got.BootstrapComplete || !got.CreateSettled {
+ t.Fatalf("created sandbox not reported complete: %+v", got)
+ }
+ if want := []string{"list", "create", "get", "networkpolicy", "endpoint", "execd/files/upload", "execd/command", "patch", "list"}; !reflect.DeepEqual(f.calls, want) {
+ t.Fatalf("calls = %v, want %v", f.calls, want)
+ }
+ data, _ := b.RuntimeConnection().Marshal()
+ if u := f.uploads[bootstrapFile]; u.Data != string(data) || u.Mode != 600 {
+ t.Fatalf("bootstrap upload = %+v", u)
+ }
+ if want := []string{"/usr/local/bin/oac-daemon", "connect", "--profile", "default", "--bootstrap-file", bootstrapFile, "-b"}; !reflect.DeepEqual(f.commands[0], want) || f.cwds[0] != "/environment/workspace" {
+ t.Fatalf("daemon command = %v in %s", f.commands[0], f.cwds[0])
+ }
+ body := f.created
+ if body["image"].(map[string]any)["uri"] != "runtime:test" || body["timeout"] != float64(3600) ||
+ !reflect.DeepEqual(body["entrypoint"], []any{"sleep", "infinity"}) ||
+ !reflect.DeepEqual(body["resourceLimits"], map[string]any{"cpu": "2", "memory": "4096Mi"}) ||
+ body["networkPolicy"].(map[string]any)["defaultAction"] != "allow" {
+ t.Fatalf("create request = %v", body)
+ }
+ if _, err := p.Create(bounded(t), b); !errors.Is(err, sandbox.ErrExists) {
+ t.Fatalf("second create = %v", err)
+ }
+}
+
+func TestCreateRejectsEgressWithoutNftEnforcement(t *testing.T) {
+ f := newFake(t)
+ f.mode = "dns"
+ p, r := testProvider(t, f)
+ got, err := p.Create(bounded(t), bootstrap(r))
+ if !errors.Is(err, sandbox.ErrInvalid) || !got.CreateSettled || got.BootstrapComplete || len(f.uploads) != 0 {
+ t.Fatalf("create = %+v, %v", got, err)
+ }
+}
+
+func TestCreateBeforeDispatchIsAbsent(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ b := bootstrap(r)
+ b.Egress.PrivateDomains = []string{"corp.example"}
+ got, err := p.Create(bounded(t), b)
+ if !errors.Is(err, sandbox.ErrInvalid) || got.State != "absent" || !got.CreateSettled || len(f.Calls()) != 0 {
+ t.Fatalf("create = %+v, %v, calls %v", got, err, f.Calls())
+ }
+}
+
+func TestCreateNonZeroInitializationIsUnconfirmed(t *testing.T) {
+ f := newFake(t)
+ three := 3
+ f.exit = func([]string) (string, string, *int) { return "", "", &three }
+ p, r := testProvider(t, f)
+ got, err := p.Create(bounded(t), bootstrap(r))
+ if !errors.Is(err, sandbox.ErrCommandUnconfirmed) || !got.CreateSettled || got.BootstrapComplete {
+ t.Fatalf("create = %+v, %v", got, err)
+ }
+}
+
+func TestRenewOnlyRunning(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.state = "Paused"
+ f.add(r, p.installation, nil)
+ got, err := p.Renew(bounded(t), r)
+ if !errors.Is(err, sandbox.ErrComputeUnconfirmed) || got.State != "paused" || strings.Contains(strings.Join(f.Calls(), ","), "renew") {
+ t.Fatalf("renew = %+v, %v, calls %v", got, err, f.Calls())
+ }
+}
+
+func TestKillRemovesAndConfirms(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.add(r, p.installation, nil)
+ if err := p.Kill(bounded(t), r); err != nil || !reflect.DeepEqual(f.Calls(), []string{"list", "delete", "list"}) {
+ t.Fatalf("kill = %v, calls %v", err, f.Calls())
+ }
+ if err := p.Kill(bounded(t), r); err != nil {
+ t.Fatal("absent allocation:", err)
+ }
+}
+
+func TestRunCommand(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.add(r, p.installation, map[string]string{keyBootstrap: "complete"})
+ seven := 7
+ f.exit = func([]string) (string, string, *int) { return "out", "err", &seven }
+ got, err := p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"tool", "--flag"}, Directory: "/environment/workspace"})
+ if err != nil || got != (sandbox.CommandResult{Stdout: "out", Stderr: "err", ExitCode: 7}) || !reflect.DeepEqual(f.commands[0], []string{"tool", "--flag"}) || f.cwds[0] != "/environment/workspace" {
+ t.Fatalf("result = %+v, %v, argv %v", got, err, f.commands)
+ }
+ if _, err := p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"tool"}, Stdin: []byte("secret")}); err != nil {
+ t.Fatal(err)
+ }
+ argv := f.commands[1]
+ file := argv[4]
+ if len(argv) != 6 || argv[0] != "/bin/sh" || argv[3] != "sh" || argv[5] != "tool" || !strings.HasPrefix(file, stdinDir+"/opensandbox-stdin-") ||
+ f.uploads[file] != (uploaded{Mode: 600, Data: "secret"}) || strings.Contains(strings.Join(argv, " "), "secret") {
+ t.Fatalf("stdin staging: argv %v uploads %v", argv, f.uploads)
+ }
+}
+
+func TestRunCommandWithoutExitCodeIsUnconfirmed(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.add(r, p.installation, map[string]string{keyBootstrap: "complete"})
+ f.exit = func([]string) (string, string, *int) { return "partial", "", nil }
+ if _, err := p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"tool"}}); !errors.Is(err, sandbox.ErrCommandUnconfirmed) {
+ t.Fatal(err)
+ }
+}
+
+func TestRunCommandRequiresBootstrappedRunningSandbox(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.add(r, p.installation, nil)
+ if _, err := p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"tool"}}); !errors.Is(err, sandbox.ErrInvalid) || len(f.commands) != 0 {
+ t.Fatal(err)
+ }
+}
+
+func TestObserveMapsMetrics(t *testing.T) {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.add(r, p.installation, map[string]string{keyBootstrap: "complete"})
+ now := time.Now()
+ f.metrics = map[string]any{"cpu_count": 2, "cpu_used_pct": 25.0, "mem_total_mib": 4096, "mem_used_mib": 512, "timestamp": now.Add(5 * time.Second).UnixMilli()}
+ target := runtimeobs.Target{TenantID: r.TenantID, EnvironmentID: r.EnvironmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: r.AllocationID, ProviderKey: p.installation}}
+ got, err := p.Observe(bounded(t), target)
+ if err != nil || *got.CPUCapacityCores != 2 || *got.CPUUtilizationRatio != 0.25 || *got.MemoryLimitBytes != 4096<<20 || *got.MemoryUsageBytes != 512<<20 ||
+ got.ObservedAt.After(time.Now()) || got.StartedAt == nil || got.CPUUsageSecondsTotal != nil {
+ t.Fatalf("sample = %+v, %v", got, err)
+ }
+ f.metrics["timestamp"] = now.Add(time.Hour).UnixMilli()
+ if _, err := p.Observe(bounded(t), target); !errors.Is(err, runtimeobs.ErrUnavailable) {
+ t.Fatal("future sample accepted", err)
+ }
+ if err := p.Kill(bounded(t), r); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.Observe(bounded(t), target); !errors.Is(err, runtimeobs.ErrNotRunning) {
+ t.Fatal("missing sandbox:", err)
+ }
+ for _, calls := range f.Calls() {
+ if calls == "patch" || calls == "create" || calls == "renew" {
+ t.Fatal("observe wrote", calls)
+ }
+ }
+}
diff --git a/services/core/internal/sandbox/opensandbox/verify.go b/services/core/internal/sandbox/opensandbox/verify.go
new file mode 100644
index 000000000..c4f77696d
--- /dev/null
+++ b/services/core/internal/sandbox/opensandbox/verify.go
@@ -0,0 +1,57 @@
+package opensandbox
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ osdk "github.com/alibaba/OpenSandbox/sdks/sandbox/go"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+)
+
+const maxCredentialReferences = 32
+
+func (ConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) {
+ return sandbox.ConfigurationOptions{}, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: ConfigurationAdapter{}.Requirements().Discovery.Reason}
+}
+
+func (ConfigurationAdapter) DiscoverSelection(context.Context, sandbox.DirectConfig) (sandbox.Selection, error) {
+ return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: ConfigurationAdapter{}.Requirements().SelectionDiscovery.Reason}
+}
+
+// VerifyCredential is read-only and bounded: the key must list sandboxes, and
+// every Core-owned reference must resolve to sandboxes of this installation.
+func (ConfigurationAdapter) VerifyCredential(ctx context.Context, c sandbox.DirectConfig, refs []sandbox.Reference) error {
+ p, err := newProvider(c)
+ if err != nil || len(refs) > maxCredentialReferences {
+ return sandbox.ErrInvalid
+ }
+ for _, r := range refs {
+ if !validReference(r) {
+ return sandbox.ErrInvalid
+ }
+ }
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ // One installation-scoped page proves the key can read; each reference then
+ // proves the sandboxes it names belong to this installation.
+ if _, err := p.lifecycle.ListSandboxes(ctx, osdk.ListOptions{Metadata: map[string]string{keyInstallation: p.installation}, Page: 1, PageSize: 1}); err != nil {
+ if errors.Is(nativeErr(err), sandbox.ErrCredentialRejected) {
+ return sandbox.ErrCredentialRejected
+ }
+ return sandbox.ErrConfigurationUnconfirmed
+ }
+ for _, r := range refs {
+ switch _, err := p.owned(ctx, r); {
+ case errors.Is(err, sandbox.ErrOwnership):
+ return sandbox.ErrCredentialOwnership
+ case errors.Is(err, sandbox.ErrCredentialRejected):
+ return err
+ case err != nil:
+ return sandbox.ErrConfigurationUnconfirmed
+ }
+ }
+ return nil
+}
diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go
index 3cba8feff..e3417b07c 100644
--- a/services/core/internal/sandbox/providers/registry.go
+++ b/services/core/internal/sandbox/providers/registry.go
@@ -10,6 +10,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"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/opensandbox"
)
// Adapter describes configuration and transport independently of compute operations.
@@ -44,6 +45,10 @@ func Builtin() *Registry {
Policy: e2b.Policy(), Setup: e2b.Setup(), Operations: e2b.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: e2b.BuildDirect,
Configuration: e2b.ConfigurationAdapter{},
},
+ "opensandbox": {
+ Policy: opensandbox.Policy(), Setup: opensandbox.Setup(), Operations: opensandbox.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: opensandbox.BuildDirect,
+ Configuration: opensandbox.ConfigurationAdapter{},
+ },
}}
}
diff --git a/services/core/internal/sandbox/providers/registry_test.go b/services/core/internal/sandbox/providers/registry_test.go
index ec8b25040..a374c8238 100644
--- a/services/core/internal/sandbox/providers/registry_test.go
+++ b/services/core/internal/sandbox/providers/registry_test.go
@@ -19,6 +19,7 @@ func TestRegistrationOwnsDeploymentPolicy(t *testing.T) {
}{
{"microsandbox", "nodes", "nodes", true},
{"e2b", "direct", "e2b", true},
+ {"opensandbox", "direct", "opensandbox", false},
} {
t.Run(tc.kind, func(t *testing.T) {
d, err := registry.Describe(tc.kind, installation)
From 56d6424395dea45398dc45bef3f17416d1b6617e Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Tue, 3 Nov 2026 08:24:18 +0000
Subject: [PATCH 2/7] Translate the OpenSandbox documentation changes
---
contracts/agents-api/zh/core-errors.md | 4 +-
contracts/agents-api/zh/environments.md | 7 +-
.../agents-api/zh/harness-capabilities.md | 6 +-
contracts/agents-api/zh/harness-onboarding.md | 4 +-
contracts/agents-api/zh/machine-api.md | 4 +-
.../zh/runtime-observability-api.md | 6 +-
.../agents-api/zh/runtime-observability.md | 21 ++++-
contracts/agents-api/zh/sandbox-deployment.md | 80 +++++++++++--------
docs/zh/api/public-agent-api.md | 4 +-
docs/zh/concepts.md | 4 +-
docs/zh/configuration.md | 6 +-
docs/zh/getting-started/install.md | 4 +-
docs/zh/getting-started/nodes.md | 4 +-
docs/zh/getting-started/operations.md | 4 +-
docs/zh/getting-started/public-address.md | 3 +-
docs/zh/getting-started/quickstart.md | 6 +-
docs/zh/getting-started/sandbox-backends.md | 38 +++++----
docs/zh/getting-started/workspace-storage.md | 4 +-
docs/zh/runtime-bootstrap.md | 3 +-
docs/zh/sandbox-provider.md | 13 +--
docs/zh/web/console-api-usage.md | 4 +-
docs/zh/web/index.md | 4 +-
docs/zh/workspace-provider.md | 3 +-
23 files changed, 138 insertions(+), 98 deletions(-)
diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md
index 89ab5fe75..d4f5537a6 100644
--- a/contracts/agents-api/zh/core-errors.md
+++ b/contracts/agents-api/zh/core-errors.md
@@ -1,7 +1,7 @@
---
title: "Core 管理错误"
source: contracts/agents-api/core-errors.md
-source_hash: 771eca3c6af62cae0c43665da6cd03854c72e0befeaba57120bf6c2d13e346fe
+source_hash: ba64e3fed2e4dca50a3b4ca747c246057ec9a53bfbb87e11cab40a114802648f
---
`/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。
@@ -68,7 +68,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封
| `harness_config_invalid` | `harness_config` | 省略 | 部署默认配置的原生参数不受支持或无效 |
| `invalid_sandbox_configuration` | `resources.cpus` | `min`:1,`max`:255 | CPU 数量超出支持范围 |
| `invalid_sandbox_configuration` | `resources.memory_mib` | `min`:512,`max`:1048576 | 内存超出支持范围 |
-| `invalid_sandbox_configuration` | `resources.root_disk_mib` 或 `resources.environment_disk_mib` | `min`:microsandbox 为 1024(`environment_disk_mib` 也接受 0);E2B 的 `min`:0,`max`:0 | 磁盘容量缺失或提供商不支持 |
+| `invalid_sandbox_configuration` | `resources.root_disk_mib` 或 `resources.environment_disk_mib` | `min`:microsandbox 为 1024(`environment_disk_mib` 也接受 0);E2B 和 OpenSandbox 的 `min`:0,`max`:0 | 磁盘容量缺失或提供商不支持 |
这些边界是验证常量,绝不是提交的值。节点名称按字节数限制;项目名称和键名称按去除首尾空白后的 Unicode 字符数限制,且不得包含控制字符。系统仅按以下顺序报告第一个失败项:模型提供商 URL、协议、密钥、限制,然后是 Harness 协议;沙箱资源依次为 CPU、内存,然后是磁盘。`model_provider` 对象内的模型提供商字段错误仍以该对象的相应字段作为 `param`。未知的沙箱提供商返回一个不含这些字段的错误。
diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md
index 463da0641..6bd3025c4 100644
--- a/contracts/agents-api/zh/environments.md
+++ b/contracts/agents-api/zh/environments.md
@@ -1,7 +1,7 @@
---
title: "Environment 与模板"
source: contracts/agents-api/environments.md
-source_hash: b0587966b17ac30e6626b3ba094005de114fc04c0190a7adc96830f92acb11c3
+source_hash: f49f6012ac8d7309520a6b71c20288fdf61ae035d20023a3fcd705727a5d639e
---
Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。
@@ -10,7 +10,7 @@ Environment 是 Session 的执行资源,包括 Harness 运行所在的机器
- [Environment files](environment-files.md):实时工作区上的 Files API。
- [执行器凭据](environment-executor-credentials.md):`self_hosted` 机器的注册、安装授权和连接状态。
-- [Sandbox deployment](sandbox-deployment.md):托管 `openai_hosted` Environment 的沙箱提供商(E2B 或 microsandbox)。
+- [Sandbox deployment](sandbox-deployment.md):托管 `openai_hosted` Environment 的沙箱提供商(microsandbox、E2B 或 OpenSandbox)。
- [Core–Runtime protocol](../../../docs/zh/runtime-protocol.md):`runtime_prepare` 传输及所有其他线上消息。
- [Runtime and outer isolation](../../../docs/zh/concepts.md#runtime-and-outer-isolation):daemon 使用启动用户的权限运行工具;隔离由外层 Environment 提供。
@@ -73,7 +73,7 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会
### 托管(`openai_hosted`) {#hosted-openai-hosted}
-部署中配置的沙箱提供商(E2B 或 microsandbox,请参阅 [sandbox deployment](sandbox-deployment.md))承载 Environment。[Harness capabilities](harness-capabilities.md) 列出了可在其中运行的 Harnesses。
+部署中配置的沙箱提供商(microsandbox、E2B 或 OpenSandbox,请参阅 [sandbox deployment](sandbox-deployment.md))承载 Environment。[Harness capabilities](harness-capabilities.md) 列出了可在其中运行的 Harnesses。
- Session 创建时,无论是否包含初始输入,都会在 Worker 配置计算资源之前提交 Session、Environment 和重试身份。若创建在 bootstrap 前中断,可恢复时不会重复执行提供商的 Create。
- 置备无需调用方执行任何操作;在 Turn 启动之前,Session 会保持空闲。
@@ -315,6 +315,7 @@ Core 可以用同一 Project 中较早一次准备的结果启动托管 Environm
- 上限按名称或 `*.` 条目列出的模型提供商主机与所列域名一样可达,不限于其端口。
- 在 E2B 上,受限域名只能通过 80 和 443 端口访问;模型提供商使用其他端口时,其公网地址的所有端口均可达。
+- 在 microsandbox 和 OpenSandbox 上,允许的名称在其解析出的每个地址上都可达,包括私有地址。
部署的沙箱 Provider 无法限制出站时,`disabled` 和 `restricted` 返回 `sandbox_network_unsupported`。`self_hosted` Environment 只接受 `enabled`,因为 Core 无法在不由它运行的机器上强制策略。
diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md
index 77324b28a..e68726836 100644
--- a/contracts/agents-api/zh/harness-capabilities.md
+++ b/contracts/agents-api/zh/harness-capabilities.md
@@ -1,7 +1,7 @@
---
title: "Harness 能力"
source: contracts/agents-api/harness-capabilities.md
-source_hash: 3cbc5d850c420d4ab0ed7793c0516c8068b365f2d3fd5161d706bdb793db9ac6
+source_hash: 3fa50dff498df9d38e50c5081ed7c5448963f21f1522a4503452057954b63125
---
本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。
@@ -14,7 +14,7 @@ source_hash: 3cbc5d850c420d4ab0ed7793c0516c8068b365f2d3fd5161d706bdb793db9ac6
| 已准入 | Core 通过相同 Runtime 路径允许准入,但该位置尚未运行真实模型验收 |
| 已拒绝 | Core 在执行前拒绝请求 |
-部署位置为 `none`(无 Environment)、托管(`openai_hosted`)和自托管(`self_hosted`)。E2B 和 microsandbox 使用相同 Runtime 与适配器,托管位置的每个已验证单元格在这两者中视为已准入。自托管验收在 Linux 机器运行;[自托管指南](../../../docs/zh/getting-started/self-hosted.md#platforms)列出支持平台。操作通过验证仅代表该操作本身,不代表与其他选项的所有组合;配置拒绝的组合列于对应操作契约。
+部署位置为 `none`(无 Environment)、托管(`openai_hosted`)和自托管(`self_hosted`)。microsandbox、E2B 和 OpenSandbox 使用相同 Runtime 与适配器,托管位置的每个已验证单元格在这三者中均视为已准入。自托管验收在 Linux 机器运行;[自托管指南](../../../docs/zh/getting-started/self-hosted.md#platforms)列出支持平台。操作通过验证仅代表该操作本身,不代表与其他选项的所有组合;配置拒绝的组合列于对应操作契约。
## 执行与输入 {#execution-and-input}
@@ -67,4 +67,4 @@ Claude 结构化输出要求 medium verbosity,可与 `multi_agent`、Skill、P
| [stdio Plugin MCP](environments.md#plugin-mcp) | 已验证:托管、自托管 | 已验证:自托管;已准入:托管 | 已验证:自托管;已准入:托管 |
| HTTP Plugin MCP | 已准入,字面值头或 HTTPS bearer | 已准入,字面值头或 HTTPS bearer | 已验证:自托管;已准入:托管;字面值头或 HTTPS bearer |
-外部工作区存储还要求所选 Harness 声明和 Runtime 支持 `retained_native_history`;使用自有磁盘的 E2B 不要求此能力。microsandbox 始终使用外部存储,因此未声明该能力的 MiniMax Code 无法在其上运行。[工作区文件系统协议](../../../docs/zh/workspace-provider.md)定义存储组合准入规则。
+外部工作区存储还要求所选 Harness 声明和 Runtime 支持 `retained_native_history`;使用自有磁盘的 E2B 和 OpenSandbox 不要求此能力。microsandbox 始终使用外部存储,因此未声明该能力的 MiniMax Code 无法在其上运行。[工作区文件系统协议](../../../docs/zh/workspace-provider.md)定义存储组合准入规则。
diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md
index 54f4db9cc..33a247402 100644
--- a/contracts/agents-api/zh/harness-onboarding.md
+++ b/contracts/agents-api/zh/harness-onboarding.md
@@ -1,7 +1,7 @@
---
title: "Harness 协议"
source: contracts/agents-api/harness-onboarding.md
-source_hash: 4ab0e6623919424df5a56d302e3070bd2b59ad43416bce08547c4b136a33e4a4
+source_hash: bb67d20b3c7684d18944ac0bf51038835df199a91fda3445b727aaae7a049e64
---
**Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。
@@ -36,7 +36,7 @@ flowchart TB
| 声明 | Harness 的最大能力、放置位置、配置和纯准入限制,由 Core 准入和 Runtime 共同读取 | `internal/harnessconfig//declaration.go` |
| Runtime 绑定 | 声明加上发现、已安装工厂和已验证能力 | `apps/daemon/internal/agent//declaration.go` |
-Environment 提供执行资源。受管 E2B 和 microsandbox 机器以及应用自有机器在预配和连接方式上有所不同;已连接的 Runtime 使用同一契约。daemon 运行于 Linux、macOS 和 Windows,受管提供商仅支持 Linux,并且每个适配器自行认定其支持的平台([self-hosted platforms](../../../docs/zh/getting-started/self-hosted.md#platforms))。只有在 Runtime 加载绑定的已安装快照后,原生工厂才会收到能力([capability preparation](environments.md#runtime-capability-preparation))。模型提供商负责模型通信设置,而不负责 Turn 调度或原生进程所有权。
+Environment 提供执行资源。受管 microsandbox、E2B 和 OpenSandbox 机器以及应用自有机器在预配和连接方式上有所不同;已连接的 Runtime 使用同一契约。daemon 运行于 Linux、macOS 和 Windows,受管提供商仅支持 Linux,并且每个适配器自行认定其支持的平台([self-hosted platforms](../../../docs/zh/getting-started/self-hosted.md#platforms))。只有在 Runtime 加载绑定的已安装快照后,原生工厂才会收到能力([capability preparation](environments.md#runtime-capability-preparation))。模型提供商负责模型通信设置,而不负责 Turn 调度或原生进程所有权。
## 步骤 {#steps}
diff --git a/contracts/agents-api/zh/machine-api.md b/contracts/agents-api/zh/machine-api.md
index 8fdc12bf5..36e15a369 100644
--- a/contracts/agents-api/zh/machine-api.md
+++ b/contracts/agents-api/zh/machine-api.md
@@ -1,7 +1,7 @@
---
title: "机器连接 API"
source: contracts/agents-api/machine-api.md
-source_hash: 0160c835973e50788d704cf432fabb64c5650e2cef0c81b173ca81d3e2a611bc
+source_hash: 97ca1b4a9d8e7785afe6b3a84c9a56ff7bc08d24e2dfc31ed4e9a827eced9f6d
---
机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon 和自托管安装程序。各路由仅接受所列凭据,不接受 Core 密钥或项目 API 密钥;控制台登录也不授予此处权限。反向代理将 `/api/v1` 直接发送给 Core;Web 不提供这些路由。
@@ -62,7 +62,7 @@ oac-daemon connect --profile default
- 新节点发送登记 token,不带 `X-OAC-Node-ID`。token 必须有效、未过期、未消费且由此安装签发。活动重置拒绝该读取。
- 已注册节点发送节点凭据,并在 `X-OAC-Node-ID` 放其 UUID。无查询时读取当前目标。`?generation=N` 仅读取此节点仍可能需要的代际:当前目标、服务 pin,或其上未释放分配或 placement 持有的代际;其他代际被拒绝。重置期间仍可读取,以恢复已有归属资源。
-响应包含 `installation_id`、`provider`、`core_url`(安装公开 URL)、`generation`、`specification`、`specification_digest`。不包含管理员、项目或 E2B 凭据,仅适用于节点型提供商。[沙箱部署契约](sandbox-deployment.md#canonical-node-specification)定义 specification 与摘要。
+响应包含 `installation_id`、`provider`、`core_url`(安装公开 URL)、`generation`、`specification`、`specification_digest`。不包含管理员、项目或沙箱提供商凭据,仅适用于节点型提供商。[沙箱部署契约](sandbox-deployment.md#canonical-node-specification)定义 specification 与摘要。
### 登记节点 {#enroll-a-node}
diff --git a/contracts/agents-api/zh/runtime-observability-api.md b/contracts/agents-api/zh/runtime-observability-api.md
index 4eb72d523..d2f4f16d8 100644
--- a/contracts/agents-api/zh/runtime-observability-api.md
+++ b/contracts/agents-api/zh/runtime-observability-api.md
@@ -1,7 +1,7 @@
---
title: "Runtime 遥测 API"
source: contracts/agents-api/runtime-observability-api.md
-source_hash: fa3b6fa85dfed2399c687a3124b8ab6407dc8dfb3e0a3b32f86333dad0750c3c
+source_hash: f869b74af3774e167e0566d42ea56c92f8c9f388c189a04d2ca6aa991106dc5c
---
Core 通过 `/core/v1` 下的只读管理员路由报告托管 Runtime 和沙箱节点所使用的信息:当前 Runtime 观测值、单个 Session 的已存储 Runtime 历史记录,以及沙箱节点的主机观测值和历史记录。读取操作绝不创建、唤醒、续期或更改计算资源,也绝不向历史记录添加样本。[Runtime observability](runtime-observability.md) 定义了 Core 如何采集和保留这些值;[Console API usage](../../../docs/zh/web/console-api-usage.md) 列出了读取这些值的 Web 页面。
@@ -119,7 +119,7 @@ Authorization: Bearer
### `disk` {#disk}
-只有列表行包含 `disk`:其值为 null,或为遵循 `memory` 规则的 `{usage_bytes, limit_bytes}`。当沙箱同时报告磁盘使用量和非零容量时,E2B 会填充此对象。microsandbox 返回 null。非 null 的 `disk` 只出现在状态为 `observed` 的行中。
+只有列表行包含 `disk`:其值为 null,或为遵循 `memory` 规则的 `{usage_bytes, limit_bytes}`。当沙箱同时报告磁盘使用量和非零容量时,E2B 会填充此对象。microsandbox 和 OpenSandbox 返回 null。非 null 的 `disk` 只出现在状态为 `observed` 的行中。
### 状态和原因 {#status-and-reason}
@@ -192,7 +192,7 @@ Core 先解析项目,然后解析 Session 及其 Environment,之后才读取
每个点都包含该桶的边界、该分配样本的覆盖范围计数,以及可空的 `cpu` 和 `memory` 对象;其 `contributor_count` 至少为 1:
-- 对于结束于该桶的所有区间,`cpu.utilization_ratio` 根据同一计算实例代次的连续累计 CPU 计数器得出:消耗的 CPU 秒数除以(经过时间乘以容量)。计算实例代次发生变化或计数器数值减小时,基线会重置。E2B 不报告累计 CPU 时间;其桶值是桶内采样比率的平均值。`cpu.capacity_cores` 是桶内最后一次容量值。
+- 对于结束于该桶的所有区间,`cpu.utilization_ratio` 根据同一计算实例代次的连续累计 CPU 计数器得出:消耗的 CPU 秒数除以(经过时间乘以容量)。计算实例代次发生变化或计数器数值减小时,基线会重置。E2B 和 OpenSandbox 不报告累计 CPU 时间;它们的桶值是桶内采样比率的平均值。`cpu.capacity_cores` 是桶内最后一次容量值。
- `memory.usage_bytes` 和 `memory.limit_bytes` 是桶内最后观测到的值。
历史记录中不保存磁盘数据。
diff --git a/contracts/agents-api/zh/runtime-observability.md b/contracts/agents-api/zh/runtime-observability.md
index 6efbbd646..063489a25 100644
--- a/contracts/agents-api/zh/runtime-observability.md
+++ b/contracts/agents-api/zh/runtime-observability.md
@@ -1,7 +1,7 @@
---
title: "Runtime 可观测性"
source: contracts/agents-api/runtime-observability.md
-source_hash: b77c76a6b8ed496dab56f3d62deb7f7f61066e0751bc4bd3b5373535b3cc7c05
+source_hash: 5a79ce08bd5c611abae2a3a488e80b1efeb8316bd2939f1a311c0579cc22792b
---
这是面向贡献者的契约,规定 Core 如何观测 Runtime 并保留其历史。路由和响应字段见 [Runtime telemetry API](runtime-observability-api.md)。代码位于 `services/core/internal/runtimeobs`(解析、源、采样器和导出)、`services/core/internal/runtimehistory`(历史查询和 PostgreSQL 存储)以及 `services/core/internal/runtimeobs/otlpexporter`。
@@ -20,7 +20,7 @@ none: tenant_id -> session_id (no Session-owned Runtime instance)
解析器(`services/core/internal/deployment/observation.go`)从数据库中读取 Session、其 Environment、当前分配以及 Session 的实测使用量。Session、守护进程连接、进程、容器和原生 Harness Session 是不同身份,彼此绝不能替代。
-托管 microsandbox 和 E2B 分配均会被观测。`none` 和 `self_hosted` Session 为 `unsupported`;Core 绝不会将共享主机统计信息归属于 `environment:none` Session。
+托管 microsandbox、E2B 和 OpenSandbox 分配均会被观测。`none` 和 `self_hosted` Session 为 `unsupported`;Core 绝不会将共享主机统计信息归属于 `environment:none` Session。
每个托管分配都通过部署所选的沙箱提供商读取;该提供商在返回数值前会验证分配的 installation(`provider_key`)以及分配标签或等效所有权数据。在读取任何提供商之前,部分行的结果由分配状态决定:处于 `creating` 状态或尚无分配时得到 `allocation_pending`,处于 `cleanup_pending` 或 `released` 状态时得到 `runtime_not_running`。提供商读取超出截止时间时得到 `sample_timeout`,返回未运行结果时得到 `runtime_not_running`,返回不可用结果时得到 `sample_unavailable`。任何其他错误、所有权不匹配或无效采样都会使读取失败。
@@ -34,7 +34,7 @@ none: tenant_id -> session_id (no Session-owned Runtime instance)
- `observed_at`,即提供商的观测时间;以及 `started_at`,即当前计算实例启动周期的开始时间;
- 累计 CPU 秒数,以及以核心数计的已配置 CPU 容量;
-- 提供商报告的 CPU 利用率比率,仅适用于不提供累计 CPU 时间的提供商(E2B);
+- 提供商报告的 CPU 利用率比率,仅适用于不提供累计 CPU 时间的提供商(E2B、OpenSandbox);
- 当前内存使用量,以及以字节计的内存限制;
- 当前磁盘使用量和容量,以字节计,且仅在提供商报告这些值时提供(E2B)。
@@ -61,6 +61,19 @@ Core 用一次 helper `observe` 请求读取一个分配:读取私有回执中
E2B 不报告累计 CPU 时间,因此 CPU 秒数保持为 null。`observed_at` 为 E2B 的时间点;比 Core 时钟最多领先 30 秒的时间点按 Core 时间记录,领先幅度更大时则为 `sample_unavailable`。未出现在运行列表中的 sandbox 为 `runtime_not_running`。时间点缺失或格式错误、列表存在歧义以及 E2B API 失败(包括 key 被拒绝)均为 `sample_unavailable`。
+### OpenSandbox {#opensandbox}
+
+Core 按所有权元数据列出该分配的 sandbox,并通过 OpenSandbox 服务器的代理读取 execd 的 `GET /metrics`。它绝不会续期或更改 sandbox。[OpenSandbox adapter 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md)说明了该 adapter。
+
+| execd 值 | 采样字段 |
+| --- | --- |
+| `cpu_used_pct / 100` | CPU 利用率比率 |
+| `cpu_count` | CPU 容量 |
+| `mem_used_mib`、`mem_total_mib` | 内存使用量和限制,以字节计 |
+| Sandbox `createdAt` | `started_at` |
+
+execd 不报告累计 CPU 时间和磁盘,因此 CPU 秒数和磁盘保持为 null。`observed_at` 为 execd 的 `timestamp`,领先 Core 时钟的 30 秒规则与 E2B 相同。缺失或未运行的 sandbox 为 `runtime_not_running`。读取失败或格式错误为 `sample_unavailable`。
+
## 读取预算 {#read-budgets}
当前列表读取处理一页最多 100 个 Session(默认 20 个),提供商读取并发数最多为 8。每次提供商读取的时限为 2 秒,整个列表请求为 10 秒;超过这些时限时,列表返回 503。单 Session 读取的时限为 2 秒。一次请求内不会重试任何提供商调用,Core 也不保留观测缓存。
@@ -111,7 +124,7 @@ PostgreSQL 存储仅保留周期性的 `openai_hosted` 记录,因此 API 读
| --- | --- | --- |
| `agents.runtime.cpu.usage` | 单调累积和,秒 | 累计 CPU 计数器 |
| `agents.runtime.cpu.capacity` | Gauge,核心数 | 已配置的 CPU 容量 |
-| `agents.runtime.cpu.utilization` | Gauge,比率 | 提供商报告的 CPU 占比(E2B) |
+| `agents.runtime.cpu.utilization` | Gauge,比率 | 提供商报告的 CPU 占比(E2B、OpenSandbox) |
| `agents.runtime.memory.usage` | Gauge,字节 | 内存使用量 |
| `agents.runtime.memory.limit` | Gauge,字节 | 内存限制 |
| `agents.session.tokens.input` | Gauge,token | 实测 Session 输入 token |
diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md
index 9e0b2f1d5..0899238b8 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: b1624463cdd34795eb6af871e2ef51792f349d73c3fa69623fa55fa063c8f435
+source_hash: 6fb81005e929a5b5061d2570cff18f441521f4e5a71832139c9bc1e190b76075
---
沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。
@@ -36,13 +36,13 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 `
| 字段 | 含义 |
| --- | --- |
| `expected_generation` | 必填的非负整数,来自 GET;绝不会自动刷新并重放 |
-| `provider` | 必须是 `microsandbox`、`e2b` 中恰好一个 |
-| `resources` | 每个沙箱的限制,见下文;microsandbox 必填,E2B 可选 |
+| `provider` | 必须是 `microsandbox`、`e2b`、`opensandbox` 中恰好一个 |
+| `resources` | 每个沙箱的限制,见下文;microsandbox 和 OpenSandbox 必填,E2B 可选 |
| `network` | 出站上限,见[下文](#network)。POST 中省略表示默认值;PUT 中省略表示保持当前上限 |
-| `configuration` | 提供商的公开选择器。E2B:不可变的 `template` 构建以及可选且配套的 `api_url` 和 `domain`。microsandbox 仅接受 `{}` 或省略 |
-| `credential` | 提供商的只写凭据。E2B:`{api_key}`,首次设置时必填,在 PUT 中省略以保留当前密钥;null 或空密钥无效。microsandbox 拒绝该字段 |
+| `configuration` | 提供商的公开选择器。E2B:不可变的 `template` 构建以及可选且配套的 `api_url` 和 `domain`。OpenSandbox:`api_url` 和 `image`。microsandbox 仅接受 `{}` 或省略 |
+| `credential` | 提供商的只写凭据。E2B 和 OpenSandbox:`{api_key}`,首次设置时必填,在 PUT 中省略以保留当前密钥;null 或空密钥无效。microsandbox 拒绝该字段 |
-请求中没有 Core 地址。Core 根据安装公开 URL [`OAC_PUBLIC_URL`](../../../docs/zh/configuration.md#settings)派生部署的 `core_url`:这是节点和沙箱客户机访问 Core 时使用的源地址。包含 `core_url` 的请求会像包含任何其他未知成员一样被拒绝,并返回 400 `invalid_request`。E2B 客户机从 E2B 云访问 Core,因此当公开 URL 为回环地址时,E2B 选择会被拒绝,并返回 409 `sandbox_configuration_error`。microsandbox 选择接受回环公开 URL,但这仅适用于本地开发,因为客户机的回环地址无法访问其主机。更改公开 URL 属于安装变更;参见[更改公开 URL](../../../docs/zh/getting-started/public-address.md#changing-the-public-url)。
+请求中没有 Core 地址。Core 根据安装公开 URL [`OAC_PUBLIC_URL`](../../../docs/zh/configuration.md#settings)派生部署的 `core_url`:这是节点和沙箱客户机访问 Core 时使用的源地址。包含 `core_url` 的请求会像包含任何其他未知成员一样被拒绝,并返回 400 `invalid_request`。E2B 客户机从 E2B 云访问 Core,OpenSandbox 客户机从 OpenSandbox 服务器所在的主机访问 Core,因此当公开 URL 为回环地址时,E2B 或 OpenSandbox 选择会被拒绝,并返回 409 `sandbox_configuration_error`。microsandbox 选择接受回环公开 URL,但这仅适用于本地开发,因为客户机的回环地址无法访问其主机。更改公开 URL 属于安装变更;参见[更改公开 URL](../../../docs/zh/getting-started/public-address.md#changing-the-public-url)。
### 资源 {#resources}
@@ -50,17 +50,19 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 `
| --- | --- |
| `cpus` | 整数,1 到 255 |
| `memory_mib` | 整数,512 到 1048576 MiB |
-| `root_disk_mib` | microsandbox:至少 1024 MiB;E2B:省略或为零 |
-| `environment_disk_mib` | microsandbox:工作区存储上每个新工作区的获准容量,零(默认值)表示不请求配额,正值必须至少为 1024 MiB 且要求强制执行配额;E2B:省略或为零 |
+| `root_disk_mib` | microsandbox:至少 1024 MiB;E2B 和 OpenSandbox:省略或为零 |
+| `environment_disk_mib` | microsandbox:工作区存储上每个新工作区的获准容量,零(默认值)表示不请求配额,正值必须至少为 1024 MiB 且要求强制执行配额;E2B 和 OpenSandbox:省略或为零 |
这些限制描述每个沙箱。节点的 `max_active` 和 `max_retained` 是独立的预留限制,设置方式见[节点与分配](#nodes-and-allocations);限制一经设置,任何主机测量值都不允许超过其中任何一个。即使数值满足这些边界,原生提供商仍可能拒绝这些值。
E2B 的 CPU 和内存必须与精确的就绪模板构建一致,Core 会在保存前通过固定版本 SDK 进行验证;磁盘容量仍属于模板的一部分。E2B 不接受其无法强制执行的磁盘配额。E2B 选择可以省略 `resources`:此时 Core 将构建的 CPU 数量和内存存储为 `cpus` 和 `memory_mib`,并通过 `specification.resources` 返回,不带磁盘字段。重启时,Core 会加载已提交的 E2B 选择,而不会再次验证模板构建,因此 E2B 中断绝不会阻碍检查或清理;新选择仍需验证。
+OpenSandbox 将 `cpus` 和 `memory_mib` 应用为沙箱的 `cpu` 和 `memory` 资源限制;磁盘由其 runtime 设置。
+
microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/environment` 的[工作区存储](../../../docs/zh/workspace-provider.md)上运行每个 Environment。[microsandbox 辅助工具](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md)说明了 restore 如何处理这些限制。
-响应中可选的 `specification.workspace` 是从所选[工作区文件系统](../../../docs/zh/workspace-provider.md)派生的不可变能力回执,不是可独立写入的设置。它仅包含 `attachment`、`user_xattr` 和 `capacity_quota`;文件系统配置、路径和凭据不进入 Sandbox specification。Core 保存前校验组合声明,保留代次校验与节点构造均使用此回执。microsandbox 始终具备该回执,尚未选择工作区存储时保存部署会返回 `workspace_storage_required`。E2B 没有回执,继续使用自有磁盘。Create 和 Resume 必须且仅当固定代次具备该回执时提供绑定。
+响应中可选的 `specification.workspace` 是从所选[工作区文件系统](../../../docs/zh/workspace-provider.md)派生的不可变能力回执,不是可独立写入的设置。它仅包含 `attachment`、`user_xattr` 和 `capacity_quota`;文件系统配置、路径和凭据不进入 Sandbox specification。Core 保存前校验组合声明,保留代次校验与节点构造均使用此回执。microsandbox 始终具备该回执,尚未选择工作区存储时保存部署会返回 `workspace_storage_required`。E2B 和 OpenSandbox 没有回执,继续使用自有磁盘。Create 和 Resume 必须且仅当固定代次具备该回执时提供绑定。
先选择文件系统配置,再配置 microsandbox 部署。选择文件系统不会改变现有代次或 allocation;下一次 Sandbox 更新派生该回执。文件系统选择必须支持提供商声明的要求。现有部署要求相同的 `attachment` 和 `user_xattr`;选择可以更改 `capacity_quota`,此后在 Sandbox 更新设置相匹配的 `environment_disk_mib` 之前拒绝新工作区。既有工作区保持其获准时的容量。
@@ -75,15 +77,19 @@ microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/enviro
默认值为 `{"public": true, "domains": []}`。无论哪种上限,回环、链路本地、云元数据地址以及其他所有私有地址始终被阻止,但 E2B 无法阻止元数据地址 169.254.169.254,且 E2B 沙箱根本无法访问私有网络。沙箱保留创建它的代次的上限。
-每个 Provider 声明自己能强制执行什么([Sandbox Provider 协议](../../../docs/zh/sandbox-provider.md#network-egress)):microsandbox 支持两个字段,E2B 支持 `public: false` 但不支持 `domains`,因为 E2B 沙箱无法访问私有网络。所选 Provider 未声明的上限会返回 400 `invalid_sandbox_configuration`,`param` 为 `network.public` 或 `network.domains`。
+每个 Provider 声明自己能强制执行什么([Sandbox Provider 协议](../../../docs/zh/sandbox-provider.md#network-egress)):microsandbox 支持两个字段,E2B 支持 `public: false` 但不支持 `domains`,因为 E2B 沙箱无法访问私有网络,OpenSandbox 支持 `public: false` 但不支持 `domains`,因为其对私有网段的拒绝规则优先于域名解析出的私有地址。所选 Provider 未声明的上限会返回 400 `invalid_sandbox_configuration`,`param` 为 `network.public` 或 `network.domains`。
### E2B 配置 {#e2b-configuration}
E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建 UUID 必须是规范形式且非零,仅提供可变模板别名将被拒绝。API 密钥在 PostgreSQL 中加密,绝不会出现在响应、引导配置、命令参数或日志中。默认情况下,Core 使用 `https://api.e2b.app` 和 `e2b.app`。对于兼容服务,请同时设置 `configuration.api_url`(一个不含路径、端口、查询、片段或凭据的 HTTPS 源地址)和 `configuration.domain`(沙箱数据平面 DNS 后缀);API 主机必须等于该域名或其子域。Core 会在发送守护进程凭据或使用 envd 之前,拒绝数据平面域名位于所选后缀之外的沙箱。
+### OpenSandbox 配置 {#opensandbox-configuration}
+
+OpenSandbox 使用 `configuration.api_url` 和 `configuration.image`:前者是 OpenSandbox 服务器的 HTTP 或 HTTPS 源地址,不含路径、查询、片段或凭据;后者是按其 [adapter 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md#build-the-runtime-image)所述为 OpenSandbox 构建的 Runtime 镜像。API 密钥在 PostgreSQL 中加密,绝不会出现在响应、引导配置、命令参数或日志中。Core 保存选择时既不检查镜像也不检查服务器;镜像无法启动或服务器的出口 sidecar 未以 `dns+nft` 模式运行时,每次 Create 都会失败。
+
### 配置发现 {#configuration-discovery}
-`POST /core/v1/sandbox/providers/{provider}/discovery` 恰好接受 `configuration`、`credential` 和 `query` 对象,总大小最多为 64 KiB,运行时间最多为 30 秒。未知成员和 null 对象会被拒绝。凭据仅用于该请求,绝不会被存储或返回;发现操作不会保存任何内容、不会更改部署、不会分配任何资源,也绝不会证明某项选择会被准入。microsandbox 会以 400 `sandbox_operation_unsupported` 拒绝该操作。
+`POST /core/v1/sandbox/providers/{provider}/discovery` 恰好接受 `configuration`、`credential` 和 `query` 对象,总大小最多为 64 KiB,运行时间最多为 30 秒。未知成员和 null 对象会被拒绝。凭据仅用于该请求,绝不会被存储或返回;发现操作不会保存任何内容、不会更改部署、不会分配任何资源,也绝不会证明某项选择会被准入。microsandbox 和 OpenSandbox 会以 400 `sandbox_operation_unsupported` 拒绝该操作。
发布 `{"configuration": {…}, "credential": {…}, "query": {"field": "…", "path": […]}}`,其中 `field` 是提供商声明为 discovered 的字段,`path` 是前几级已选择的值。结果为 `{"options": [{"value": "…", "label": "…", "resources": {"cpus": 2, "memory_mib": 2048}, "complete": true}]}`,可能为空。`complete: true` 的选项可保存为该字段的值,其余选项打开下一级。其他任何字段返回 400 `invalid_request_error`。
@@ -91,12 +97,12 @@ E2B 把 `template` 声明为 discovered。空 `path` 列出该密钥的模板,
## 安全响应 {#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`。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`。OpenSandbox 返回 `configuration.api_url`、`configuration.image` 和空的 `metadata` 对象。microsandbox 返回空的 `configuration` 和 `metadata` 对象以及 `credential_configured: false`;未配置的部署则不含这两个对象。
- `metadata.template_build` 为 `{status, resources: {cpus, memory_mib, root_disk_mib}}`:这是保存选择时 Core 通过固定版本 SDK 读取的构建。GET 绝不会调用 E2B,因此 E2B 中断期间该操作仍保持低成本。验证仅接受 CPU 数量和内存与所选值一致的 `ready` 构建;`root_disk_mib` 是构建的原生磁盘大小,Core 不会强制执行该值。未知值为 null,`metadata: {}` 表示未记录任何观测,在不提供凭据的情况下提交完全相同的 PUT 也不会刷新它。
- 所选提供商声明 checkpoint 支持时,`suspension` 为 `{idle_seconds, retention_seconds}`,即 Core 的 [suspension policy](../../../docs/zh/sandbox-provider.md#suspension);其他部署(包括未配置的部署)返回 null。
- 请求中的 `resources` 和响应中的 `specification.resources` 是每个沙箱的限制。响应中的 `resources.allocations` 和 `resources.pending` 分别计算尚未释放的分配,以及尚未分配沙箱的待处理托管 Environment;`resources.running` 计算计算资源正在运行的未释放分配,因此不含已挂起的分配。
-- 未配置的部署具有空的提供商且没有 specification。microsandbox 使用 `mode: nodes`;E2B 使用 `mode: direct`,且没有合成节点。
+- 未配置的部署具有空的提供商且没有 specification。microsandbox 使用 `mode: nodes`;E2B 和 OpenSandbox 使用 `mode: direct`,且没有合成节点。
- `generation` 标识已保存的选择。`owner_epoch` 用于对执行所有者和节点连接进行栅栏隔离;它不能替代 `expected_generation`。
- `specification_digest` 是服务器对提供商、限制和派生的工作区能力回执的标识;注册过程会原样回显该值。
@@ -106,7 +112,7 @@ GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url
POST 会在持久保存候选配置之前对其进行验证,并且不会创建计算资源、Session 或模型请求。在当前代次上,完全相同的选择是无操作;使用旧代次则返回 409 `sandbox_generation_stale`,即使请求体相同也是如此。缺少前置条件或准备失败时,已提交的提供商保持不变。更改提供商前必须先重置。POST 还会在重置完成后使用该重置的新代次进行初始化。
-仅在没有重置进行时,PUT 才接受同一提供商。只发送一次观测到的代次,绝不自动重放结果不确定的写入操作。新分配使用新提交的 specification,现有分配则保留其不可变的部署代次。同提供商更改不会停用任何节点、令牌或所有者 epoch,也不会排空任何执行:microsandbox 节点在继续提供旧固定版本的同时独立准备新目标,而 E2B 更改会立即生效。
+仅在没有重置进行时,PUT 才接受同一提供商。只发送一次观测到的代次,绝不自动重放结果不确定的写入操作。新分配使用新提交的 specification,现有分配则保留其不可变的部署代次。同提供商更改不会停用任何节点、令牌或所有者 epoch,也不会排空任何执行:microsandbox 节点在继续提供旧固定版本的同时独立准备新目标,而 E2B 和 OpenSandbox 更改会立即生效。
### E2B 密钥替换 {#e2b-key-replacement}
@@ -124,6 +130,10 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建
不会返回提供商文本或凭据。写入操作及其 `change` 或 `replace_credential` 审计条目共用一个事务。替换操作会短暂对提供商调用设置栅栏,即使调用方已取消,也会等待辅助进程实际退出,并在提交前再次验证;辅助进程退出并不能证明远程 Create 已结算。栅栏和读取均有时间边界,失败时会保留旧密钥和生命周期。成功响应后,所有保留代次管理都使用已提交的密钥;只有在清理完成后,才能在 E2B 中撤销旧密钥,绝不能提前撤销。模板和资源更改不会排空生命周期。
+### OpenSandbox 密钥替换 {#opensandbox-key-replacement}
+
+请求规则与 E2B 密钥替换相同。OpenSandbox 服务器只有一个密钥,因此检查的是访问权限:候选密钥必须能通过每个保留代次的服务器的身份验证,并列出每个未释放分配的 sandbox。401 或 403 返回 400 `sandbox_credential_invalid`,任何其他失败返回 503 `sandbox_verification_unconfirmed`。服务地址和镜像的更改不会排空生命周期。
+
## 挂起策略 {#suspension-policy}
`PATCH /core/v1/sandbox/deployment` 接受 `{"idle_seconds": 300}`。`idle_seconds` 是必填整数,范围为 30–86400 秒,默认为 300。该设置保存在 Core 的数据库中。只有已配置且声明 checkpoint 支持的提供商可修改;未配置、不支持或正在重置的部署拒绝写入。成功返回当前部署视图,`suspension.idle_seconds` 反映已保存的值。
@@ -132,7 +142,7 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建
## 代次所有权与推出 {#generation-ownership-and-rollout}
-`runtime_deployment` 保存当前 specification。被取代的行仅保留不可变的 specification、构建和端点元数据,绝不保留另一个 E2B 密钥。E2B 分配在预留时绑定其代次;节点放置在调度器预留兼容容量时绑定,其分配会复制该代次,即使之后发生更新也是如此。检查、续期、命令和清理使用分配的原始 specification 和端点以及当前密钥;缺失代次绝不会回退到当前 specification。已释放代次的标识仍会保留。
+`runtime_deployment` 保存当前 specification。被取代的行仅保留不可变的 specification、构建和端点元数据,绝不保留另一个提供商密钥。直接分配(E2B、OpenSandbox)在预留时绑定其代次;节点放置在调度器预留兼容容量时绑定,其分配会复制该代次,即使之后发生更新也是如此。检查、续期、命令和清理使用分配的原始 specification 和端点以及当前密钥;缺失代次绝不会回退到当前 specification。已释放代次的标识仍会保留。
当一个代次为当前代次、被尚未释放的分配或放置引用,或者被尚未移除的节点固定时,该代次会保留。节点的持久服务固定状态可跨离线时段和零资源状态保留,并与当前就绪状态相互独立。回收操作与更新和准入共享部署锁,每轮最多删除 32 个符合条件的代次行。只有确认释放后,重置才会停用固定状态并清除被取代的行。
@@ -142,7 +152,7 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建
"rollout": {"state": "settled", "previous_generation_sandboxes": 3, "nodes": null}
```
-`previous_generation_sandboxes` 使用与资源总数相同的快照,统计旧代次中尚未释放的分配以及没有分配的待处理放置,绝不会重复计算同一资源。E2B 和未配置的部署返回 null `nodes`。节点部署将 `nodes` 返回为计数 `{ready, preparing, failed, unknown}`,每个尚未移除的节点恰好归入一个类别:
+`previous_generation_sandboxes` 使用与资源总数相同的快照,统计旧代次中尚未释放的分配以及没有分配的待处理放置,绝不会重复计算同一资源。直接(E2B、OpenSandbox)部署和未配置的部署返回 null `nodes`。节点部署将 `nodes` 返回为计数 `{ready, preparing, failed, unknown}`,每个尚未移除的节点恰好归入一个类别:
- 在当前连接上离线的节点为 `unknown`;
- 否则,当前连接和所有者 epoch 上的精确目标观测会给出 `ready`、`preparing` 或 `failed`,缺少观测则给出 `unknown`。
@@ -199,7 +209,7 @@ stateDiagram-v2
"offline_nodes": [{"node_id": "node-uuid", "name": "worker", "resources": 2}]}}
```
-一个数据库快照会先按 `cleanup`,再按 `busy` 或 `idle`,对每个尚未释放的分配和每个尚未分配沙箱的待处理托管 Environment 进行划分,因此 `busy + idle + cleanup == resources.allocations + resources.pending`。已删除或已过期且具有未释放回执的 Session 计入 cleanup。`offline_nodes` 是通过分配或活动放置持有资源的离线节点的完整列表,并按 ID 排序,其总和等于 `on_offline_nodes`;存在性依据节点是否[在线](node-generation-protocol.md#connection)判断,而不依据提供商就绪状态。直接 E2B 资源没有节点。离线资源会持续构成阻塞条件,直到清理确认其已释放。
+一个数据库快照会先按 `cleanup`,再按 `busy` 或 `idle`,对每个尚未释放的分配和每个尚未分配沙箱的待处理托管 Environment 进行划分,因此 `busy + idle + cleanup == resources.allocations + resources.pending`。已删除或已过期且具有未释放回执的 Session 计入 cleanup。`offline_nodes` 是通过分配或活动放置持有资源的离线节点的完整列表,并按 ID 排序,其总和等于 `on_offline_nodes`;存在性依据节点是否[在线](node-generation-protocol.md#connection)判断,而不依据提供商就绪状态。直接的 E2B 和 OpenSandbox 资源没有节点。离线资源会持续构成阻塞条件,直到清理确认其已释放。
当两个占用计数均达到零时,所有者会排空执行,并原子清除提供商、mode、specification、提供商 configuration、credential、metadata 和提供商 policy,停用节点和未使用的注册令牌,递增代次与所有者 epoch,并记录 `reset_complete`。安装身份和历史会保留。Core 会立即发布未配置状态,即使没有提供商也保留新代次,因此延迟加载无法恢复旧配置。使用 POST 和返回的代次即可重新配置,无需重启。
@@ -211,7 +221,7 @@ stateDiagram-v2
## 节点与分配 {#nodes-and-allocations}
-节点容量独立于部署 specification 设置。`POST /core/v1/sandbox/enrollment-tokens` 接受可选的 `max_active` 和 `max_retained`,并返回 `{token, expires_at, enrollment_id}`;`enrollment_id` 是该命令的公共句柄,绝不是凭据。Core 将令牌与发送的限制一同存储,省略的限制保持未设置。节点注册时,Core 把每个显式限制原样复制给节点,并把每个未设置的限制设为注册请求中的主机测量值与当前 specification 的[推荐容量](#recommended-capacity);限制未设置且没有主机测量值时,返回 409 `sandbox_node_host_unmeasured`,令牌保持未使用。只设置 `max_retained` 时,推导出的 `max_active` 不会超过它;只设置 `max_active` 时,推导出的 `max_retained` 会被提高到至少等于 `max_active`。microsandbox 会使用两个限制。E2B 没有节点,并返回 409 `sandbox_deployment_conflict`。节点不能提交限制,但未设置的限制取决于节点在注册时报告的主机测量值,范围限定在 1 到 1000000。其本地检查可以拒绝其无法运行的部署,但绝不会提高限制。显式值和之后的 PATCH 修改具有约束力:节点注册之后,Core 不再推导。[节点容量](../../../docs/zh/configuration.md#node-capacity)为操作员说明了这些限制。
+节点容量独立于部署 specification 设置。`POST /core/v1/sandbox/enrollment-tokens` 接受可选的 `max_active` 和 `max_retained`,并返回 `{token, expires_at, enrollment_id}`;`enrollment_id` 是该命令的公共句柄,绝不是凭据。Core 将令牌与发送的限制一同存储,省略的限制保持未设置。节点注册时,Core 把每个显式限制原样复制给节点,并把每个未设置的限制设为注册请求中的主机测量值与当前 specification 的[推荐容量](#recommended-capacity);限制未设置且没有主机测量值时,返回 409 `sandbox_node_host_unmeasured`,令牌保持未使用。只设置 `max_retained` 时,推导出的 `max_active` 不会超过它;只设置 `max_active` 时,推导出的 `max_retained` 会被提高到至少等于 `max_active`。microsandbox 会使用两个限制。E2B 和 OpenSandbox 没有节点,并返回 409 `sandbox_deployment_conflict`。节点不能提交限制,但未设置的限制取决于节点在注册时报告的主机测量值,范围限定在 1 到 1000000。其本地检查可以拒绝其无法运行的部署,但绝不会提高限制。显式值和之后的 PATCH 修改具有约束力:节点注册之后,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`、主机测量值 `cpu_count`、`available_memory_bytes` 和 `available_disk_bytes`、`rollout`、`recommended_capacity`、`enrollment_id` 和 `core_url`。`recommended_capacity` 是 `{max_active, max_retained}`,即节点最新健康状态与当前 specification 的[推荐容量](#recommended-capacity);在节点报告 CPU 数量或有效核数以及总内存之前,以及当前 specification 未通过校验时,为 null。`enrollment_id` 是注册该节点的命令的句柄;如果 Core 没有该句柄,则为 null。`core_url` 是注册时的安装公开 URL;`core_url` 与当前公开 URL 不同的节点不会收到新放置。已放置到该节点的工作会继续在那里完成,包括已放置但尚未获得分配的 Environment;只要旧地址仍能访问 Core,其保留沙箱仍可恢复。参见[更改公开 URL](../../../docs/zh/getting-started/public-address.md#changing-the-public-url)。
@@ -234,23 +244,23 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。
某些字段在不同提供商中保持同一名称,但含义不同或不适用。部署字段来自 `GET /core/v1/sandbox/deployment`,节点和分配字段来自节点路由,Runtime 字段来自 [Runtime 遥测 API](runtime-observability-api.md);其中 `disk` 仅出现在观测列表中,历史则在 [Session Runtime 历史](runtime-observability-api.md#session-runtime-history)下说明。
-| 字段 | microsandbox | E2B |
-| --- | --- | --- |
-| 独立工作区文件系统 | 必需:[Kernel NFS adapter](../../../docs/zh/workspace-provider.md#kernel-nfs-adapter),与计算资源分开配置 | 不支持 |
-| 部署 `specification.network` | `public` 和 `domains` | 仅 `public`;`domains` 必须为空,私有网络不可达 |
-| 部署 `specification.resources` | `cpus`、`memory_mib`、`root_disk_mib`,以及作为配额请求的 `environment_disk_mib` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 |
-| 部署 `metadata.template_build` | 不存在:`metadata` 为空 | Core 保存选择时读取到的构建 |
-| 部署 `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`;Core 用 SDK 的原生 pause 暂停空闲的 E2B 沙箱 |
-| 部署 `resources.allocations`、`resources.pending` | 所有节点的总数 | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment |
-| 注册令牌 `max_active`、`max_retained` | 两个限制均适用;省略的限制在注册时推导 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 |
-| 节点列表和详情 | 已注册节点 | 空列表;详情返回 404 |
-| 节点 `retained`、`snapshots`、`max_retained` | 已暂停沙箱数为 `retained` 减去 `active`;`snapshots` 还包含恢复或清理期间保留的快照 | 不适用 |
-| 节点 `host.available_disk_bytes` | 节点状态目录所在文件系统的可用空间;沙箱磁盘有自己的配额 | 不适用 |
-| 分配 `compute_phase`、`compute_phase_changed_at` | 包含 `suspended`;时间表示当前阶段的起点,不是快照到期时间 | 不适用:没有节点分配 |
-| Runtime 观测 `memory` | 来自 VM:内存限制和使用量 | 来自 E2B 指标:内存使用量和限制 |
-| Runtime 观测 `disk` | `null` | E2B `diskUsed` 和 `diskTotal`;模板未报告时为 `null` |
-| Runtime 观测 `lifecycle_state: sleeping` | 暂停期间 | 从不 |
-| Runtime 历史 CPU | 根据累计 CPU 时间派生 | 每个时间桶中 E2B 所报告使用率比值的平均值 |
+| 字段 | microsandbox | E2B | OpenSandbox |
+| --- | --- | --- | --- |
+| 独立工作区文件系统 | 必需:[Kernel NFS adapter](../../../docs/zh/workspace-provider.md#kernel-nfs-adapter),与计算资源分开配置 | 不支持 | 不支持 |
+| 部署 `specification.network` | `public` 和 `domains` | 仅 `public`;`domains` 必须为空,私有网络不可达 | 仅 `public`;`domains` 必须为空 |
+| 部署 `specification.resources` | `cpus`、`memory_mib`、`root_disk_mib`,以及作为配额请求的 `environment_disk_mib` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`,作为资源限制应用;无磁盘字段 |
+| 部署 `metadata.template_build` | 不存在:`metadata` 为空 | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 |
+| 部署 `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`;Core 用 SDK 的原生 pause 暂停空闲的 E2B 沙箱 | `null`:不支持 checkpoint,空闲沙箱继续运行 |
+| 部署 `resources.allocations`、`resources.pending` | 所有节点的总数 | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | Core 中尚未释放的 OpenSandbox 沙箱,以及正在等待沙箱的托管 Environment |
+| 注册令牌 `max_active`、`max_retained` | 两个限制均适用;省略的限制在注册时推导 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;OpenSandbox 没有节点 |
+| 节点列表和详情 | 已注册节点 | 空列表;详情返回 404 | 空列表;详情返回 404 |
+| 节点 `retained`、`snapshots`、`max_retained` | 已暂停沙箱数为 `retained` 减去 `active`;`snapshots` 还包含恢复或清理期间保留的快照 | 不适用 | 不适用 |
+| 节点 `host.available_disk_bytes` | 节点状态目录所在文件系统的可用空间;沙箱磁盘有自己的配额 | 不适用 | 不适用 |
+| 分配 `compute_phase`、`compute_phase_changed_at` | 包含 `suspended`;时间表示当前阶段的起点,不是快照到期时间 | 不适用:没有节点分配 | 不适用:没有节点分配 |
+| Runtime 观测 `memory` | 来自 VM:内存限制和使用量 | 来自 E2B 指标:内存使用量和限制 | 来自 execd 指标:内存使用量和限制 |
+| Runtime 观测 `disk` | `null` | E2B `diskUsed` 和 `diskTotal`;模板未报告时为 `null` | `null` |
+| Runtime 观测 `lifecycle_state: sleeping` | 暂停期间 | 从不 | 从不 |
+| Runtime 历史 CPU | 根据累计 CPU 时间派生 | 每个时间桶中 E2B 所报告使用率比值的平均值 | 每个时间桶中 execd 所报告使用率比值的平均值 |
## 错误 {#errors}
diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md
index 8d459bd18..3c51807d7 100644
--- a/docs/zh/api/public-agent-api.md
+++ b/docs/zh/api/public-agent-api.md
@@ -1,7 +1,7 @@
---
title: "Agents API 指南"
source: docs/api/public-agent-api.md
-source_hash: bc22d7ef93e4284b122c25f39b15acb53eec314005b5273f9c9456638e727c67
+source_hash: a32f91dfee6a8eb9c6001310a1d357f5d241a55da12437870ce1c0383fcc3dbc
---
Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。
@@ -259,7 +259,7 @@ agents "/agents/sessions" -H "Idempotency-Key: $(uuidgen)" -d '{
| `environment.type` | 运行位置 | 备注 |
| --- | --- | --- |
-| `openai_hosted` | Core 在某个节点或 E2B 上创建的沙箱;容量由管理员提供 | 可选 `network`、`packages`、`files`、`skills`、`plugins`、`env`、`capability_directories`、`setup_commands`,也可指定模板 |
+| `openai_hosted` | Core 在某个节点、E2B 或 OpenSandbox 上创建的沙箱;容量由管理员提供 | 可选 `network`、`packages`、`files`、`skills`、`plugins`、`env`、`capability_directories`、`setup_commands`,也可指定模板 |
| `self_hosted` | 你自己的 Linux、macOS 或 Windows 计算机 | 要求提供绝对路径 `workspace_directory`。Skills、软件包、文件或模板应放在 `x_agents_core.environment` 中。响应会在 `x_agents_core.installation` 中携带安装命令;请参阅 [运行自托管 Session](../getting-started/self-hosted.md)。Session 会自带自己的 `model_provider` |
| `none` | 由操作员注册的设备连接,无工作区 | `input` 为必填。模型来自安装的默认配置;如果未配置默认模型,则来自设备 |
diff --git a/docs/zh/concepts.md b/docs/zh/concepts.md
index bf032f634..6cfa7becf 100644
--- a/docs/zh/concepts.md
+++ b/docs/zh/concepts.md
@@ -1,7 +1,7 @@
---
title: "概念"
source: docs/concepts.md
-source_hash: 20e710ce0826e741123fcdcb5d04df5d92b39706d82c89bc6591dae958c3f3bb
+source_hash: 2b9bc8e73650b5f1a298ec55acda7868e014023935f6d074a109ab81b1d6923f
---
项目是 OpenAgentCore 的执行租户。应用使用其 API 密钥;运维人员使用独立的 Core 密钥管理安装实例。[API 命名空间和凭据](api/index.md)将每类调用方映射到对应命名空间和凭据。
@@ -28,7 +28,7 @@ source_hash: 20e710ce0826e741123fcdcb5d04df5d92b39706d82c89bc6591dae958c3f3bb
Runtime daemon 在 Linux、macOS 和 Windows 上运行。原生平台行为由 Runtime 及其 Harness adapter 负责;托管沙箱提供商运行 Linux 环境。
-工具以启动 daemon 的账户权限运行。daemon 不增加文件系统、权限或网络隔离。应使用外层 Environment 提供隔离:托管 E2B 或 microsandbox 环境,或在自托管机器的 Runtime 外使用容器或虚拟机。认证、私有存储、锁和进程清理保护连接与生命周期,但以同一用户运行的工具可以访问 Runtime 数据。
+工具以启动 daemon 的账户权限运行。daemon 不增加文件系统、权限或网络隔离。应使用外层 Environment 提供隔离:托管 microsandbox、E2B 或 OpenSandbox 环境,或在自托管机器的 Runtime 外使用容器或虚拟机。认证、私有存储、锁和进程清理保护连接与生命周期,但以同一用户运行的工具可以访问 Runtime 数据。
## 秘密与审计 {#secrets-and-audit}
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md
index 3ac843b03..83c2437d7 100644
--- a/docs/zh/configuration.md
+++ b/docs/zh/configuration.md
@@ -1,7 +1,7 @@
---
title: "配置参考"
source: docs/configuration.md
-source_hash: 12597ad7cc56e580428774d31f6e7f89deb32052aa753ed988d731b9828a9f8b
+source_hash: 1cd0723f9215ffa38fbc155edac30bc7c90a9761b49dcef88668e83e1e0fe720
---
Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类:
@@ -59,8 +59,8 @@ Core 安装的每项设置、其归属位置以及修改方式。安装程序写
| 设置 | Web 中的位置 | Core API | 注意事项 |
| --- | --- | --- | --- |
-| 沙箱提供商:microsandbox 或 E2B | **设置** → **沙箱与调度**:设置向导,最后点击 **保存配置** | `/core/v1/sandbox/deployment` | 每个安装只能使用一个沙箱提供商,在首次登录后选择。要改用其他提供商,必须先执行 **重置部署**;请参阅[更改沙箱配置](getting-started/nodes.md#change-the-sandbox-configuration) |
-| 沙箱大小、E2B 密钥和模板构建 | **设置** → **沙箱与调度** → **修改资源** | `/core/v1/sandbox/deployment` | Web 会推荐 [提供商声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小。E2B 密钥仅可写入,并且已加密 |
+| 沙箱提供商:microsandbox、E2B 或 OpenSandbox | **设置** → **沙箱与调度**:设置向导,最后点击 **保存配置** | `/core/v1/sandbox/deployment` | 每个安装只能使用一个沙箱提供商,在首次登录后选择。要改用其他提供商,必须先执行 **重置部署**;请参阅[更改沙箱配置](getting-started/nodes.md#change-the-sandbox-configuration) |
+| 沙箱大小,以及 E2B 或 OpenSandbox 连接 | **设置** → **沙箱与调度** → **修改资源** | `/core/v1/sandbox/deployment` | Web 会推荐 [提供商声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小。E2B 和 OpenSandbox 密钥仅可写入,并且已加密 |
| 节点及其容量 | **节点**:**添加节点**、**编辑节点** 和移除 | `/core/v1/sandbox/enrollment-tokens`、`/core/v1/sandbox/nodes` | 请参阅[节点容量](#node-capacity)和[添加和管理节点](getting-started/nodes.md) |
| 沙箱网络上限 | **设置** → **沙箱与调度** → **网络**:**允许访问公网**、**额外允许的域名** → **保存** | `/core/v1/sandbox/deployment` | `network.public` 和 `network.domains`;请参阅[网络](../../contracts/agents-api/zh/sandbox-deployment.md#network)。已有沙箱保持启动时的上限。提供商不支持的控件会被禁用 |
| 空闲挂起 | **设置** → **沙箱与调度** → **自动挂起** → **空闲多久后挂起(秒)** → **保存** | `PATCH /core/v1/sandbox/deployment` | 整个部署统一生效;参阅[挂起策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy) |
diff --git a/docs/zh/getting-started/install.md b/docs/zh/getting-started/install.md
index 01eb5f039..3d316e295 100644
--- a/docs/zh/getting-started/install.md
+++ b/docs/zh/getting-started/install.md
@@ -1,7 +1,7 @@
---
title: "安装 Core 和 Web"
source: docs/getting-started/install.md
-source_hash: a0bb75ea6ce1671d2d4bfb2880e2b12ea8555cade40218f0cc2a4f43a829508f
+source_hash: 9c401648161e7702931e321ae7e783b52621d37f4d41c0328d76044407bed1a1
---
本指南带运维人员从一台空主机到可用的安装:在 Docker 下运行的 Core、Web 控制台和 PostgreSQL,一个沙箱提供商,一个默认模型和第一个项目 API 密钥。Web 的 **概览** 通过 **新手引导** 清单跟踪同样的步骤。
@@ -78,7 +78,7 @@ curl -fsSL https://openagentcore.dev/install.sh | bash -s -- --public-url https:
## 添加沙箱容量 {#add-sandbox-capacity}
-安装程序不选择沙箱提供商。请[选择沙箱提供商](./sandbox-backends.md):在自有 Linux 节点上运行 microsandbox,并使用[设置工作区存储](./workspace-storage.md)中的工作区存储,是自管部署;E2B 不需要节点。应用也可以在自己的机器上运行[自托管 Session](./self-hosted.md)。
+安装程序不选择沙箱提供商。请[选择沙箱提供商](./sandbox-backends.md):在自有 Linux 节点上运行 microsandbox,并使用[设置工作区存储](./workspace-storage.md)中的工作区存储,是自管部署;E2B 和 OpenSandbox 不需要节点。应用也可以在自己的机器上运行[自托管 Session](./self-hosted.md)。
## 设置默认模型 {#set-a-default-model}
diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md
index f18465286..e556f45e3 100644
--- a/docs/zh/getting-started/nodes.md
+++ b/docs/zh/getting-started/nodes.md
@@ -1,7 +1,7 @@
---
title: "添加和管理节点"
source: docs/getting-started/nodes.md
-source_hash: cb146086d2a7a4f30b96f53afea066519bfd8635bac4f16794ab27dda9e32207
+source_hash: 73323a790b799090c067a944bc2c39b02f20d412c7bca04c83f8d072f6204103
---
节点是一台 Linux 主机,为 Core 托管 Session 运行沙箱。本指南面向在沙箱提供商(参阅[选择沙箱提供商](./sandbox-backends.md))为 microsandbox 时添加、修改和移除节点的运维人员;E2B 不需要节点。
@@ -99,7 +99,7 @@ root 只准备账号、组和服务单元;其他操作都以 `oac-node` 在没
沙箱设置适用于整个安装,节点跟随这些设置。
-- **规格、E2B 密钥或模板构建。** 打开 **设置** → **沙箱与调度** → **修改资源**,修改并保存。已有沙箱保持其配置。每个节点继续服务旧配置,同时准备新配置;**节点** 展示进度:**正在准备目标配置**、**目标配置已就绪**,或带[原因](#readiness-codes)的 **目标配置准备失败**。Core 优先在已准备好新配置的节点上放置新 Session;这些节点没有容量时,会使用仍服务旧配置的节点。E2B 变更立即生效。
+- **规格,或 E2B、OpenSandbox 连接。** 打开 **设置** → **沙箱与调度** → **修改资源**,修改并保存。已有沙箱保持其配置。每个节点继续服务旧配置,同时准备新配置;**节点** 展示进度:**正在准备目标配置**、**目标配置已就绪**,或带[原因](#readiness-codes)的 **目标配置准备失败**。Core 优先在已准备好新配置的节点上放置新 Session;这些节点没有容量时,会使用仍服务旧配置的节点。E2B 和 OpenSandbox 变更立即生效。
- **容量与空闲挂起。** 在 **节点** 中通过 **编辑节点** 修改节点的活跃和保留上限,或用 **使用推荐值** 填入推荐值。在 **设置** → **沙箱与调度** 中,通过 **自动挂起** → **空闲多久后挂起(秒)** → **保存** 修改整个部署的[空闲策略](../../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)。
- **沙箱提供商。** 同一页面选择 **重置部署**。自动重置立即归档空闲托管 Session,并让运行中的工作在设定截止时间前完成;强制重置立即取消工作。离线节点必须恢复连接,让 Core 确认清理。重置完成时,Core 已撤销所有节点和未使用命令:配置新的沙箱提供商后重新添加节点。**取消重置** 停止剩余工作;已归档 Session 仍保持归档。
diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md
index 7acc55a3d..5440783a8 100644
--- a/docs/zh/getting-started/operations.md
+++ b/docs/zh/getting-started/operations.md
@@ -1,7 +1,7 @@
---
title: "运维"
source: docs/getting-started/operations.md
-source_hash: 7ccb5fea5da457afd341300bcd026dc20683625d73b57a494ab50b56c67b33e5
+source_hash: 680e43de496964a8a9331bd712347686ed07cafcd23db0d1e38bcbb897e53f99
---
Core 安装运维人员的日常任务:`oac` 命令、服务健康状态、Core 密钥、备份和卸载。节点有[专门的指南](./nodes.md),[故障排查](./troubleshooting.md)列出了安装程序和访问方面的提示信息。设置见[配置参考](../configuration.md)。
@@ -209,7 +209,7 @@ Windows 上运行 `& "$HOME/.oac/core/oac.exe" uninstall`。命令会展示 Comp
全部数据随之删除:项目和 API 密钥、Session 历史、存储的凭据和 Core 密钥。要保留数据,请用 `docker compose stop` 停止安装,或先[备份](#back-up)。
-卸载不停止沙箱:节点沙箱在节点继续运行,E2B 沙箱在 E2B 继续运行并计费。Core 仍运行时,归档它们的 Session,或[重置部署](nodes.md#change-the-sandbox-configuration)并等待完成。
+卸载不停止沙箱:节点沙箱在节点继续运行,E2B 沙箱在 E2B 继续运行并计费,OpenSandbox 沙箱继续运行到一小时租约结束。Core 仍运行时,归档它们的 Session,或[重置部署](nodes.md#change-the-sandbox-configuration)并等待完成。
如果 Core 已不存在,按那里的说明在各节点主机上带 `--force` 运行节点卸载命令,使用 `oac uninstall` 打印的安装 ID,或节点单元名 `oac-node-.service` 中的安装 ID。
diff --git a/docs/zh/getting-started/public-address.md b/docs/zh/getting-started/public-address.md
index e09056b35..21c345808 100644
--- a/docs/zh/getting-started/public-address.md
+++ b/docs/zh/getting-started/public-address.md
@@ -1,7 +1,7 @@
---
title: "配置公开地址"
source: docs/getting-started/public-address.md
-source_hash: 29563e665c6fdcecc63fe7813e7138d100a4dc4f98389625cbdb6fd25d924a21
+source_hash: ba97e492fde66f4bbc0c6c4af68b9910dcf28b57228950988aaa68785cd6d5c6
---
设置浏览器、节点、沙箱和应用访问 Core 所用的唯一源地址,并在其前面放置带 HTTPS 的反向代理。本指南面向将 Core 暴露到其所在机器之外的运维人员。
@@ -16,6 +16,7 @@ source_hash: 29563e665c6fdcecc63fe7813e7138d100a4dc4f98389625cbdb6fd25d924a21
| 本地网络 | `http://:8080`,`--host` 为 `0.0.0.0` 且默认路由带有私有网络地址时由安装程序选用 |
| 本地网络之外 | 反向代理提供的 HTTPS 源地址,例如 `https://core.example` |
| E2B 提供商 | 可从互联网访问且不是回环地址的 HTTPS 源地址,因为 E2B 沙箱会从 E2B 云端调用 Core |
+| OpenSandbox 提供商 | OpenSandbox 服务器的沙箱可访问且不是回环地址的源地址 |
安装时用 `--public-url` 传入该源地址,或按[更改公开 URL](#changing-the-public-url)稍后修改。使用选项安装,参阅[安装选项](./install-options.md#pass-flags)。
diff --git a/docs/zh/getting-started/quickstart.md b/docs/zh/getting-started/quickstart.md
index 25e35b55c..1d634672f 100644
--- a/docs/zh/getting-started/quickstart.md
+++ b/docs/zh/getting-started/quickstart.md
@@ -1,7 +1,7 @@
---
title: "运行第一个 Session"
source: docs/getting-started/quickstart.md
-source_hash: 8795e92d7eb61f7f946e96bcd3966b16e2eb800a8f36ee1b1de524df106c868f
+source_hash: 784dd62e274e49975af1bc1b136cec5d6028ed0896cbe6e9345d4c686d7423df
---
使用官方 OpenAI Python SDK 通过 Core 运行 Agent:用项目 API 密钥连接,启动一个会写入文件的 Session,并读取结果。你需要管理员提供的密钥和 API 基础 URL。
@@ -9,7 +9,7 @@ source_hash: 8795e92d7eb61f7f946e96bcd3966b16e2eb800a8f36ee1b1de524df106c868f
开始之前,请准备:
- 项目 API 密钥和 API 基础 URL([签发项目 API 密钥](install.md#issue-a-project-api-key));
-- 已就绪的节点或 E2B 沙箱提供商,让 Core 能运行 Agent([添加和管理节点](nodes.md));
+- 已就绪的节点,或 E2B、OpenSandbox 沙箱提供商,让 Core 能运行 Agent([添加和管理节点](nodes.md));
- Python 3.9 或更高版本;
- 一个模型:安装的默认模型,或你自己的提供商的模型 ID、基础 URL 和 API 密钥。[模型执行](../../../contracts/agents-api/zh/model-execution.md#saved-defaults-and-precedence)说明 Session 使用哪个模型,以及每个 Harness 支持哪些协议。
@@ -54,7 +54,7 @@ curl "$OPENAI_BASE_URL/agents" -H "OpenAI-Beta: agents=v1" \
## 2. 启动 Session {#_2-start-a-session}
-Session 是拥有独立工作区的一次 Agent 对话。使用 `openai_hosted` 时,Core 在节点或 E2B 上创建沙箱,并在其中启动 Harness。
+Session 是拥有独立工作区的一次 Agent 对话。使用 `openai_hosted` 时,Core 在节点、E2B 或 OpenSandbox 上创建沙箱,并在其中启动 Harness。
要使用自己的提供商而不是安装的默认模型,请先导出其设置,然后运行第二个标签页的代码:
diff --git a/docs/zh/getting-started/sandbox-backends.md b/docs/zh/getting-started/sandbox-backends.md
index 37284c487..35043d131 100644
--- a/docs/zh/getting-started/sandbox-backends.md
+++ b/docs/zh/getting-started/sandbox-backends.md
@@ -1,33 +1,33 @@
---
title: "选择沙箱提供商"
source: docs/getting-started/sandbox-backends.md
-source_hash: ff785ba90caac12dda09f1c7560152bb9dbbf3cd4ff7427a83efc87680677403
+source_hash: 24c4e2254f1278e8fa7629385232e672c3dfb64ed252aaf4a3058a1e2a435d37
---
-比较 microsandbox 和 E2B,然后在 Web 中选择一个。本指南面向已完成[安装 Core 和 Web](./install.md)、需要为 Core 托管的 Session 提供计算资源的运维人员。
+比较 microsandbox、E2B 和 OpenSandbox,然后在 Web 中选择一个。本指南面向已完成[安装 Core 和 Web](./install.md)、需要为 Core 托管的 Session 提供计算资源的运维人员。
安装程序不选择提供商。一个安装只使用一个提供商。以后要更改,请先重置部署;参阅[更改沙箱配置](./nodes.md#change-the-sandbox-configuration)。
## 比较各提供商 {#compare-the-backends}
-| | microsandbox | E2B |
-| --- | --- | --- |
-| 沙箱运行位置 | Linux 节点上的 microVM | E2B 云端 |
-| 需要添加节点 | 是 | 否 |
-| 你需要提供 | 带 systemd 和 `/dev/kvm` 的 Linux amd64 主机 | E2B API 密钥和模板;Core 的公共 HTTPS URL |
-| 隔离 | 每个沙箱有独立内核 | 由 E2B 提供 |
-| 空闲沙箱可挂起和恢复 | 是,通过检查点 | 是,由 E2B 原地暂停 |
-| 工作区存储 | 必需,使用 NFS | 自有磁盘 |
-| 磁盘限制 | 根磁盘和工作区配额 | 由模板设置 |
+| | microsandbox | E2B | OpenSandbox |
+| --- | --- | --- | --- |
+| 沙箱运行位置 | Linux 节点上的 microVM | E2B 云端 | 你的 OpenSandbox 服务器,运行在 Docker 或 Kubernetes 上 |
+| 需要添加节点 | 是 | 否 | 否 |
+| 你需要提供 | 带 systemd 和 `/dev/kvm` 的 Linux amd64 主机 | E2B API 密钥和模板;Core 的公共 HTTPS URL | 服务器的 URL 和 API 密钥,以及为其构建的 Runtime 镜像;其沙箱可访问的 Core 地址 |
+| 隔离 | 每个沙箱有独立内核 | 由 E2B 提供 | 由 OpenSandbox 的 runtime 提供:容器,或安全容器 runtime |
+| 空闲沙箱可挂起和恢复 | 是,通过检查点 | 是,由 E2B 原地暂停 | 否;继续运行 |
+| 工作区存储 | 必需,使用 NFS | 自有磁盘 | 自有磁盘 |
+| 磁盘限制 | 根磁盘和工作区配额 | 由模板设置 | 由 OpenSandbox 的 runtime 设置 |
字段级差异见[部署契约](../../../contracts/agents-api/zh/sandbox-deployment.md#what-each-field-means-per-sandbox-provider)。节点的主机要求见[添加和管理节点](./nodes.md#host-requirements)。
-沙箱节点运行在 Linux amd64 上。Core 运行在 macOS 或 Windows 上时,请连接 Linux 节点或使用 E2B。应用也可以在自己的机器上运行[自托管 Session](./self-hosted.md),无需提供商。
+沙箱节点运行在 Linux amd64 上。Core 运行在 macOS 或 Windows 上时,请连接 Linux 节点或使用 E2B 或 OpenSandbox。应用也可以在自己的机器上运行[自托管 Session](./self-hosted.md),无需提供商。
## 在 Web 中选择提供商 {#select-the-backend-in-web}
-1. 打开 **设置 → 沙箱与调度**。设置向导会询问 **沙箱在哪里运行?**:**自有机器**(microsandbox)或 **E2B**。
-2. 选择 **自有机器** 时,选择沙箱规格。Web 会把[提供商声明的默认大小](../sandbox-provider.md#register-the-provider-kind)推荐为 **标准**,审阅页的 **高级设置** 可填写确切的 CPU、内存和磁盘值。E2B 不声明默认大小,所以它的审阅页没有 **高级设置**。
+1. 打开 **设置 → 沙箱与调度**。设置向导会询问 **沙箱在哪里运行?**:**自有机器**(microsandbox)、**E2B** 或 **OpenSandbox**。
+2. 选择 **自有机器** 时,选择沙箱规格。Web 会把[提供商声明的默认大小](../sandbox-provider.md#register-the-provider-kind)推荐为 **标准**,审阅页的 **高级设置** 可填写确切的 CPU、内存和磁盘值。E2B 不声明默认大小,所以它的审阅页没有 **高级设置**;OpenSandbox 的审阅页推荐 2 个 CPU 和 4 GiB 内存,并有 **高级设置**。
3. 选择 **保存配置**。该选择保存在 Core 的数据库中。
选择 **自有机器** 后,继续阅读[添加和管理节点](./nodes.md)。使用 microsandbox 时,请先[设置工作区存储](./workspace-storage.md)。microsandbox 节点间的检查点恢复,参阅[跨节点恢复检查点](./nodes.md#checkpoint-recovery-across-nodes)。
@@ -44,6 +44,16 @@ E2B 不需要节点,但其沙箱会从 E2B 云端调用 Core,因此 Core 需
密钥和模板规则(包括密钥替换)见[初始设置与同提供商变更](../../../contracts/agents-api/zh/sandbox-deployment.md#initial-setup-and-same-provider-changes)。
+## 使用 OpenSandbox {#use-opensandbox}
+
+OpenSandbox 不需要节点。其沙箱会调用 Core 的公共地址,该地址不能是回环地址;参阅[配置公开地址](./public-address.md)。
+
+1. 按 [OpenSandbox adapter 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md)准备服务器并构建 Runtime 镜像。
+2. 在设置向导中选择 **OpenSandbox**。在 **连接 OpenSandbox** 页输入 **服务地址**、**Runtime 镜像** 和 **API 密钥**。
+3. 在 **确认并保存** 页,保留推荐的规格,或在 **高级设置** 中修改,然后选择 **保存配置**。Core 加密保存该密钥,且不会再返回它。
+
+Core 保存时不会联系服务器。URL、密钥或镜像有误,会在首个 Session 的沙箱创建时暴露。编辑已保存的 OpenSandbox 配置时,**API 密钥** 留空即保留已保存的密钥。
+
## 后续步骤 {#next-steps}
- microsandbox:[添加和管理节点](./nodes.md)。
diff --git a/docs/zh/getting-started/workspace-storage.md b/docs/zh/getting-started/workspace-storage.md
index 61651171c..12f6347c7 100644
--- a/docs/zh/getting-started/workspace-storage.md
+++ b/docs/zh/getting-started/workspace-storage.md
@@ -1,12 +1,12 @@
---
title: "设置工作区存储"
source: docs/getting-started/workspace-storage.md
-source_hash: 4a9e08947b7e359b1bb725217ee097c3df3283054be986219be421911ee930a5
+source_hash: 158aa4bffbb61f61f607976f25f568c80e4fc0d0ab916840ee81f79799b1b410
---
将 Session 工作区保存在沙箱节点之外的 NFS 文件系统上,可选择是否设置每个工作区的配额。microsandbox 的每个 Environment 都运行在此存储上,因此请在配置 microsandbox 后端之前先选择它。
-支持的组合是 microsandbox 与[内核 NFS 适配器](../workspace-provider.md#kernel-nfs-adapter)。E2B 使用自有磁盘,不支持独立工作区存储;参阅[选择沙箱提供商](./sandbox-backends.md)。
+支持的组合是 microsandbox 与[内核 NFS 适配器](../workspace-provider.md#kernel-nfs-adapter)。E2B 和 OpenSandbox 使用自有磁盘,不支持独立工作区存储;参阅[选择沙箱提供商](./sandbox-backends.md)。
```mermaid
flowchart LR
diff --git a/docs/zh/runtime-bootstrap.md b/docs/zh/runtime-bootstrap.md
index 95f02c013..0e8fc535e 100644
--- a/docs/zh/runtime-bootstrap.md
+++ b/docs/zh/runtime-bootstrap.md
@@ -1,7 +1,7 @@
---
title: "Runtime 引导"
source: docs/runtime-bootstrap.md
-source_hash: 991f76a7edfe957cb0558a490a2ec4f094148bba85980aea386b85c802db9e08
+source_hash: 903a8abffdaacc581cd85c2c4dae890c492e15647762d7a917ff3005f4770428
---
Runtime 引导协议连接沙箱提供商与其启动的托管 Runtime。提供商向 Runtime 交付一个引导文件,该文件是此次启动中 Runtime 唯一的认证输入,也是其 Environment 绑定的唯一来源。
@@ -76,6 +76,7 @@ oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json
| 提供商 | 交付方式 |
| --- | --- |
| microsandbox、E2B | 在启动同一命令之前交付文件。 |
+| OpenSandbox | 通过 execd 上传文件,然后启动同一命令。 |
## 验证 {#verification}
diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md
index 1a7872e40..875f5c195 100644
--- a/docs/zh/sandbox-provider.md
+++ b/docs/zh/sandbox-provider.md
@@ -1,15 +1,15 @@
---
title: "沙箱提供商协议"
source: docs/sandbox-provider.md
-source_hash: 6e00a807390c887b3458acf9be3349402c8d34e08c3e7426af2e52510b6eadd0
+source_hash: 124be5328278155cd381287612bd8783edf11655eba9c352248a2b494e8170c5
---
-沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox 和 E2B 实现了该协议。
+沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox、E2B 和 OpenSandbox 实现了该协议。
| | |
| --- | --- |
| 调用方 | Core 的 runtime manager 与 execution Worker |
-| 实现 | microsandbox(node adapter)和 E2B(direct adapter);见[参考 adapter](#reference-adapters) |
+| 实现 | microsandbox(node adapter)、E2B 和 OpenSandbox(direct adapter);见[参考 adapter](#reference-adapters) |
| 代码 | `sandbox_provider.go` 中的 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go),以及 setup 阶段的 `ConfigurationAdapter` |
| 传输 | direct adapter 在进程内被调用。node adapter 运行在沙箱节点上,通过[沙箱节点协议](../../contracts/agents-api/zh/node-generation-protocol.md#provider-requests)接收同样的调用 |
| 版本规则 | 进程内接口没有 wire 版本。沙箱节点协议和各 helper 协议要求精确版本匹配 |
@@ -156,7 +156,7 @@ configuration adapter 必须非 nil,包括其具体值。每个 `Configuration
具有凭据的 direct adapter 在替换 key 前验证全部保留 generation 与 allocation reference。公共 `sandbox.CallFence` 排除原生调用并等待 helper 完成,包括调用方已超时的调用;execution 调用已准备的 verification 和 fencing callback,不按厂商分支。
-厂商部署验证和 SDK setup 留在构造边界,构造不创建 Environment。node-local adapter 的 `sandbox.Built` 返回提供商、probe、installation identity、backend fingerprint 和 specification digest;helper 可能比调用方存活更久时,还返回 `Quiescent` 检查,generation 回收会等待它。factory 还返回 close 函数。`execution.RuntimeProvider` 将 adapter 绑定到 kind、installation ID、backend fingerprint、generation、mode 和 node ownership;选择由数据库负责,内存副本不构成另一权限来源。microsandbox 在 node 上运行,E2B 直接构造。node proxy 仅对注册声明支持 checkpoint 的 backend 暴露 checkpoint 操作,公共 lifecycle 通过 checkpoint 声明准入 suspension,不通过提供商名称。
+厂商部署验证和 SDK setup 留在构造边界,构造不创建 Environment。node-local adapter 的 `sandbox.Built` 返回提供商、probe、installation identity、backend fingerprint 和 specification digest;helper 可能比调用方存活更久时,还返回 `Quiescent` 检查,generation 回收会等待它。factory 还返回 close 函数。`execution.RuntimeProvider` 将 adapter 绑定到 kind、installation ID、backend fingerprint、generation、mode 和 node ownership;选择由数据库负责,内存副本不构成另一权限来源。microsandbox 在 node 上运行,E2B 和 OpenSandbox 直接构造。node proxy 仅对注册声明支持 checkpoint 的 backend 暴露 checkpoint 操作,公共 lifecycle 通过 checkpoint 声明准入 suspension,不通过提供商名称。
backend fingerprint 标识原生资源命名空间,不表示容量。Core 保留部署 generation,使所属 allocation 继续解析到原 backend;不要将保留 allocation 重新指向替代 backend。
@@ -171,7 +171,7 @@ launcher 从[派生进程环境](configuration.md)提供 `sandbox.ProcessPaths`
adapter 不实现这些行为,但依赖它们。代码层面的机制见 [Core implementation constraints](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/IMPLEMENTATION.md#hosted-sandboxes)。
- Core 在调用 `Create` 之前提交 allocation、其 daemon 凭据摘要和 Session 绑定。只有新的 allocation receipt 才允许 `Create`;重试和 Core 重启只观察同一个 `Reference`,不重放 `Create`,也不轮换凭据。
-- 每个 node 有一个串行 lifecycle worker,同一 node 的调用从不并发;E2B allocation 共用一个 worker。某个 node 上卡住的提供商不会阻塞其他 node。
+- 每个 node 有一个串行 lifecycle worker,同一 node 的调用从不并发;direct 提供商的 allocation 共用一个 worker。某个 node 上卡住的提供商不会阻塞其他 node。
- Core 从不根据引擎或提供商名称推断引导完成;由 adapter 通过 `Info.BootstrapComplete` 声明。
- Core 只在撤销 device 权限、结清待处理 input 并请求取消之后才调用 `Kill`。临时提供商故障、未知的 `Create` 结果和已停止的计算资源都不证明永久失败。
- Core 只有在确认清理 allocation 的计算资源与 volume,并证明原始 `Create` 已结算后才释放 allocation。在此之前它保留清理归属,包括观察到资源不存在之后,有界扫描继续捕获迟到的资源,不再次 `Create`。公开删除 Session 之后同样适用。
@@ -208,6 +208,8 @@ Provider 在始终允许到 Core 的连接之上严格执行这些内容,并
microsandbox 按首条匹配的顺序和默认拒绝来应用规则,创建与恢复相同,并关闭严格主机名校验和 DNS rebind 保护。沙箱只能访问主机的 DNS 服务器:发往其他服务器的 DNS 和主机的其他端口都被拒绝,因此只有主机的应答能把名称绑定到地址。该服务器不论策略都会应答任何名称,因此被拒绝的名称仍能解析,只是连接会失败。名称成为不带端口的域名放行规则,放行这些应答解析出的地址(包括私有地址);`*.example.com` 条目成为子域名放行规则,永远不匹配 `example.com`。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部 IPv4 和 IPv6 流量。E2B 只在 80 和 443 端口匹配主机名,因此 adapter 还会为 Core 主机和每个 Endpoint 在其他端口上允许其全球 IPv4 地址。IP 字面量只作为单地址的全球地址范围被允许,私有字面量没有规则。
+OpenSandbox 同样只声明 `Restrict`,并要求其出口 sidecar 以 `dns+nft` 模式运行([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)),其拒绝规则优先于所有放行规则。因此 `Public` 时默认放行,并拒绝私有、共享和链路本地网段,其中去掉 Core 主机在 Create 时由其主机解析出的地址;出于同一原因,它无法遵守 `PrivateDomains`。非 `Public` 时默认拒绝,并放行 Core 主机、`Endpoints` 和 `Domains`。名称成为域名规则,OpenSandbox 对其应答解析出的地址(包括私有地址)的所有端口应用该规则;`*.example.com` 条目只匹配子域名。IP 字面量在其端口上被允许:Core 的始终允许,Endpoint 的仅在其为全球地址时允许。回环保持开放,因为在沙箱内部它就是沙箱自己的网络命名空间。
+
## 检查点生命周期 {#checkpoint-lifecycle}
声明了 checkpoint 操作(`Initial`、`NewCompute`、`GetCompute`、`RenewCompute`、`Suspend`、`Resume`、`ResumeCompute`、`KillCompute`、`DeleteSnapshot`、`RunCommandCompute`)的提供商,使 Core 能把空闲 allocation 暂停为快照,并在同一或另一个兼容 node 上恢复。Core 记录 allocation 的 `compute_phase`;未声明 checkpoint 支持的提供商为 `disabled`,其余阶段按下图变化。删除、归档、重置和保留期到期会在完成[Core 对提供商的保证](#what-core-guarantees-to-a-provider)中描述的清理后,从任意阶段释放 allocation。
@@ -327,3 +329,4 @@ node 测试单独覆盖 disconnect、reconnect fencing,以及 Create response
| --- | --- | --- | --- |
| 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) |
| 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) |
+| OpenSandbox (direct) | [`sandbox/opensandbox`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/opensandbox) | [`deploy/opensandbox`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md) | [选择沙箱提供商](getting-started/sandbox-backends.md#use-opensandbox) |
diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md
index 96675e0a7..5cec7816e 100644
--- a/docs/zh/web/console-api-usage.md
+++ b/docs/zh/web/console-api-usage.md
@@ -1,7 +1,7 @@
---
title: "控制台 API 使用"
source: docs/web/console-api-usage.md
-source_hash: 1e058d564c992bbf7d3e924025b5f9e980d725bcc426d105b3f671b2239c30e2
+source_hash: 6eb0c8250cd0d5b309ac0261a142051302497f8f77afd0214e13236bca1316f0
---
本页列出各控制台页面调用的 Core 路由,以及控制台如何限定读取范围。本页面向控制台开发者;运维人员请阅读 [Web 控制台](index.md)。[Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API 命名空间和凭据](../api/index.md) 定义了本文使用的术语。
@@ -109,7 +109,7 @@ source_hash: 1e058d564c992bbf7d3e924025b5f9e980d725bcc426d105b3f671b2239c30e2
| 移除节点 | `DELETE /core/v1/sandbox/nodes/{node_id}` | 确认后移除节点;只有 Core 确认删除后该行才会消失,随后 **清理主机** 对话框会提供主机的卸载命令(需要 root 或 sudo;对于使用不同于部署地址的地址注册的节点,还需要使用 `--force`,以跳过安装程序与 Core 的确认) |
| Runtime 观测 | `GET /core/v1/sandbox/runtime-observations` | **沙箱监控**:每个项目的托管 Runtime,每项均以所属项目为标签;E2B Sandbox 对话框还会将 `observation.disk` 显示为已用量/限制值(其他位置为 null) |
-E2B 部署没有节点;其 API 密钥为只写。**概览** 和 **沙箱监控** 根据部署的 `resources.running` 和 `resources.pending` 统计其正在运行和正在启动的 Sandbox,而托管 Runtime 行来自 Runtime 观测。两个来源独立刷新,因此控制台不会根据两者之间的差异推断保留或清理状态。
+E2B 或 OpenSandbox 部署没有节点;其 API 密钥为只写。**概览** 和 **沙箱监控** 根据部署的 `resources.running` 和 `resources.pending` 统计其正在运行和正在启动的 Sandbox,而托管 Runtime 行来自 Runtime 观测。两个来源独立刷新,因此控制台不会根据两者之间的差异推断保留或清理状态。
## 写入 {#writes}
diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md
index d79473185..aece7c1b9 100644
--- a/docs/zh/web/index.md
+++ b/docs/zh/web/index.md
@@ -1,7 +1,7 @@
---
title: "Web 控制台"
source: docs/web/index.md
-source_hash: 6ea51f7e9634ae3f7433aad452b2a81302d13df0b533743d77feaafd4a53edb6
+source_hash: 88fc11ad05bafbb2f094e3eb4e4b227f6c6bacef686b88055d7265fcada6b16f
---
Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥登录,查看健康状态和用量,检查应用创建的内容,并管理项目、密钥、节点和设置。应用使用项目 API 密钥调用 `/v1`,不使用 Web。
@@ -37,7 +37,7 @@ Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥
| 任务 | 位置 |
| --- | --- |
-| 选择沙箱提供商(microsandbox 或 E2B)、规格和 Runtime,或重置沙箱提供商 | **设置 → 沙箱与调度**;参阅[修改沙箱配置](../getting-started/nodes.md#change-the-sandbox-configuration) |
+| 选择沙箱提供商(microsandbox、E2B 或 OpenSandbox)、规格和 Runtime,或重置沙箱提供商 | **设置 → 沙箱与调度**;参阅[修改沙箱配置](../getting-started/nodes.md#change-the-sandbox-configuration) |
| 添加、调整或移除执行节点 | **节点**:**添加节点**、**编辑节点** 和移除;参阅[添加和管理节点](../getting-started/nodes.md) |
| 设置 Harness 默认模型 | **设置 → 模型与凭据**;参阅[默认模型](../configuration.md#default-models) |
| 创建项目并签发 API 密钥 | **项目与密钥**;参阅 [项目和 API 密钥](../getting-started/operations.md#projects-and-api-keys) |
diff --git a/docs/zh/workspace-provider.md b/docs/zh/workspace-provider.md
index 0efac398c..0a2416804 100644
--- a/docs/zh/workspace-provider.md
+++ b/docs/zh/workspace-provider.md
@@ -1,7 +1,7 @@
---
title: 工作区文件系统协议
source: docs/workspace-provider.md
-source_hash: 3f8264d8bd0232f31cb6c91c2f482ece6f713292dc4c53044a0742aeccaa8baa
+source_hash: da8e7fe6896f79a32df6fbefeb3d181a9597d9c39cc3c080897f38d49581437d
---
工作区文件系统协议连接 Core 和沙箱节点与拥有 Environment 持久存储的适配器。Core 创建和删除存储对象;节点将对象解析为主机目录,由其[沙箱提供商协议](sandbox-provider.md)挂载到计算资源。本文面向文件系统适配器和沙箱提供商的作者。
@@ -19,6 +19,7 @@ Core 在分配任一资源前,选择并验证沙箱提供商与文件系统适
| --- | --- |
| microsandbox | 必需(`host_directory`、`user_xattr`) |
| E2B | 不支持;使用提供商自有的 Environment 磁盘 |
+| OpenSandbox | 不支持;使用提供商自有的 Environment 磁盘 |
## 实现适配器 {#implement-an-adapter}
From 6e34c86b0760dfaaaedaf5946e02b3c8fe7290f2 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Tue, 3 Nov 2026 08:43:32 +0000
Subject: [PATCH 3/7] Wait for the OpenSandbox egress sidecar during create
---
.../internal/sandbox/opensandbox/fake_test.go | 2 ++
.../internal/sandbox/opensandbox/provider.go | 26 ++++++++++++-------
.../sandbox/opensandbox/provider_test.go | 19 +++++++++++++-
3 files changed, 37 insertions(+), 10 deletions(-)
diff --git a/services/core/internal/sandbox/opensandbox/fake_test.go b/services/core/internal/sandbox/opensandbox/fake_test.go
index 016013950..ae7b4f895 100644
--- a/services/core/internal/sandbox/opensandbox/fake_test.go
+++ b/services/core/internal/sandbox/opensandbox/fake_test.go
@@ -164,6 +164,8 @@ func (f *fake) serve(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"status": "ok", "enforcementMode": f.mode})
case "endpoint":
writeJSON(w, 200, map[string]any{"endpoint": strings.TrimPrefix(f.server.URL, "http://") + "/v1/sandboxes/" + id + "/proxy/44772"})
+ case "execd/ping":
+ w.WriteHeader(200)
case "execd/files/upload":
f.upload(w, r)
case "execd/command":
diff --git a/services/core/internal/sandbox/opensandbox/provider.go b/services/core/internal/sandbox/opensandbox/provider.go
index 565da2e5d..80f23caea 100644
--- a/services/core/internal/sandbox/opensandbox/provider.go
+++ b/services/core/internal/sandbox/opensandbox/provider.go
@@ -274,22 +274,30 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
if item.ID != created.ID || !owns(*item, p.ownerMetadata(r)) {
return got, sandbox.ErrOwnership
}
- status, err := p.lifecycle.GetNetworkPolicy(ctx, item.ID)
- if err != nil {
- return unconfirmed, nativeErr(err)
- }
- if status.EnforcementMode != "dns+nft" {
- return got, fmt.Errorf("%w: the OpenSandbox egress sidecar must run in dns+nft mode", sandbox.ErrInvalid)
- }
-
data, err := connection.Marshal()
if err != nil {
return unconfirmed, sandbox.ErrInvalid
}
- live, err := osdk.ConnectSandbox(ctx, p.connection, item.ID)
+ // The egress sidecar starts answering after the create returns, so its
+ // policy status is part of the SDK's readiness wait.
+ var mode string
+ live, err := osdk.ConnectSandbox(ctx, p.connection, item.ID, osdk.ReadyOptions{HealthCheck: func(ctx context.Context, s *osdk.Sandbox) (bool, error) {
+ if err := s.Ping(ctx); err != nil {
+ return false, err
+ }
+ status, err := p.lifecycle.GetNetworkPolicy(ctx, item.ID)
+ if err != nil {
+ return false, err
+ }
+ mode = status.EnforcementMode
+ return true, nil
+ }})
if err != nil {
return unconfirmed, nativeErr(err)
}
+ if mode != "dns+nft" {
+ return got, fmt.Errorf("%w: the OpenSandbox egress sidecar must run in dns+nft mode", sandbox.ErrInvalid)
+ }
if err := upload(ctx, live, bootstrapFile, data); err != nil {
return unconfirmed, nativeErr(err)
}
diff --git a/services/core/internal/sandbox/opensandbox/provider_test.go b/services/core/internal/sandbox/opensandbox/provider_test.go
index 72d4ceb09..829f4d649 100644
--- a/services/core/internal/sandbox/opensandbox/provider_test.go
+++ b/services/core/internal/sandbox/opensandbox/provider_test.go
@@ -66,7 +66,7 @@ func TestCreateBootstrapsOnce(t *testing.T) {
if !got.BootstrapComplete || !got.CreateSettled {
t.Fatalf("created sandbox not reported complete: %+v", got)
}
- if want := []string{"list", "create", "get", "networkpolicy", "endpoint", "execd/files/upload", "execd/command", "patch", "list"}; !reflect.DeepEqual(f.calls, want) {
+ if want := []string{"list", "create", "get", "endpoint", "execd/ping", "networkpolicy", "execd/files/upload", "execd/command", "patch", "list"}; !reflect.DeepEqual(f.calls, want) {
t.Fatalf("calls = %v, want %v", f.calls, want)
}
data, _ := b.RuntimeConnection().Marshal()
@@ -98,6 +98,23 @@ func TestCreateRejectsEgressWithoutNftEnforcement(t *testing.T) {
}
}
+func TestCreateWaitsForTheEgressSidecar(t *testing.T) {
+ f := newFake(t)
+ starting := true
+ f.fault = func(call string, w http.ResponseWriter) bool {
+ if call != "networkpolicy" || !starting {
+ return false
+ }
+ starting = false
+ writeJSON(w, 502, map[string]string{"code": "BAD_GATEWAY", "message": "egress starting"})
+ return true
+ }
+ p, r := testProvider(t, f)
+ if got, err := p.Create(bounded(t), bootstrap(r)); err != nil || !got.BootstrapComplete {
+ t.Fatalf("create = %+v, %v", got, err)
+ }
+}
+
func TestCreateBeforeDispatchIsAbsent(t *testing.T) {
f := newFake(t)
p, r := testProvider(t, f)
From 375f8a43153f8b3ae56b62557cca413f231391d6 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Tue, 3 Nov 2026 08:58:06 +0000
Subject: [PATCH 4/7] Reject public OpenSandbox sandboxes when Core has a
private address
---
contracts/agents-api/sandbox-deployment.md | 2 +-
contracts/agents-api/zh/sandbox-deployment.md | 4 +-
docs/getting-started/sandbox-backends.md | 2 +-
docs/sandbox-provider.md | 2 +-
docs/zh/getting-started/sandbox-backends.md | 4 +-
docs/zh/sandbox-provider.md | 4 +-
.../internal/sandbox/opensandbox/network.go | 37 +++-----------
.../sandbox/opensandbox/network_test.go | 51 +++++++------------
8 files changed, 36 insertions(+), 70 deletions(-)
diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md
index 8e5664321..ec9fa693c 100644
--- a/contracts/agents-api/sandbox-deployment.md
+++ b/contracts/agents-api/sandbox-deployment.md
@@ -74,7 +74,7 @@ Select the filesystem configuration before the microsandbox deployment. Selectin
The default is `{"public": true, "domains": []}`. Under every cap, loopback, link-local and cloud-metadata addresses and all other private addresses stay blocked, except that E2B cannot block the metadata address 169.254.169.254 and its sandboxes cannot reach private networks at all. A sandbox keeps the cap of the generation that created it.
-Each Provider declares what it enforces ([Sandbox Provider protocol](../../docs/sandbox-provider.md#network-egress)): microsandbox supports both fields, E2B supports `public: false` but not `domains` because E2B sandboxes cannot reach private networks, and OpenSandbox supports `public: false` but not `domains` because its deny rules for private ranges win over a domain's private answers. A cap the selected Provider does not declare returns 400 `invalid_sandbox_configuration` with `param` `network.public` or `network.domains`.
+Each Provider declares what it enforces ([Sandbox Provider protocol](../../docs/sandbox-provider.md#network-egress)): microsandbox supports both fields, E2B supports `public: false` but not `domains` because E2B sandboxes cannot reach private networks, and OpenSandbox supports `public: false` but not `domains` because its deny rules for private ranges win over a domain's private answers. For the same reason an OpenSandbox sandbox with public access needs the installation public URL at global addresses; when it resolves to a private one, a sandbox that may reach public addresses fails to start, and a `public: false` cap runs the deployment's Sessions without public access. A cap the selected Provider does not declare returns 400 `invalid_sandbox_configuration` with `param` `network.public` or `network.domains`.
### E2B configuration
diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md
index 0899238b8..8dde116b6 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: 6fb81005e929a5b5061d2570cff18f441521f4e5a71832139c9bc1e190b76075
+source_hash: 9a668c5872eaed4bd0b7d33a228d87e2897ffa5fa7d9dde4deae7aa3b1b66725
---
沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。
@@ -77,7 +77,7 @@ microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/enviro
默认值为 `{"public": true, "domains": []}`。无论哪种上限,回环、链路本地、云元数据地址以及其他所有私有地址始终被阻止,但 E2B 无法阻止元数据地址 169.254.169.254,且 E2B 沙箱根本无法访问私有网络。沙箱保留创建它的代次的上限。
-每个 Provider 声明自己能强制执行什么([Sandbox Provider 协议](../../../docs/zh/sandbox-provider.md#network-egress)):microsandbox 支持两个字段,E2B 支持 `public: false` 但不支持 `domains`,因为 E2B 沙箱无法访问私有网络,OpenSandbox 支持 `public: false` 但不支持 `domains`,因为其对私有网段的拒绝规则优先于域名解析出的私有地址。所选 Provider 未声明的上限会返回 400 `invalid_sandbox_configuration`,`param` 为 `network.public` 或 `network.domains`。
+每个 Provider 声明自己能强制执行什么([Sandbox Provider 协议](../../../docs/zh/sandbox-provider.md#network-egress)):microsandbox 支持两个字段,E2B 支持 `public: false` 但不支持 `domains`,因为 E2B 沙箱无法访问私有网络,OpenSandbox 支持 `public: false` 但不支持 `domains`,因为其对私有网段的拒绝规则优先于域名解析出的私有地址。出于同一原因,可访问公网的 OpenSandbox 沙箱需要安装公开 URL 解析到全局地址;解析到私有地址时,可访问公网地址的沙箱无法启动,`public: false` 上限可以让部署的 Session 在没有公网访问的情况下运行。所选 Provider 未声明的上限会返回 400 `invalid_sandbox_configuration`,`param` 为 `network.public` 或 `network.domains`。
### E2B 配置 {#e2b-configuration}
diff --git a/docs/getting-started/sandbox-backends.md b/docs/getting-started/sandbox-backends.md
index 00f065246..da1d99b5a 100644
--- a/docs/getting-started/sandbox-backends.md
+++ b/docs/getting-started/sandbox-backends.md
@@ -44,7 +44,7 @@ The key and template rules, including key replacement, are in [Initial setup and
## Use OpenSandbox
-OpenSandbox needs no nodes. Its sandboxes call Core's public address, which must not be loopback; see [Configure the public address](./public-address.md).
+OpenSandbox needs no nodes. Its sandboxes call Core's public address, which must not be loopback; see [Configure the public address](./public-address.md). Sandboxes with public internet access need it at a global address ([network cap](../../contracts/agents-api/sandbox-deployment.md#network)).
1. Prepare the server and build the Runtime image as the [OpenSandbox adapter guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md) describes.
2. In the setup wizard, choose **OpenSandbox**. Under **Connect OpenSandbox**, enter the **Server URL**, the **Runtime image** and the **API key**.
diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md
index 70f6c9612..7bb3a73eb 100644
--- a/docs/sandbox-provider.md
+++ b/docs/sandbox-provider.md
@@ -206,7 +206,7 @@ The Provider enforces exactly this on top of the always-allowed connection to Co
microsandbox applies the rules with first-match order and default deny, on create and restore alike, with strict hostname enforcement and DNS rebind protection off. The sandbox reaches only the host's DNS server: DNS to any other server and every other host port are refused, so only the host's answers bind names to addresses. That server answers every name whatever the policy, so a refused name still resolves and only its connections fail. A name becomes a port-less domain allow for the addresses those answers resolve, private ones included, and a `*.example.com` entry a subdomain allow that never matches `example.com`. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all IPv4 and IPv6 traffic except the Core host, `Endpoints` and `Domains`. E2B matches a host name only on ports 80 and 443, so the adapter also allows the global IPv4 addresses of the Core host and of each Endpoint on any other port. An IP literal is allowed only as a global address range of one address, and a private literal gets no rule.
-OpenSandbox also declares `Restrict` only and requires its egress sidecar in `dns+nft` mode ([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)), whose deny rules win over every allow. `Public` therefore allows by default and denies the private, shared and link-local ranges with the Core host's addresses cut out, as its host resolves them at Create; for the same reason it cannot honor `PrivateDomains`. Not `Public` denies by default and allows the Core host, `Endpoints` and `Domains`. A name becomes a domain rule, which OpenSandbox applies to every port of the addresses its answers resolve, private ones included, and a `*.example.com` entry matches only subdomains. An IP literal is allowed on its port: Core's always, an Endpoint's only when it is a global address. Loopback stays open because inside the sandbox it is the sandbox's own network namespace.
+OpenSandbox also declares `Restrict` only and requires its egress sidecar in `dns+nft` mode ([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)), whose deny rules win over every allow. `Public` therefore allows by default and denies the private, shared and link-local ranges, so it cannot honor `PrivateDomains`, and Create returns `ErrInvalid` before dispatch when the Core host, as its host resolves it, has an address outside the global ranges: opening that address would open all of its ports. Not `Public` denies by default and allows the Core host, `Endpoints` and `Domains`. A name becomes a domain rule, which OpenSandbox applies to every port of the addresses its answers resolve, private ones included, and a `*.example.com` entry matches only subdomains. An IP literal is allowed on its port: Core's always, an Endpoint's only when it is a global address. Loopback stays open because inside the sandbox it is the sandbox's own network namespace.
## Checkpoint lifecycle
diff --git a/docs/zh/getting-started/sandbox-backends.md b/docs/zh/getting-started/sandbox-backends.md
index 35043d131..dc26201fc 100644
--- a/docs/zh/getting-started/sandbox-backends.md
+++ b/docs/zh/getting-started/sandbox-backends.md
@@ -1,7 +1,7 @@
---
title: "选择沙箱提供商"
source: docs/getting-started/sandbox-backends.md
-source_hash: 24c4e2254f1278e8fa7629385232e672c3dfb64ed252aaf4a3058a1e2a435d37
+source_hash: 13d81221aac68a2edde3434c1ae51b76c052d034f55a8a7162d9eac5d7a602d5
---
比较 microsandbox、E2B 和 OpenSandbox,然后在 Web 中选择一个。本指南面向已完成[安装 Core 和 Web](./install.md)、需要为 Core 托管的 Session 提供计算资源的运维人员。
@@ -46,7 +46,7 @@ E2B 不需要节点,但其沙箱会从 E2B 云端调用 Core,因此 Core 需
## 使用 OpenSandbox {#use-opensandbox}
-OpenSandbox 不需要节点。其沙箱会调用 Core 的公共地址,该地址不能是回环地址;参阅[配置公开地址](./public-address.md)。
+OpenSandbox 不需要节点。其沙箱会调用 Core 的公共地址,该地址不能是回环地址;参阅[配置公开地址](./public-address.md)。可访问公网的沙箱需要它是全局地址([网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network))。
1. 按 [OpenSandbox adapter 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md)准备服务器并构建 Runtime 镜像。
2. 在设置向导中选择 **OpenSandbox**。在 **连接 OpenSandbox** 页输入 **服务地址**、**Runtime 镜像** 和 **API 密钥**。
diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md
index 875f5c195..54dd652cd 100644
--- a/docs/zh/sandbox-provider.md
+++ b/docs/zh/sandbox-provider.md
@@ -1,7 +1,7 @@
---
title: "沙箱提供商协议"
source: docs/sandbox-provider.md
-source_hash: 124be5328278155cd381287612bd8783edf11655eba9c352248a2b494e8170c5
+source_hash: 37fee1a55da581c55a1b653e5712ff99021ed07ef88d0e482aa545b42573e3a9
---
沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox、E2B 和 OpenSandbox 实现了该协议。
@@ -208,7 +208,7 @@ Provider 在始终允许到 Core 的连接之上严格执行这些内容,并
microsandbox 按首条匹配的顺序和默认拒绝来应用规则,创建与恢复相同,并关闭严格主机名校验和 DNS rebind 保护。沙箱只能访问主机的 DNS 服务器:发往其他服务器的 DNS 和主机的其他端口都被拒绝,因此只有主机的应答能把名称绑定到地址。该服务器不论策略都会应答任何名称,因此被拒绝的名称仍能解析,只是连接会失败。名称成为不带端口的域名放行规则,放行这些应答解析出的地址(包括私有地址);`*.example.com` 条目成为子域名放行规则,永远不匹配 `example.com`。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部 IPv4 和 IPv6 流量。E2B 只在 80 和 443 端口匹配主机名,因此 adapter 还会为 Core 主机和每个 Endpoint 在其他端口上允许其全球 IPv4 地址。IP 字面量只作为单地址的全球地址范围被允许,私有字面量没有规则。
-OpenSandbox 同样只声明 `Restrict`,并要求其出口 sidecar 以 `dns+nft` 模式运行([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)),其拒绝规则优先于所有放行规则。因此 `Public` 时默认放行,并拒绝私有、共享和链路本地网段,其中去掉 Core 主机在 Create 时由其主机解析出的地址;出于同一原因,它无法遵守 `PrivateDomains`。非 `Public` 时默认拒绝,并放行 Core 主机、`Endpoints` 和 `Domains`。名称成为域名规则,OpenSandbox 对其应答解析出的地址(包括私有地址)的所有端口应用该规则;`*.example.com` 条目只匹配子域名。IP 字面量在其端口上被允许:Core 的始终允许,Endpoint 的仅在其为全球地址时允许。回环保持开放,因为在沙箱内部它就是沙箱自己的网络命名空间。
+OpenSandbox 同样只声明 `Restrict`,并要求其出口 sidecar 以 `dns+nft` 模式运行([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)),其拒绝规则优先于所有放行规则。因此 `Public` 时默认放行,并拒绝私有、共享和链路本地网段,所以无法遵守 `PrivateDomains`;当 Core 主机由其主机解析出的地址不全在全局网段时,Create 在派发前返回 `ErrInvalid`:放行该地址会放行它的所有端口。非 `Public` 时默认拒绝,并放行 Core 主机、`Endpoints` 和 `Domains`。名称成为域名规则,OpenSandbox 对其应答解析出的地址(包括私有地址)的所有端口应用该规则;`*.example.com` 条目只匹配子域名。IP 字面量在其端口上被允许:Core 的始终允许,Endpoint 的仅在其为全球地址时允许。回环保持开放,因为在沙箱内部它就是沙箱自己的网络命名空间。
## 检查点生命周期 {#checkpoint-lifecycle}
diff --git a/services/core/internal/sandbox/opensandbox/network.go b/services/core/internal/sandbox/opensandbox/network.go
index 0283e0643..f703a8d61 100644
--- a/services/core/internal/sandbox/opensandbox/network.go
+++ b/services/core/internal/sandbox/opensandbox/network.go
@@ -25,9 +25,9 @@ var privatePrefixes = []netip.Prefix{
type resolver func(ctx context.Context, host string) ([]netip.Addr, error)
// networkPolicy maps Egress to the OpenSandbox egress policy. The sidecar lets a
-// deny rule win over any allow, so a public sandbox denies the private ranges
-// minus Core's own addresses, and any other sandbox denies by default and
-// allows Core, the model provider endpoints and the named domains.
+// deny rule win over any allow, so a public sandbox, which denies the private
+// ranges, needs Core at global addresses; any other sandbox denies by default
+// and allows Core, the model provider endpoints and the named domains.
func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) (*osdk.NetworkPolicy, error) {
core, err := url.Parse(b.CoreURL)
if err != nil || core.Hostname() == "" {
@@ -48,18 +48,12 @@ func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) (
if err != nil {
return nil, err
}
+ // Opening a private Core address would open all of its ports.
+ if slices.ContainsFunc(addrs, func(a netip.Addr) bool { return !isGlobal(a) }) {
+ return nil, fmt.Errorf("%w: a public OpenSandbox sandbox reaches Core only at global addresses", sandbox.ErrInvalid)
+ }
for _, prefix := range privatePrefixes {
- remaining := []netip.Prefix{prefix}
- for _, addr := range addrs {
- var next []netip.Prefix
- for _, p := range remaining {
- next = append(next, without(p, addr)...)
- }
- remaining = next
- }
- for _, p := range remaining {
- rules = append(rules, osdk.NetworkRule{Action: "deny", Target: p.String()})
- }
+ rules = append(rules, osdk.NetworkRule{Action: "deny", Target: prefix.String()})
}
return &osdk.NetworkPolicy{DefaultAction: "allow", Egress: rules}, nil
}
@@ -108,18 +102,3 @@ func hostAddrs(ctx context.Context, host string, resolve resolver) ([]netip.Addr
func isGlobal(addr netip.Addr) bool {
return addr.IsGlobalUnicast() && !slices.ContainsFunc(privatePrefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
}
-
-// without returns the minimal prefixes that cover p except addr.
-func without(p netip.Prefix, addr netip.Addr) []netip.Prefix {
- if !p.Contains(addr) {
- return []netip.Prefix{p}
- }
- var out []netip.Prefix
- for bits := p.Bits() + 1; bits <= addr.BitLen(); bits++ {
- raw := netip.PrefixFrom(addr, bits).Masked().Addr().AsSlice()
- raw[(bits-1)/8] ^= 0x80 >> ((bits - 1) % 8)
- sibling, _ := netip.AddrFromSlice(raw)
- out = append(out, netip.PrefixFrom(sibling, bits))
- }
- return out
-}
diff --git a/services/core/internal/sandbox/opensandbox/network_test.go b/services/core/internal/sandbox/opensandbox/network_test.go
index 09c5c72ee..c40d4b40d 100644
--- a/services/core/internal/sandbox/opensandbox/network_test.go
+++ b/services/core/internal/sandbox/opensandbox/network_test.go
@@ -41,42 +41,29 @@ func targets(policy *osdk.NetworkPolicy) []string {
return out
}
-func TestPublicNetworkDeniesPrivateRangesExceptCore(t *testing.T) {
+func TestPublicNetworkDeniesPrivateRanges(t *testing.T) {
+ policy := policyFor(t, "https://core.example/api/v1", sandbox.Egress{Public: true}, "203.0.113.5")
+ want := "deny 10.0.0.0/8,deny 172.16.0.0/12,deny 192.168.0.0/16,deny 100.64.0.0/10,deny 169.254.0.0/16,deny fc00::/7,deny fe80::/10"
+ if policy.DefaultAction != "allow" || strings.Join(targets(policy), ",") != want {
+ t.Fatalf("%s rules = %v", policy.DefaultAction, targets(policy))
+ }
+}
+
+func TestPublicNetworkRejectsPrivateCore(t *testing.T) {
for name, tc := range map[string]struct {
url string
resolved []string
- }{"literal": {"https://10.1.2.3:8443/api/v1", nil}, "resolved name": {"https://core.example/api/v1", []string{"10.1.2.3"}}} {
- t.Run(name, func(t *testing.T) {
- policy := policyFor(t, tc.url, sandbox.Egress{Public: true}, tc.resolved...)
- if policy.DefaultAction != "allow" {
- t.Fatal(policy.DefaultAction)
- }
- core := netip.MustParseAddr("10.1.2.3")
- var covered []string
- for _, r := range policy.Egress {
- if r.Action != "deny" || len(r.Ports) != 0 {
- t.Fatalf("rule %+v", r)
- }
- if netip.MustParsePrefix(r.Target).Contains(core) {
- t.Fatalf("deny %s covers Core", r.Target)
- }
- covered = append(covered, r.Target)
- }
- // The other private ranges stay denied whole, and the carve-out leaves a sibling of Core's /32.
- joined := " " + strings.Join(covered, " ") + " "
- for _, want := range []string{" 172.16.0.0/12 ", " 192.168.0.0/16 ", " 100.64.0.0/10 ", " 169.254.0.0/16 ", " fc00::/7 ", " fe80::/10 ", " 10.1.2.2/32 ", " 10.128.0.0/9 "} {
- if !strings.Contains(joined, want) {
- t.Fatalf("missing %q in %s", want, joined)
- }
+ }{"literal": {"https://10.1.2.3:8443/api/v1", nil}, "resolved name": {"https://core.example/api/v1", []string{"203.0.113.5", "10.1.2.3"}}} {
+ resolve := func(context.Context, string) ([]netip.Addr, error) {
+ var out []netip.Addr
+ for _, a := range tc.resolved {
+ out = append(out, netip.MustParseAddr(a))
}
- })
- }
-}
-
-func TestPublicNetworkLeavesPublicCoreUntouched(t *testing.T) {
- policy := policyFor(t, "https://core.example/api/v1", sandbox.Egress{Public: true}, "203.0.113.5")
- if len(policy.Egress) != len(privatePrefixes) || policy.Egress[0].Target != "10.0.0.0/8" {
- t.Fatalf("rules = %v", targets(policy))
+ return out, nil
+ }
+ if _, err := networkPolicy(t.Context(), sandbox.Bootstrap{CoreURL: tc.url, Egress: sandbox.Egress{Public: true}}, resolve); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatalf("%s: %v", name, err)
+ }
}
}
From e3bfc07b7cffec31ee1eeed2e2301f83a98ec7cd Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Tue, 3 Nov 2026 10:05:18 +0000
Subject: [PATCH 5/7] Deny private ranges in every OpenSandbox sandbox and
require a global Core
---
contracts/agents-api/environments.md | 2 +-
contracts/agents-api/sandbox-deployment.md | 6 +-
contracts/agents-api/zh/environments.md | 4 +-
contracts/agents-api/zh/sandbox-deployment.md | 8 +--
docs/getting-started/public-address.md | 2 +-
docs/getting-started/sandbox-backends.md | 4 +-
docs/sandbox-provider.md | 2 +-
docs/zh/getting-started/public-address.md | 4 +-
docs/zh/getting-started/sandbox-backends.md | 6 +-
docs/zh/sandbox-provider.md | 4 +-
.../agents-client/src/deployment-contract.ts | 2 +-
services/core/deploy/opensandbox/README.md | 9 +--
.../sandbox/opensandbox/configuration.go | 2 +-
.../internal/sandbox/opensandbox/fake_test.go | 15 +++--
.../internal/sandbox/opensandbox/network.go | 44 +++++++-------
.../sandbox/opensandbox/network_test.go | 38 ++++++------
.../internal/sandbox/opensandbox/provider.go | 14 ++++-
.../sandbox/opensandbox/provider_test.go | 60 +++++++++++++++++++
.../internal/sandbox/opensandbox/verify.go | 8 ++-
.../core/internal/sandbox/operations_test.go | 3 +-
20 files changed, 160 insertions(+), 77 deletions(-)
diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md
index f18a3932c..4108a296e 100644
--- a/contracts/agents-api/environments.md
+++ b/contracts/agents-api/environments.md
@@ -313,7 +313,7 @@ Limits ([adapter rules](../../docs/sandbox-provider.md#network-egress)):
- A model provider host that the cap lists, by name or through a `*.` entry, is reachable like a listed domain, not only on its port.
- On E2B, a restricted domain is reachable only on ports 80 and 443, and a model provider on another port is reachable on every port of its public addresses.
-- On microsandbox and OpenSandbox, an allowed name is reachable on every address it resolves to, private ones included.
+- On OpenSandbox, an allowed domain and the model provider host are reachable on every port of their public addresses.
`disabled` and `restricted` return `sandbox_network_unsupported` when the deployment's sandbox Provider cannot restrict egress. A `self_hosted` Environment accepts only `enabled`, because Core cannot enforce a policy on a machine it does not run.
diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md
index ec9fa693c..bbb2c4b68 100644
--- a/contracts/agents-api/sandbox-deployment.md
+++ b/contracts/agents-api/sandbox-deployment.md
@@ -74,7 +74,7 @@ Select the filesystem configuration before the microsandbox deployment. Selectin
The default is `{"public": true, "domains": []}`. Under every cap, loopback, link-local and cloud-metadata addresses and all other private addresses stay blocked, except that E2B cannot block the metadata address 169.254.169.254 and its sandboxes cannot reach private networks at all. A sandbox keeps the cap of the generation that created it.
-Each Provider declares what it enforces ([Sandbox Provider protocol](../../docs/sandbox-provider.md#network-egress)): microsandbox supports both fields, E2B supports `public: false` but not `domains` because E2B sandboxes cannot reach private networks, and OpenSandbox supports `public: false` but not `domains` because its deny rules for private ranges win over a domain's private answers. For the same reason an OpenSandbox sandbox with public access needs the installation public URL at global addresses; when it resolves to a private one, a sandbox that may reach public addresses fails to start, and a `public: false` cap runs the deployment's Sessions without public access. A cap the selected Provider does not declare returns 400 `invalid_sandbox_configuration` with `param` `network.public` or `network.domains`.
+Each Provider declares what it enforces ([Sandbox Provider protocol](../../docs/sandbox-provider.md#network-egress)): microsandbox supports both fields, E2B supports `public: false` but not `domains` because E2B sandboxes cannot reach private networks, and OpenSandbox supports `public: false` but not `domains` because its deny rules for private ranges win over a domain's private answers. Those deny rules also win over the connection to Core, so OpenSandbox needs the installation public URL at global addresses. When it resolves to a private one, every OpenSandbox sandbox fails to start. A cap the selected Provider does not declare returns 400 `invalid_sandbox_configuration` with `param` `network.public` or `network.domains`.
### E2B configuration
@@ -82,7 +82,7 @@ E2B uses `configuration.template` in `template-id:build-uuid` form; the build UU
### OpenSandbox configuration
-OpenSandbox uses `configuration.api_url`, the HTTP or HTTPS origin of the OpenSandbox server without path, query, fragment or credentials, and `configuration.image`, the Runtime image built for OpenSandbox as its [adapter guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md#build-the-runtime-image) describes. The API key is encrypted in PostgreSQL and never appears in a response, bootstrap configuration, command argument or log. Core checks neither the image nor the server when it saves the selection; each Create fails when the image cannot start or the server's egress sidecar does not run in `dns+nft` mode.
+OpenSandbox uses `configuration.api_url`, the HTTP or HTTPS origin of the OpenSandbox server without path, query, fragment or credentials, and `configuration.image`, the Runtime image built for OpenSandbox as its [adapter guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md#build-the-runtime-image) describes. The API key is encrypted in PostgreSQL and never appears in a response, bootstrap configuration, command argument or log. Core does not check the image. The initial selection does not contact the server, and a later change verifies the saved or new key as [key replacement](#opensandbox-key-replacement) describes. Each Create fails when the image cannot start, the server's egress sidecar does not run in `dns+nft` mode or the installation public URL resolves to a non-global address.
### Configuration discovery
@@ -129,7 +129,7 @@ No provider text or credential is returned. The write and its `change` or `repla
### OpenSandbox key replacement
-The request rules are those of E2B key replacement. An OpenSandbox server has one key, so the check is access: the candidate key must authenticate with the server of every retained generation and list the sandboxes of each unreleased allocation. A 401 or 403 returns 400 `sandbox_credential_invalid`, and any other failure 503 `sandbox_verification_unconfirmed`. Server URL and image changes do not drain lifecycles.
+The request rules are those of E2B key replacement. The candidate key must authenticate with the server of every retained generation and list the sandboxes of each unreleased allocation, so a key that lists none, such as one of another OpenSandbox tenant, is refused. A 401 or 403 returns 400 `sandbox_credential_invalid`, sandboxes of another installation 409 `sandbox_credential_ownership`, and any other failure 503 `sandbox_verification_unconfirmed`, including an allocation whose sandboxes are gone. Server URL and image changes do not drain lifecycles.
## Suspension policy
diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md
index 6bd3025c4..fd66bfd81 100644
--- a/contracts/agents-api/zh/environments.md
+++ b/contracts/agents-api/zh/environments.md
@@ -1,7 +1,7 @@
---
title: "Environment 与模板"
source: contracts/agents-api/environments.md
-source_hash: f49f6012ac8d7309520a6b71c20288fdf61ae035d20023a3fcd705727a5d639e
+source_hash: db29e6b679d41900e5369e625844f0096cfdf7614ae73007b43de1cb00843a24
---
Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。
@@ -315,7 +315,7 @@ Core 可以用同一 Project 中较早一次准备的结果启动托管 Environm
- 上限按名称或 `*.` 条目列出的模型提供商主机与所列域名一样可达,不限于其端口。
- 在 E2B 上,受限域名只能通过 80 和 443 端口访问;模型提供商使用其他端口时,其公网地址的所有端口均可达。
-- 在 microsandbox 和 OpenSandbox 上,允许的名称在其解析出的每个地址上都可达,包括私有地址。
+- 在 OpenSandbox 上,允许的域名和模型提供商主机在其公网地址的每个端口上都可达。
部署的沙箱 Provider 无法限制出站时,`disabled` 和 `restricted` 返回 `sandbox_network_unsupported`。`self_hosted` Environment 只接受 `enabled`,因为 Core 无法在不由它运行的机器上强制策略。
diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md
index 8dde116b6..61ecbab97 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: 9a668c5872eaed4bd0b7d33a228d87e2897ffa5fa7d9dde4deae7aa3b1b66725
+source_hash: d58c78e62c5d4ac3a3bf6d231de8fc1e26ce33d95ff276df0cc2bc428824355b
---
沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。
@@ -77,7 +77,7 @@ microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/enviro
默认值为 `{"public": true, "domains": []}`。无论哪种上限,回环、链路本地、云元数据地址以及其他所有私有地址始终被阻止,但 E2B 无法阻止元数据地址 169.254.169.254,且 E2B 沙箱根本无法访问私有网络。沙箱保留创建它的代次的上限。
-每个 Provider 声明自己能强制执行什么([Sandbox Provider 协议](../../../docs/zh/sandbox-provider.md#network-egress)):microsandbox 支持两个字段,E2B 支持 `public: false` 但不支持 `domains`,因为 E2B 沙箱无法访问私有网络,OpenSandbox 支持 `public: false` 但不支持 `domains`,因为其对私有网段的拒绝规则优先于域名解析出的私有地址。出于同一原因,可访问公网的 OpenSandbox 沙箱需要安装公开 URL 解析到全局地址;解析到私有地址时,可访问公网地址的沙箱无法启动,`public: false` 上限可以让部署的 Session 在没有公网访问的情况下运行。所选 Provider 未声明的上限会返回 400 `invalid_sandbox_configuration`,`param` 为 `network.public` 或 `network.domains`。
+每个 Provider 声明自己能强制执行什么([Sandbox Provider 协议](../../../docs/zh/sandbox-provider.md#network-egress)):microsandbox 支持两个字段,E2B 支持 `public: false` 但不支持 `domains`,因为 E2B 沙箱无法访问私有网络,OpenSandbox 支持 `public: false` 但不支持 `domains`,因为其对私有网段的拒绝规则优先于域名解析出的私有地址。这些拒绝规则同样优先于到 Core 的连接,因此 OpenSandbox 需要安装公开 URL 解析到全局地址。解析到私有地址时,所有 OpenSandbox 沙箱都无法启动。所选 Provider 未声明的上限会返回 400 `invalid_sandbox_configuration`,`param` 为 `network.public` 或 `network.domains`。
### E2B 配置 {#e2b-configuration}
@@ -85,7 +85,7 @@ E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建
### OpenSandbox 配置 {#opensandbox-configuration}
-OpenSandbox 使用 `configuration.api_url` 和 `configuration.image`:前者是 OpenSandbox 服务器的 HTTP 或 HTTPS 源地址,不含路径、查询、片段或凭据;后者是按其 [adapter 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md#build-the-runtime-image)所述为 OpenSandbox 构建的 Runtime 镜像。API 密钥在 PostgreSQL 中加密,绝不会出现在响应、引导配置、命令参数或日志中。Core 保存选择时既不检查镜像也不检查服务器;镜像无法启动或服务器的出口 sidecar 未以 `dns+nft` 模式运行时,每次 Create 都会失败。
+OpenSandbox 使用 `configuration.api_url` 和 `configuration.image`:前者是 OpenSandbox 服务器的 HTTP 或 HTTPS 源地址,不含路径、查询、片段或凭据;后者是按其 [adapter 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md#build-the-runtime-image)所述为 OpenSandbox 构建的 Runtime 镜像。API 密钥在 PostgreSQL 中加密,绝不会出现在响应、引导配置、命令参数或日志中。Core 不检查镜像。首次选择时不会联系服务器,之后的更改会按[密钥替换](#opensandbox-key-replacement)所述校验已保存的或新的密钥。镜像无法启动、服务器的出口 sidecar 未以 `dns+nft` 模式运行,或安装公开 URL 解析到非全局地址时,每次 Create 都会失败。
### 配置发现 {#configuration-discovery}
@@ -132,7 +132,7 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建
### OpenSandbox 密钥替换 {#opensandbox-key-replacement}
-请求规则与 E2B 密钥替换相同。OpenSandbox 服务器只有一个密钥,因此检查的是访问权限:候选密钥必须能通过每个保留代次的服务器的身份验证,并列出每个未释放分配的 sandbox。401 或 403 返回 400 `sandbox_credential_invalid`,任何其他失败返回 503 `sandbox_verification_unconfirmed`。服务地址和镜像的更改不会排空生命周期。
+请求规则与 E2B 密钥替换相同。候选密钥必须能通过每个保留代次的服务器的身份验证,并列出每个未释放分配的 sandbox,因此列不出任何 sandbox 的密钥(例如另一个 OpenSandbox 租户的密钥)会被拒绝。401 或 403 返回 400 `sandbox_credential_invalid`,属于其他安装的 sandbox 返回 409 `sandbox_credential_ownership`,其他任何失败(包括某个分配的 sandbox 已不存在)返回 503 `sandbox_verification_unconfirmed`。服务地址和镜像的更改不会排空生命周期。
## 挂起策略 {#suspension-policy}
diff --git a/docs/getting-started/public-address.md b/docs/getting-started/public-address.md
index 7ce8c28dd..51fd1f6b6 100644
--- a/docs/getting-started/public-address.md
+++ b/docs/getting-started/public-address.md
@@ -14,7 +14,7 @@ Set the one origin that browsers, nodes, sandboxes and applications use to reach
| Local network | `http://:8080`, which the installer picks when `--host` is `0.0.0.0` and the default route has a private-network address |
| Beyond the local network | The HTTPS origin your reverse proxy serves, for example `https://core.example` |
| E2B backend | An HTTPS origin reachable from the internet and not loopback, because E2B's sandboxes call Core from E2B's cloud |
-| OpenSandbox backend | An origin that the OpenSandbox server's sandboxes reach and that is not loopback |
+| OpenSandbox backend | An origin that the OpenSandbox server's sandboxes reach at global addresses |
Pass the origin as `--public-url` when you install, or change it later as described in [Change the public URL](#changing-the-public-url). To install with a flag, see [Installation options](./install-options.md#pass-flags).
diff --git a/docs/getting-started/sandbox-backends.md b/docs/getting-started/sandbox-backends.md
index da1d99b5a..85ec0d61d 100644
--- a/docs/getting-started/sandbox-backends.md
+++ b/docs/getting-started/sandbox-backends.md
@@ -44,13 +44,13 @@ The key and template rules, including key replacement, are in [Initial setup and
## Use OpenSandbox
-OpenSandbox needs no nodes. Its sandboxes call Core's public address, which must not be loopback; see [Configure the public address](./public-address.md). Sandboxes with public internet access need it at a global address ([network cap](../../contracts/agents-api/sandbox-deployment.md#network)).
+OpenSandbox needs no nodes. Its sandboxes call Core's public address, which must not be loopback; see [Configure the public address](./public-address.md). It must also resolve to a global address ([network cap](../../contracts/agents-api/sandbox-deployment.md#network)).
1. Prepare the server and build the Runtime image as the [OpenSandbox adapter guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md) describes.
2. In the setup wizard, choose **OpenSandbox**. Under **Connect OpenSandbox**, enter the **Server URL**, the **Runtime image** and the **API key**.
3. On **Review and save**, keep the proposed size or change it under **Advanced settings**, then choose **Save configuration**. Core stores the key encrypted and never returns it.
-Core does not contact the server when it saves. A wrong URL, key or image shows up when the first Session's sandbox is created. When you edit a saved OpenSandbox configuration, leave the **API key** blank to keep the saved key.
+Core does not contact the server on the first save, so a wrong URL, key or image shows up when the first Session's sandbox is created. Later changes check the key against the server. When you edit a saved OpenSandbox configuration, leave the **API key** blank to keep the saved key.
## Next steps
diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md
index 7bb3a73eb..413f66635 100644
--- a/docs/sandbox-provider.md
+++ b/docs/sandbox-provider.md
@@ -206,7 +206,7 @@ The Provider enforces exactly this on top of the always-allowed connection to Co
microsandbox applies the rules with first-match order and default deny, on create and restore alike, with strict hostname enforcement and DNS rebind protection off. The sandbox reaches only the host's DNS server: DNS to any other server and every other host port are refused, so only the host's answers bind names to addresses. That server answers every name whatever the policy, so a refused name still resolves and only its connections fail. A name becomes a port-less domain allow for the addresses those answers resolve, private ones included, and a `*.example.com` entry a subdomain allow that never matches `example.com`. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all IPv4 and IPv6 traffic except the Core host, `Endpoints` and `Domains`. E2B matches a host name only on ports 80 and 443, so the adapter also allows the global IPv4 addresses of the Core host and of each Endpoint on any other port. An IP literal is allowed only as a global address range of one address, and a private literal gets no rule.
-OpenSandbox also declares `Restrict` only and requires its egress sidecar in `dns+nft` mode ([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)), whose deny rules win over every allow. `Public` therefore allows by default and denies the private, shared and link-local ranges, so it cannot honor `PrivateDomains`, and Create returns `ErrInvalid` before dispatch when the Core host, as its host resolves it, has an address outside the global ranges: opening that address would open all of its ports. Not `Public` denies by default and allows the Core host, `Endpoints` and `Domains`. A name becomes a domain rule, which OpenSandbox applies to every port of the addresses its answers resolve, private ones included, and a `*.example.com` entry matches only subdomains. An IP literal is allowed on its port: Core's always, an Endpoint's only when it is a global address. Loopback stays open because inside the sandbox it is the sandbox's own network namespace.
+OpenSandbox also declares `Restrict` only and requires its egress sidecar in `dns+nft` mode ([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)). Its deny rules win over every allow, and a domain rule opens every port of the addresses its answers resolve. Every sandbox therefore denies the private, shared and link-local ranges, so the adapter cannot honor `PrivateDomains`. Create returns `ErrInvalid` before dispatch when the Core host, as its host resolves it, has an address outside the global ranges, because allowing that address would open all of its ports. `Public` allows everything else by default. Not `Public` denies by default and allows the Core host, `Endpoints` and `Domains`: a name as a domain rule, a global IP literal on its port. Loopback stays open because inside the sandbox it is the sandbox's own network namespace.
## Checkpoint lifecycle
diff --git a/docs/zh/getting-started/public-address.md b/docs/zh/getting-started/public-address.md
index 21c345808..fc7937295 100644
--- a/docs/zh/getting-started/public-address.md
+++ b/docs/zh/getting-started/public-address.md
@@ -1,7 +1,7 @@
---
title: "配置公开地址"
source: docs/getting-started/public-address.md
-source_hash: ba97e492fde66f4bbc0c6c4af68b9910dcf28b57228950988aaa68785cd6d5c6
+source_hash: f9cb56d297618abd5c226e5417c41a69c46ee45c46f8b4c25b6d220faafd9f09
---
设置浏览器、节点、沙箱和应用访问 Core 所用的唯一源地址,并在其前面放置带 HTTPS 的反向代理。本指南面向将 Core 暴露到其所在机器之外的运维人员。
@@ -16,7 +16,7 @@ source_hash: ba97e492fde66f4bbc0c6c4af68b9910dcf28b57228950988aaa68785cd6d5c6
| 本地网络 | `http://:8080`,`--host` 为 `0.0.0.0` 且默认路由带有私有网络地址时由安装程序选用 |
| 本地网络之外 | 反向代理提供的 HTTPS 源地址,例如 `https://core.example` |
| E2B 提供商 | 可从互联网访问且不是回环地址的 HTTPS 源地址,因为 E2B 沙箱会从 E2B 云端调用 Core |
-| OpenSandbox 提供商 | OpenSandbox 服务器的沙箱可访问且不是回环地址的源地址 |
+| OpenSandbox 提供商 | OpenSandbox 服务器的沙箱可在全局地址上访问的源地址 |
安装时用 `--public-url` 传入该源地址,或按[更改公开 URL](#changing-the-public-url)稍后修改。使用选项安装,参阅[安装选项](./install-options.md#pass-flags)。
diff --git a/docs/zh/getting-started/sandbox-backends.md b/docs/zh/getting-started/sandbox-backends.md
index dc26201fc..e1720b93f 100644
--- a/docs/zh/getting-started/sandbox-backends.md
+++ b/docs/zh/getting-started/sandbox-backends.md
@@ -1,7 +1,7 @@
---
title: "选择沙箱提供商"
source: docs/getting-started/sandbox-backends.md
-source_hash: 13d81221aac68a2edde3434c1ae51b76c052d034f55a8a7162d9eac5d7a602d5
+source_hash: 6ff014aa67c43fc73e2ed049d8ec5ceb65f771511af0ba2ec0d692d6d529191a
---
比较 microsandbox、E2B 和 OpenSandbox,然后在 Web 中选择一个。本指南面向已完成[安装 Core 和 Web](./install.md)、需要为 Core 托管的 Session 提供计算资源的运维人员。
@@ -46,13 +46,13 @@ E2B 不需要节点,但其沙箱会从 E2B 云端调用 Core,因此 Core 需
## 使用 OpenSandbox {#use-opensandbox}
-OpenSandbox 不需要节点。其沙箱会调用 Core 的公共地址,该地址不能是回环地址;参阅[配置公开地址](./public-address.md)。可访问公网的沙箱需要它是全局地址([网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network))。
+OpenSandbox 不需要节点。其沙箱会调用 Core 的公共地址,该地址不能是回环地址;参阅[配置公开地址](./public-address.md)。它还必须解析到全局地址([网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network))。
1. 按 [OpenSandbox adapter 指南](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/opensandbox/README.md)准备服务器并构建 Runtime 镜像。
2. 在设置向导中选择 **OpenSandbox**。在 **连接 OpenSandbox** 页输入 **服务地址**、**Runtime 镜像** 和 **API 密钥**。
3. 在 **确认并保存** 页,保留推荐的规格,或在 **高级设置** 中修改,然后选择 **保存配置**。Core 加密保存该密钥,且不会再返回它。
-Core 保存时不会联系服务器。URL、密钥或镜像有误,会在首个 Session 的沙箱创建时暴露。编辑已保存的 OpenSandbox 配置时,**API 密钥** 留空即保留已保存的密钥。
+Core 首次保存时不会联系服务器,因此 URL、密钥或镜像有误,会在首个 Session 的沙箱创建时暴露。之后的更改会向服务器校验密钥。编辑已保存的 OpenSandbox 配置时,**API 密钥** 留空即保留已保存的密钥。
## 后续步骤 {#next-steps}
diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md
index 54dd652cd..184328f9e 100644
--- a/docs/zh/sandbox-provider.md
+++ b/docs/zh/sandbox-provider.md
@@ -1,7 +1,7 @@
---
title: "沙箱提供商协议"
source: docs/sandbox-provider.md
-source_hash: 37fee1a55da581c55a1b653e5712ff99021ed07ef88d0e482aa545b42573e3a9
+source_hash: 4574b75eba13665db11b98498c90a9329dabdd4714dcf6383f777c54143f2772
---
沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox、E2B 和 OpenSandbox 实现了该协议。
@@ -208,7 +208,7 @@ Provider 在始终允许到 Core 的连接之上严格执行这些内容,并
microsandbox 按首条匹配的顺序和默认拒绝来应用规则,创建与恢复相同,并关闭严格主机名校验和 DNS rebind 保护。沙箱只能访问主机的 DNS 服务器:发往其他服务器的 DNS 和主机的其他端口都被拒绝,因此只有主机的应答能把名称绑定到地址。该服务器不论策略都会应答任何名称,因此被拒绝的名称仍能解析,只是连接会失败。名称成为不带端口的域名放行规则,放行这些应答解析出的地址(包括私有地址);`*.example.com` 条目成为子域名放行规则,永远不匹配 `example.com`。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部 IPv4 和 IPv6 流量。E2B 只在 80 和 443 端口匹配主机名,因此 adapter 还会为 Core 主机和每个 Endpoint 在其他端口上允许其全球 IPv4 地址。IP 字面量只作为单地址的全球地址范围被允许,私有字面量没有规则。
-OpenSandbox 同样只声明 `Restrict`,并要求其出口 sidecar 以 `dns+nft` 模式运行([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)),其拒绝规则优先于所有放行规则。因此 `Public` 时默认放行,并拒绝私有、共享和链路本地网段,所以无法遵守 `PrivateDomains`;当 Core 主机由其主机解析出的地址不全在全局网段时,Create 在派发前返回 `ErrInvalid`:放行该地址会放行它的所有端口。非 `Public` 时默认拒绝,并放行 Core 主机、`Endpoints` 和 `Domains`。名称成为域名规则,OpenSandbox 对其应答解析出的地址(包括私有地址)的所有端口应用该规则;`*.example.com` 条目只匹配子域名。IP 字面量在其端口上被允许:Core 的始终允许,Endpoint 的仅在其为全球地址时允许。回环保持开放,因为在沙箱内部它就是沙箱自己的网络命名空间。
+OpenSandbox 同样只声明 `Restrict`,并要求其出口 sidecar 以 `dns+nft` 模式运行([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)),其拒绝规则优先于所有放行规则,而域名规则会放行其应答解析出的地址的所有端口。因此每个沙箱都拒绝私有、共享和链路本地网段,adapter 无法遵守 `PrivateDomains`。当 Core 主机由其主机解析出的地址不全在全局网段时,Create 在派发前返回 `ErrInvalid`,因为放行该地址会放行它的所有端口。`Public` 时其余流量默认放行。非 `Public` 时默认拒绝,并放行 Core 主机、`Endpoints` 和 `Domains`:名称作为域名规则,全局 IP 字面量在其端口上放行。回环保持开放,因为在沙箱内部它就是沙箱自己的网络命名空间。
## 检查点生命周期 {#checkpoint-lifecycle}
diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts
index 2fe61858a..36f1cfe54 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}],"network":{"max_domains":100,"max_domain_length":253,"domain_pattern":"^(?:\\*\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$"},"providers":{"e2b":{"mode":"direct","disk":false,"default_resources":null,"network":{"restrict":true,"private_domains":false},"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"network":{"restrict":true,"private_domains":true},"setup":{"label":"microsandbox","summary":{"en":"Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}},"opensandbox":{"mode":"direct","disk":false,"default_resources":{"cpus":2,"memory_mib":4096},"network":{"restrict":true,"private_domains":false},"setup":{"label":"OpenSandbox","summary":{"en":"Runs sandboxes on your own OpenSandbox server, on Docker or Kubernetes.","zh-CN":"沙箱运行在你自己的 OpenSandbox 服务上,支持 Docker 或 Kubernetes。"},"recommended":false,"fields":[{"name":"api_url","secret":false,"optional":false,"discovered":false,"label":{"en":"Server URL","zh-CN":"服务地址"},"help":{"en":"","zh-CN":""}},{"name":"image","secret":false,"optional":false,"discovered":false,"label":{"en":"Runtime image","zh-CN":"Runtime 镜像"},"help":{"en":"Build it from the OpenAgentCore Runtime image with deploy/opensandbox/Dockerfile.","zh-CN":"用 deploy/opensandbox/Dockerfile 从 OpenAgentCore Runtime 镜像构建。"}},{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}}],"presets":[],"credential":"required","public_origin":"required","discovery":false}}},"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}],"network":{"max_domains":100,"max_domain_length":253,"domain_pattern":"^(?:\\*\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$"},"providers":{"e2b":{"mode":"direct","disk":false,"default_resources":null,"network":{"restrict":true,"private_domains":false},"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"network":{"restrict":true,"private_domains":true},"setup":{"label":"microsandbox","summary":{"en":"Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}},"opensandbox":{"mode":"direct","disk":false,"default_resources":{"cpus":2,"memory_mib":4096},"network":{"restrict":true,"private_domains":false},"setup":{"label":"OpenSandbox","summary":{"en":"Runs sandboxes on your own OpenSandbox server, on Docker or Kubernetes.","zh-CN":"沙箱运行在你自己的 OpenSandbox 服务上,支持 Docker 或 Kubernetes。"},"recommended":false,"fields":[{"name":"api_url","secret":false,"optional":false,"discovered":false,"label":{"en":"Server URL","zh-CN":"服务地址"},"help":{"en":"","zh-CN":""}},{"name":"image","secret":false,"optional":false,"discovered":false,"label":{"en":"Runtime image","zh-CN":"Runtime 镜像"},"help":{"en":"Build it from the OpenAgentCore Runtime image with services/core/deploy/opensandbox/Dockerfile.","zh-CN":"用 services/core/deploy/opensandbox/Dockerfile 从 OpenAgentCore Runtime 镜像构建。"}},{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}}],"presets":[],"credential":"required","public_origin":"required","discovery":false}}},"minimum_disk":1024} as const;
diff --git a/services/core/deploy/opensandbox/README.md b/services/core/deploy/opensandbox/README.md
index 6c5d566bb..ff2ed8c36 100644
--- a/services/core/deploy/opensandbox/README.md
+++ b/services/core/deploy/opensandbox/README.md
@@ -6,13 +6,14 @@ Core's OpenSandbox Sandbox Provider ([`sandbox/opensandbox`](../../internal/sand
| Setting | Requirement |
| --- | --- |
-| Version | OpenSandbox server 1.2.0 or newer |
+| Version | OpenSandbox release 1.2.0 or newer |
| `server.api_key` | Set; Core sends it as `OPEN-SANDBOX-API-KEY` ([Configuration](https://open-sandbox.ai/getting-started/configuration)) |
| `[egress] mode` | `dns+nft`. Core's network policy needs packet-level enforcement ([Egress](https://open-sandbox.ai/architecture/network/egress)), and Create fails on a server that reports another mode |
| `server.max_sandbox_timeout_seconds` | Unset or at least 3600: Core creates each sandbox with a one-hour lease and renews it |
-| Network | Core reaches the server URL; sandboxes reach Core's [public address](../../../../docs/getting-started/public-address.md) |
+| `[docker] publish_host` | `127.0.0.1` on the Docker runtime, with the server on the host network. execd answers on each sandbox's published port without authentication, and Core reaches it only through the server's proxy |
+| Network | Core reaches the server URL; sandboxes reach Core's [public address](../../../../docs/getting-started/public-address.md), which must resolve to global addresses ([network cap](../../../../contracts/agents-api/sandbox-deployment.md#network)) |
-The isolation is OpenSandbox's: its Docker runtime runs plain containers, and a [secure container runtime](https://open-sandbox.ai/guides/secure-container) gives each sandbox its own kernel. Core reaches each sandbox's execd through the server's proxy, so restrict who can reach the server and its sandbox ports as OpenSandbox's [Secure Access](https://open-sandbox.ai/guides/secure-access) and [Single-Host Network](https://open-sandbox.ai/architecture/network/single-host-network) describe.
+The isolation is OpenSandbox's: its Docker runtime runs plain containers, and a [secure container runtime](https://open-sandbox.ai/guides/secure-container) gives each sandbox its own kernel. Restrict who can reach the server; on Kubernetes, [Secure Access](https://open-sandbox.ai/guides/secure-access) protects the sandbox endpoints.
## Build the Runtime image
@@ -39,7 +40,7 @@ Rebuild it with every Core release and select the new image in the deployment. T
| `RunCommand` | execd `POST /command` as the Runtime user; confidential input goes through a private file that the command reads and removes |
| `Observe` | execd `GET /metrics`; see [Runtime observability](../../../../contracts/agents-api/runtime-observability.md) |
-Every sandbox carries `io.oac.installation`, `io.oac.tenant`, `io.oac.environment` and `io.oac.allocation`, and the adapter acts only on a sandbox whose four values match. It keeps no local state. One create request yields at most one sandbox, every later step of the attempt runs inside it, and Kill removes it, so an owned sandbox observed in any state but `Pending` settles the creation; Core records that before cleanup. A create that OpenSandbox rejects with a 4xx status settles as absent. Any other unknown outcome stays unsettled until a sandbox appears, and the one-hour timeout ends a late one.
+Every sandbox carries `io.oac.installation`, `io.oac.tenant`, `io.oac.environment` and `io.oac.allocation`, and the adapter acts only on a sandbox whose four values match. It keeps no local state. One create request yields at most one sandbox, every later step of the attempt runs inside it, and Kill removes it, so an owned sandbox observed in any state but `Pending` settles the creation; Core records that before cleanup. A create that OpenSandbox rejects with a 4xx status, or that never reaches the server because the dial, name lookup or certificate check fails, settles as absent. Any other unknown outcome stays unsettled until a sandbox appears, and the one-hour timeout ends a late one.
The checkpoint operations are not mapped. OpenSandbox's [pause](https://open-sandbox.ai/guides/pause-resume) differs by runtime: Docker freezes the container in place, Kubernetes keeps only the root filesystem, and only Fast Sandbox checkpoints memory. The preparation cache is not mapped either. [Network egress](../../../../docs/sandbox-provider.md#network-egress) describes how Core's egress maps to the network policy.
diff --git a/services/core/internal/sandbox/opensandbox/configuration.go b/services/core/internal/sandbox/opensandbox/configuration.go
index 8d9c971e8..8d9bb5d1b 100644
--- a/services/core/internal/sandbox/opensandbox/configuration.go
+++ b/services/core/internal/sandbox/opensandbox/configuration.go
@@ -32,7 +32,7 @@ func Setup() sandbox.SetupDeclaration {
Fields: []sandbox.SetupField{
{Name: "api_url", Label: sandbox.Text{EN: "Server URL", ZHCN: "服务地址"}},
{Name: "image", Label: sandbox.Text{EN: "Runtime image", ZHCN: "Runtime 镜像"},
- Help: sandbox.Text{EN: "Build it from the OpenAgentCore Runtime image with deploy/opensandbox/Dockerfile.", ZHCN: "用 deploy/opensandbox/Dockerfile 从 OpenAgentCore Runtime 镜像构建。"}},
+ Help: sandbox.Text{EN: "Build it from the OpenAgentCore Runtime image with services/core/deploy/opensandbox/Dockerfile.", ZHCN: "用 services/core/deploy/opensandbox/Dockerfile 从 OpenAgentCore Runtime 镜像构建。"}},
{Name: "api_key", Secret: true, Label: sandbox.Text{EN: "API key", ZHCN: "API 密钥"}},
},
}
diff --git a/services/core/internal/sandbox/opensandbox/fake_test.go b/services/core/internal/sandbox/opensandbox/fake_test.go
index ae7b4f895..fd992e90f 100644
--- a/services/core/internal/sandbox/opensandbox/fake_test.go
+++ b/services/core/internal/sandbox/opensandbox/fake_test.go
@@ -235,12 +235,7 @@ func (f *fake) command(w http.ResponseWriter, r *http.Request) {
func testProvider(t *testing.T, f *fake) (*Provider, sandbox.Reference) {
t.Helper()
- installation := uuid.NewString()
- p, err := newProvider(sandbox.DirectConfig{InstallationID: installation, Selection: sandbox.Selection{
- Provider: "opensandbox",
- DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 4096}, Network: sandbox.DefaultNetwork()},
- Configuration: &DeploymentConfiguration{APIURL: f.server.URL, Image: "runtime:test", APIKey: "synthetic-key"},
- }})
+ p, err := newProvider(testConfig(f.server.URL))
if err != nil {
t.Fatal(err)
}
@@ -250,6 +245,14 @@ func testProvider(t *testing.T, f *fake) (*Provider, sandbox.Reference) {
return p, sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}
}
+func testConfig(apiURL string) sandbox.DirectConfig {
+ return sandbox.DirectConfig{InstallationID: uuid.NewString(), Selection: sandbox.Selection{
+ Provider: "opensandbox",
+ DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 4096}, Network: sandbox.DefaultNetwork()},
+ Configuration: &DeploymentConfiguration{APIURL: apiURL, Image: "runtime:test", APIKey: "synthetic-key"},
+ }}
+}
+
func bootstrap(r sandbox.Reference) sandbox.Bootstrap {
return sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic",
Egress: sandbox.Egress{Public: true, PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{}}}
diff --git a/services/core/internal/sandbox/opensandbox/network.go b/services/core/internal/sandbox/opensandbox/network.go
index f703a8d61..e8c8def66 100644
--- a/services/core/internal/sandbox/opensandbox/network.go
+++ b/services/core/internal/sandbox/opensandbox/network.go
@@ -14,7 +14,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
)
-// privatePrefixes are the addresses a public-internet sandbox must not reach.
+// privatePrefixes are the addresses no sandbox may reach.
// Loopback is not among them: inside the sandbox's network namespace it is the
// sandbox itself (execd and the sidecar's DNS proxy), never the host.
var privatePrefixes = []netip.Prefix{
@@ -25,9 +25,11 @@ var privatePrefixes = []netip.Prefix{
type resolver func(ctx context.Context, host string) ([]netip.Addr, error)
// networkPolicy maps Egress to the OpenSandbox egress policy. The sidecar lets a
-// deny rule win over any allow, so a public sandbox, which denies the private
-// ranges, needs Core at global addresses; any other sandbox denies by default
-// and allows Core, the model provider endpoints and the named domains.
+// deny rule win over any allow and opens every port of an allowed name's
+// addresses, so every sandbox denies the private ranges and Core must be at
+// global addresses. A public sandbox allows the rest by default; any other
+// denies by default and allows Core, the model provider endpoints and the
+// named domains.
func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) (*osdk.NetworkPolicy, error) {
core, err := url.Parse(b.CoreURL)
if err != nil || core.Hostname() == "" {
@@ -42,27 +44,25 @@ func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) (
return nil, sandbox.ErrInvalid
}
}
+ addrs, err := hostAddrs(ctx, coreHost, resolve)
+ if err != nil {
+ return nil, err
+ }
+ if slices.ContainsFunc(addrs, func(a netip.Addr) bool { return !isGlobal(a) }) {
+ return nil, fmt.Errorf("%w: OpenSandbox sandboxes reach Core only at global addresses", sandbox.ErrInvalid)
+ }
var rules []osdk.NetworkRule
+ for _, prefix := range privatePrefixes {
+ rules = append(rules, osdk.NetworkRule{Action: "deny", Target: prefix.String()})
+ }
if b.Egress.Public {
- addrs, err := hostAddrs(ctx, coreHost, resolve)
- if err != nil {
- return nil, err
- }
- // Opening a private Core address would open all of its ports.
- if slices.ContainsFunc(addrs, func(a netip.Addr) bool { return !isGlobal(a) }) {
- return nil, fmt.Errorf("%w: a public OpenSandbox sandbox reaches Core only at global addresses", sandbox.ErrInvalid)
- }
- for _, prefix := range privatePrefixes {
- rules = append(rules, osdk.NetworkRule{Action: "deny", Target: prefix.String()})
- }
return &osdk.NetworkPolicy{DefaultAction: "allow", Egress: rules}, nil
}
- // allow adds a domain rule, or an IP rule on the TCP port. Only Core's own
- // address may be non-global.
- allow := func(host string, port int, isCore bool) {
+ // allow adds a domain rule, or an IP rule on the TCP port for a global address.
+ allow := func(host string, port int) {
rule := osdk.NetworkRule{Action: "allow", Target: host}
if addr, err := netip.ParseAddr(host); err == nil {
- if !isCore && !isGlobal(addr) {
+ if !isGlobal(addr) {
return
}
rule.Ports = []int{port}
@@ -71,16 +71,16 @@ func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) (
rules = append(rules, rule)
}
}
- allow(coreHost, corePort, true)
+ allow(coreHost, corePort)
for _, endpoint := range b.Egress.Endpoints {
host, port, err := sandbox.SplitEndpoint(endpoint)
if err != nil {
return nil, err
}
- allow(strings.ToLower(host), port, false)
+ allow(strings.ToLower(host), port)
}
for _, domain := range b.Egress.Domains {
- allow(strings.ToLower(domain), 0, false)
+ allow(strings.ToLower(domain), 0)
}
return &osdk.NetworkPolicy{DefaultAction: "deny", Egress: rules}, nil
}
diff --git a/services/core/internal/sandbox/opensandbox/network_test.go b/services/core/internal/sandbox/opensandbox/network_test.go
index c40d4b40d..4748cc1d0 100644
--- a/services/core/internal/sandbox/opensandbox/network_test.go
+++ b/services/core/internal/sandbox/opensandbox/network_test.go
@@ -41,15 +41,29 @@ func targets(policy *osdk.NetworkPolicy) []string {
return out
}
+const privateDenies = "deny 10.0.0.0/8,deny 172.16.0.0/12,deny 192.168.0.0/16,deny 100.64.0.0/10,deny 169.254.0.0/16,deny fc00::/7,deny fe80::/10"
+
func TestPublicNetworkDeniesPrivateRanges(t *testing.T) {
policy := policyFor(t, "https://core.example/api/v1", sandbox.Egress{Public: true}, "203.0.113.5")
- want := "deny 10.0.0.0/8,deny 172.16.0.0/12,deny 192.168.0.0/16,deny 100.64.0.0/10,deny 169.254.0.0/16,deny fc00::/7,deny fe80::/10"
- if policy.DefaultAction != "allow" || strings.Join(targets(policy), ",") != want {
+ if policy.DefaultAction != "allow" || strings.Join(targets(policy), ",") != privateDenies {
t.Fatalf("%s rules = %v", policy.DefaultAction, targets(policy))
}
}
-func TestPublicNetworkRejectsPrivateCore(t *testing.T) {
+func TestRestrictedNetworkAllowsOnlyNamedDestinations(t *testing.T) {
+ egress := sandbox.Egress{Domains: []string{"Docs.Example.com"}, Endpoints: []string{"api.model.example:443", "203.0.113.9:8443", "10.0.0.7:9000", "api.model.example:8443"}}
+ policy := policyFor(t, "https://core.example/api/v1", egress, "203.0.113.5")
+ // The private denies win over the private answers of every allowed name.
+ want := privateDenies + ",allow core.example,allow api.model.example,allow 203.0.113.9:8443,allow docs.example.com"
+ if policy.DefaultAction != "deny" || strings.Join(targets(policy), ",") != want {
+ t.Fatalf("%s rules = %v, want %v", policy.DefaultAction, targets(policy), want)
+ }
+ if policy := policyFor(t, "http://203.0.113.4:8080/api/v1", sandbox.Egress{}); strings.Join(targets(policy), ",") != privateDenies+",allow 203.0.113.4:8080" {
+ t.Fatalf("core literal rules = %v", targets(policy))
+ }
+}
+
+func TestNetworkRejectsPrivateCore(t *testing.T) {
for name, tc := range map[string]struct {
url string
resolved []string
@@ -61,24 +75,14 @@ func TestPublicNetworkRejectsPrivateCore(t *testing.T) {
}
return out, nil
}
- if _, err := networkPolicy(t.Context(), sandbox.Bootstrap{CoreURL: tc.url, Egress: sandbox.Egress{Public: true}}, resolve); !errors.Is(err, sandbox.ErrInvalid) {
- t.Fatalf("%s: %v", name, err)
+ for _, public := range []bool{true, false} {
+ if _, err := networkPolicy(t.Context(), sandbox.Bootstrap{CoreURL: tc.url, Egress: sandbox.Egress{Public: public}}, resolve); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatalf("%s public=%v: %v", name, public, err)
+ }
}
}
}
-func TestRestrictedNetworkAllowsOnlyNamedDestinations(t *testing.T) {
- egress := sandbox.Egress{Domains: []string{"Docs.Example.com"}, Endpoints: []string{"api.model.example:443", "203.0.113.9:8443", "10.0.0.7:9000", "api.model.example:8443"}}
- got := targets(policyFor(t, "https://core.example/api/v1", egress))
- want := []string{"allow core.example", "allow api.model.example", "allow 203.0.113.9:8443", "allow docs.example.com"}
- if strings.Join(got, ",") != strings.Join(want, ",") {
- t.Fatalf("rules = %v, want %v", got, want)
- }
- if policy := policyFor(t, "http://10.2.3.4:8080/api/v1", sandbox.Egress{}); policy.DefaultAction != "deny" || strings.Join(targets(policy), ",") != "allow 10.2.3.4:8080" {
- t.Fatalf("core literal rules = %v", targets(policy))
- }
-}
-
func TestUnresolvableCoreFailsBeforeDispatch(t *testing.T) {
fail := func(context.Context, string) ([]netip.Addr, error) { return nil, errors.New("no such host") }
if _, err := networkPolicy(t.Context(), sandbox.Bootstrap{CoreURL: "https://core.example/api/v1", Egress: sandbox.Egress{Public: true}}, fail); !errors.Is(err, sandbox.ErrInvalid) {
diff --git a/services/core/internal/sandbox/opensandbox/provider.go b/services/core/internal/sandbox/opensandbox/provider.go
index 80f23caea..3f43090a9 100644
--- a/services/core/internal/sandbox/opensandbox/provider.go
+++ b/services/core/internal/sandbox/opensandbox/provider.go
@@ -3,6 +3,7 @@ package opensandbox
import (
"bytes"
"context"
+ "crypto/tls"
"errors"
"fmt"
"net"
@@ -95,6 +96,15 @@ func nativeErr(err error) error {
return sandbox.ErrComputeUnconfirmed
}
+// notSent reports a failure before the request reached the server: the dial,
+// the name lookup or the server certificate failed.
+func notSent(err error) bool {
+ var lookup *net.DNSError
+ var dial *net.OpError
+ var certificate *tls.CertificateVerificationError
+ return errors.As(err, &lookup) || errors.As(err, &dial) && dial.Op == "dial" || errors.As(err, &certificate)
+}
+
// statusOf is the HTTP status of a native error response, or 0.
func statusOf(err error) int {
var api *osdk.APIError
@@ -230,7 +240,7 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
return sandbox.Info{Reference: r, State: "absent", CreateSettled: true}, err
}
if b.Workspace != nil {
- return rejected(unsupported("Create"))
+ return rejected(sandbox.ErrInvalid)
}
connection := b.RuntimeConnection()
if !validReference(r) || !validID(b.SessionID) || !validID(b.DeviceID) || connection.Validate() != nil || b.Egress.Validate() != nil || len(b.Egress.PrivateDomains) > 0 {
@@ -262,6 +272,8 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
return rejected(sandbox.ErrCredentialRejected)
case status >= 400 && status < 500:
return rejected(sandbox.ErrInvalid)
+ case notSent(err):
+ return rejected(sandbox.ErrComputeUnconfirmed)
}
return sandbox.Info{Reference: r}, sandbox.ErrComputeUnconfirmed
}
diff --git a/services/core/internal/sandbox/opensandbox/provider_test.go b/services/core/internal/sandbox/opensandbox/provider_test.go
index 829f4d649..5dd7b3349 100644
--- a/services/core/internal/sandbox/opensandbox/provider_test.go
+++ b/services/core/internal/sandbox/opensandbox/provider_test.go
@@ -4,11 +4,14 @@ import (
"context"
"errors"
"net/http"
+ "net/http/httptest"
"reflect"
"strings"
"testing"
"time"
+ "github.com/google/uuid"
+
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/contracttest"
@@ -115,6 +118,63 @@ func TestCreateWaitsForTheEgressSidecar(t *testing.T) {
}
}
+func TestOnlyAnUnsentCreateIsAbsent(t *testing.T) {
+ closed := httptest.NewServer(http.NotFoundHandler())
+ closed.Close()
+ if _, err := (&http.Client{Transport: &http.Transport{}}).Post(closed.URL, "text/plain", nil); !notSent(err) {
+ t.Fatal("refused dial not classified as unsent:", err)
+ }
+ // A connection dropped after the request was sent proves nothing.
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.fault = func(call string, w http.ResponseWriter) bool {
+ if call != "create" {
+ return false
+ }
+ conn, _, _ := w.(http.Hijacker).Hijack()
+ conn.Close()
+ return true
+ }
+ if got, err := p.Create(bounded(t), bootstrap(r)); !errors.Is(err, sandbox.ErrComputeUnconfirmed) || got.CreateSettled {
+ t.Fatalf("create = %+v, %v", got, err)
+ }
+}
+
+func TestVerifyCredentialNeedsListedOwnership(t *testing.T) {
+ f := newFake(t)
+ config := testConfig(f.server.URL)
+ r := sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}
+ verify := func(refs ...sandbox.Reference) error {
+ return ConfigurationAdapter{}.VerifyCredential(t.Context(), config, refs)
+ }
+ if err := verify(r); !errors.Is(err, sandbox.ErrConfigurationUnconfirmed) {
+ t.Fatal("empty listing accepted:", err)
+ }
+ f.add(r, config.InstallationID, nil)
+ if err := verify(r); err != nil {
+ t.Fatal(err)
+ }
+ if err := verify(make([]sandbox.Reference, maxCredentialReferences+1)...); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal("unbounded references accepted:", err)
+ }
+ f.fault = func(call string, w http.ResponseWriter) bool {
+ writeJSON(w, 401, map[string]string{"code": "UNAUTHORIZED", "message": "bad key"})
+ return true
+ }
+ if err := verify(r); !errors.Is(err, sandbox.ErrCredentialRejected) {
+ t.Fatal(err)
+ }
+ f.fault = func(call string, w http.ResponseWriter) bool {
+ writeJSON(w, 200, map[string]any{"items": []map[string]any{{"id": "x", "status": map[string]any{"state": "Running"},
+ "metadata": map[string]string{keyInstallation: config.InstallationID, keyTenant: r.TenantID, keyEnvironment: r.EnvironmentID, keyAllocation: "foreign"}}},
+ "pagination": map[string]any{"hasNextPage": false}})
+ return true
+ }
+ if err := verify(r); !errors.Is(err, sandbox.ErrCredentialOwnership) {
+ t.Fatal(err)
+ }
+}
+
func TestCreateBeforeDispatchIsAbsent(t *testing.T) {
f := newFake(t)
p, r := testProvider(t, f)
diff --git a/services/core/internal/sandbox/opensandbox/verify.go b/services/core/internal/sandbox/opensandbox/verify.go
index c4f77696d..9d3159f6b 100644
--- a/services/core/internal/sandbox/opensandbox/verify.go
+++ b/services/core/internal/sandbox/opensandbox/verify.go
@@ -22,7 +22,7 @@ func (ConfigurationAdapter) DiscoverSelection(context.Context, sandbox.DirectCon
}
// VerifyCredential is read-only and bounded: the key must list sandboxes, and
-// every Core-owned reference must resolve to sandboxes of this installation.
+// every Core-owned reference must list sandboxes of this installation.
func (ConfigurationAdapter) VerifyCredential(ctx context.Context, c sandbox.DirectConfig, refs []sandbox.Reference) error {
p, err := newProvider(c)
if err != nil || len(refs) > maxCredentialReferences {
@@ -44,12 +44,14 @@ func (ConfigurationAdapter) VerifyCredential(ctx context.Context, c sandbox.Dire
return sandbox.ErrConfigurationUnconfirmed
}
for _, r := range refs {
- switch _, err := p.owned(ctx, r); {
+ // An empty listing never proves the key sees the reference's sandboxes:
+ // a key of another OpenSandbox tenant lists nothing too.
+ switch items, err := p.owned(ctx, r); {
case errors.Is(err, sandbox.ErrOwnership):
return sandbox.ErrCredentialOwnership
case errors.Is(err, sandbox.ErrCredentialRejected):
return err
- case err != nil:
+ case err != nil || len(items) == 0:
return sandbox.ErrConfigurationUnconfirmed
}
}
diff --git a/services/core/internal/sandbox/operations_test.go b/services/core/internal/sandbox/operations_test.go
index d8bca197b..f7622166a 100644
--- a/services/core/internal/sandbox/operations_test.go
+++ b/services/core/internal/sandbox/operations_test.go
@@ -7,6 +7,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"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/opensandbox"
"reflect"
"testing"
)
@@ -58,7 +59,7 @@ func TestDeclarationsRejectMissingUnknownAndContradictoryOperations(t *testing.T
// Unsupported methods must be callable safely even without native clients or
// configuration: any attempt at native I/O would panic on these zero providers.
func TestEveryUnsupportedNativeOperationRejectsWithoutSideEffects(t *testing.T) {
- for _, provider := range []sandbox.SandboxProvider{µsandbox.Provider{}, &e2b.Provider{}, µsandbox.Provider{}} {
+ for _, provider := range []sandbox.SandboxProvider{µsandbox.Provider{}, &e2b.Provider{}, &opensandbox.Provider{}} {
if err := sandbox.ValidateProvider(provider); err != nil {
t.Fatal(err)
}
From c829f5e9d46b64b925f3a6eaea3a9a2b302812b5 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Tue, 3 Nov 2026 10:37:53 +0000
Subject: [PATCH 6/7] Settle OpenSandbox creates from any observed sandbox and
confirmed native errors
---
contracts/agents-api/environments.md | 2 +-
contracts/agents-api/zh/environments.md | 4 +-
services/core/deploy/opensandbox/README.md | 7 +-
.../internal/sandbox/opensandbox/provider.go | 70 ++++++++++++-------
.../sandbox/opensandbox/provider_test.go | 35 +++++++++-
5 files changed, 85 insertions(+), 33 deletions(-)
diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md
index 4108a296e..6c06d27f8 100644
--- a/contracts/agents-api/environments.md
+++ b/contracts/agents-api/environments.md
@@ -313,7 +313,7 @@ Limits ([adapter rules](../../docs/sandbox-provider.md#network-egress)):
- A model provider host that the cap lists, by name or through a `*.` entry, is reachable like a listed domain, not only on its port.
- On E2B, a restricted domain is reachable only on ports 80 and 443, and a model provider on another port is reachable on every port of its public addresses.
-- On OpenSandbox, an allowed domain and the model provider host are reachable on every port of their public addresses.
+- On OpenSandbox, the Core host, an allowed domain and the model provider host are reachable on every port of their public addresses.
`disabled` and `restricted` return `sandbox_network_unsupported` when the deployment's sandbox Provider cannot restrict egress. A `self_hosted` Environment accepts only `enabled`, because Core cannot enforce a policy on a machine it does not run.
diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md
index fd66bfd81..d0d6f4aa3 100644
--- a/contracts/agents-api/zh/environments.md
+++ b/contracts/agents-api/zh/environments.md
@@ -1,7 +1,7 @@
---
title: "Environment 与模板"
source: contracts/agents-api/environments.md
-source_hash: db29e6b679d41900e5369e625844f0096cfdf7614ae73007b43de1cb00843a24
+source_hash: 86e73d81ff9ccf658d542d3424ddee590b5914e868cd756e4f1cf85ba1b7996c
---
Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。
@@ -315,7 +315,7 @@ Core 可以用同一 Project 中较早一次准备的结果启动托管 Environm
- 上限按名称或 `*.` 条目列出的模型提供商主机与所列域名一样可达,不限于其端口。
- 在 E2B 上,受限域名只能通过 80 和 443 端口访问;模型提供商使用其他端口时,其公网地址的所有端口均可达。
-- 在 OpenSandbox 上,允许的域名和模型提供商主机在其公网地址的每个端口上都可达。
+- 在 OpenSandbox 上,Core 主机、允许的域名和模型提供商主机在其公网地址的每个端口上都可达。
部署的沙箱 Provider 无法限制出站时,`disabled` 和 `restricted` 返回 `sandbox_network_unsupported`。`self_hosted` Environment 只接受 `enabled`,因为 Core 无法在不由它运行的机器上强制策略。
diff --git a/services/core/deploy/opensandbox/README.md b/services/core/deploy/opensandbox/README.md
index ff2ed8c36..f581f8632 100644
--- a/services/core/deploy/opensandbox/README.md
+++ b/services/core/deploy/opensandbox/README.md
@@ -11,9 +11,10 @@ Core's OpenSandbox Sandbox Provider ([`sandbox/opensandbox`](../../internal/sand
| `[egress] mode` | `dns+nft`. Core's network policy needs packet-level enforcement ([Egress](https://open-sandbox.ai/architecture/network/egress)), and Create fails on a server that reports another mode |
| `server.max_sandbox_timeout_seconds` | Unset or at least 3600: Core creates each sandbox with a one-hour lease and renews it |
| `[docker] publish_host` | `127.0.0.1` on the Docker runtime, with the server on the host network. execd answers on each sandbox's published port without authentication, and Core reaches it only through the server's proxy |
+| Kubernetes | Pod and service ranges outside the private ranges added to the egress `deny.always` list ([Network isolation](https://open-sandbox.ai/guides/network-isolation)); the adapter denies only private, shared and link-local ranges |
| Network | Core reaches the server URL; sandboxes reach Core's [public address](../../../../docs/getting-started/public-address.md), which must resolve to global addresses ([network cap](../../../../contracts/agents-api/sandbox-deployment.md#network)) |
-The isolation is OpenSandbox's: its Docker runtime runs plain containers, and a [secure container runtime](https://open-sandbox.ai/guides/secure-container) gives each sandbox its own kernel. Restrict who can reach the server; on Kubernetes, [Secure Access](https://open-sandbox.ai/guides/secure-access) protects the sandbox endpoints.
+The isolation is OpenSandbox's: its Docker runtime runs plain containers, and a [secure container runtime](https://open-sandbox.ai/guides/secure-container) gives each sandbox its own kernel. The adapter does not request [Secure Access](https://open-sandbox.ai/guides/secure-access), so restrict who can reach the server and its ingress.
## Build the Runtime image
@@ -27,7 +28,7 @@ docker build --build-arg RUNTIME_IMAGE=oac-runtime: \
docker push registry.example.com/oac/runtime-opensandbox:
```
-Rebuild it with every Core release and select the new image in the deployment. The image is deployment configuration, not a public Environment Template.
+Rebuild it with every Core release, pull it on every OpenSandbox host, then select it in the deployment: Core gives each Create 30 seconds, which a pull inside the create request can exceed. The image is deployment configuration, not a public Environment Template.
## Operations
@@ -40,7 +41,7 @@ Rebuild it with every Core release and select the new image in the deployment. T
| `RunCommand` | execd `POST /command` as the Runtime user; confidential input goes through a private file that the command reads and removes |
| `Observe` | execd `GET /metrics`; see [Runtime observability](../../../../contracts/agents-api/runtime-observability.md) |
-Every sandbox carries `io.oac.installation`, `io.oac.tenant`, `io.oac.environment` and `io.oac.allocation`, and the adapter acts only on a sandbox whose four values match. It keeps no local state. One create request yields at most one sandbox, every later step of the attempt runs inside it, and Kill removes it, so an owned sandbox observed in any state but `Pending` settles the creation; Core records that before cleanup. A create that OpenSandbox rejects with a 4xx status, or that never reaches the server because the dial, name lookup or certificate check fails, settles as absent. Any other unknown outcome stays unsettled until a sandbox appears, and the one-hour timeout ends a late one.
+Every sandbox carries `io.oac.installation`, `io.oac.tenant`, `io.oac.environment` and `io.oac.allocation`, and the adapter acts only on a sandbox whose four values match. It keeps no local state. One create request yields at most one sandbox, every later step of the attempt runs inside it, and Kill removes it, so an owned sandbox observed in any state settles the creation; Core records that before cleanup. A create that never reaches the server, because the dial, name lookup or certificate check fails, settles as absent. When the server answers with its own error, its rollback has finished, so the adapter lists the allocation: an empty listing settles it as absent, and a sandbox that outlived the rollback settles it for cleanup. Any other unknown outcome stays unsettled until a sandbox appears, and the one-hour timeout ends a late one.
The checkpoint operations are not mapped. OpenSandbox's [pause](https://open-sandbox.ai/guides/pause-resume) differs by runtime: Docker freezes the container in place, Kubernetes keeps only the root filesystem, and only Fast Sandbox checkpoints memory. The preparation cache is not mapped either. [Network egress](../../../../docs/sandbox-provider.md#network-egress) describes how Core's egress maps to the network policy.
diff --git a/services/core/internal/sandbox/opensandbox/provider.go b/services/core/internal/sandbox/opensandbox/provider.go
index 3f43090a9..59a08f5b7 100644
--- a/services/core/internal/sandbox/opensandbox/provider.go
+++ b/services/core/internal/sandbox/opensandbox/provider.go
@@ -148,13 +148,12 @@ func owns(item osdk.SandboxInfo, want map[string]string) bool {
// info reports one owned sandbox. One create request yields at most one
// sandbox, every later effect of the attempt is inside it, and Kill removes it,
-// so an observed non-Pending sandbox proves the attempt can no longer leave
+// so an observed sandbox in any state proves the attempt can no longer leave
// resources behind; Core records it before Kill.
func info(r sandbox.Reference, item osdk.SandboxInfo) sandbox.Info {
- settled := item.Status.State != osdk.StatePending
return sandbox.Info{
Reference: r, ProviderID: item.ID, State: strings.ToLower(string(item.Status.State)),
- BootstrapComplete: settled && item.Metadata[keyBootstrap] == "complete", CreateSettled: settled,
+ BootstrapComplete: item.Metadata[keyBootstrap] == "complete", CreateSettled: true,
}
}
@@ -260,6 +259,10 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
return sandbox.Info{Reference: r}, sandbox.ErrExists
}
+ data, err := connection.Marshal()
+ if err != nil {
+ return rejected(sandbox.ErrInvalid)
+ }
timeout := int(lease / time.Second)
created, err := p.lifecycle.CreateSandbox(ctx, osdk.CreateSandboxRequest{
Image: &osdk.ImageSpec{URI: p.image}, Entrypoint: []string{"sleep", "infinity"}, Timeout: &timeout,
@@ -267,29 +270,18 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
Metadata: p.ownerMetadata(r), NetworkPolicy: policy,
})
if err != nil {
- switch status := statusOf(err); {
- case status == http.StatusUnauthorized || status == http.StatusForbidden:
- return rejected(sandbox.ErrCredentialRejected)
- case status >= 400 && status < 500:
- return rejected(sandbox.ErrInvalid)
- case notSent(err):
- return rejected(sandbox.ErrComputeUnconfirmed)
- }
- return sandbox.Info{Reference: r}, sandbox.ErrComputeUnconfirmed
+ return p.failedCreate(ctx, r, err, rejected)
}
- unconfirmed := sandbox.Info{Reference: r}
+ // The create returned its one sandbox, so the attempt is settled from here on.
+ got := info(r, *created)
item, err := p.lifecycle.GetSandbox(ctx, created.ID)
if err != nil {
- return unconfirmed, nativeErr(err)
+ return got, nativeErr(err)
}
- got := info(r, *item)
+ got = info(r, *item)
if item.ID != created.ID || !owns(*item, p.ownerMetadata(r)) {
return got, sandbox.ErrOwnership
}
- data, err := connection.Marshal()
- if err != nil {
- return unconfirmed, sandbox.ErrInvalid
- }
// The egress sidecar starts answering after the create returns, so its
// policy status is part of the SDK's readiness wait.
var mode string
@@ -305,26 +297,24 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
return true, nil
}})
if err != nil {
- return unconfirmed, nativeErr(err)
+ return got, nativeErr(err)
}
if mode != "dns+nft" {
return got, fmt.Errorf("%w: the OpenSandbox egress sidecar must run in dns+nft mode", sandbox.ErrInvalid)
}
if err := upload(ctx, live, bootstrapFile, data); err != nil {
- return unconfirmed, nativeErr(err)
+ return got, nativeErr(err)
}
- initialization, cancel := context.WithTimeout(ctx, 2*time.Minute)
- defer cancel()
- result, err := run(initialization, live, []string{"/usr/local/bin/oac-daemon", "connect", "--profile", "default", "--bootstrap-file", bootstrapFile, "-b"}, "/environment/workspace")
+ result, err := run(ctx, live, []string{"/usr/local/bin/oac-daemon", "connect", "--profile", "default", "--bootstrap-file", bootstrapFile, "-b"}, "/environment/workspace")
if err != nil {
- return unconfirmed, err
+ return got, err
}
if result.ExitCode != 0 {
return got, sandbox.ErrCommandUnconfirmed
}
marker := "complete"
if _, err := p.lifecycle.PatchSandboxMetadata(ctx, item.ID, osdk.MetadataPatch{keyBootstrap: &marker}); err != nil {
- return unconfirmed, nativeErr(err)
+ return got, nativeErr(err)
}
got, _, err = p.lookup(ctx, r)
if err == nil && !got.BootstrapComplete {
@@ -333,6 +323,34 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
return got, err
}
+// failedCreate classifies a create request that returned no sandbox. A request
+// that never reached the server created nothing. When the server answered with
+// its own error, its rollback has finished, so a listing shows whether anything
+// outlived it. Any other failure may hide a sandbox that appears later.
+func (p *Provider) failedCreate(ctx context.Context, r sandbox.Reference, err error, rejected func(error) (sandbox.Info, error)) (sandbox.Info, error) {
+ if notSent(err) {
+ return rejected(sandbox.ErrComputeUnconfirmed)
+ }
+ var native *osdk.APIError
+ if !errors.As(err, &native) || native.Response.Code == "" {
+ return sandbox.Info{Reference: r}, sandbox.ErrComputeUnconfirmed
+ }
+ if native.StatusCode == http.StatusUnauthorized || native.StatusCode == http.StatusForbidden {
+ return rejected(sandbox.ErrCredentialRejected)
+ }
+ outcome := sandbox.ErrComputeUnconfirmed
+ if native.StatusCode >= 400 && native.StatusCode < 500 {
+ outcome = sandbox.ErrInvalid
+ }
+ switch items, err := p.owned(ctx, r); {
+ case err != nil:
+ return sandbox.Info{Reference: r}, sandbox.ErrComputeUnconfirmed
+ case len(items) > 0:
+ return info(r, items[0]), outcome
+ }
+ return rejected(outcome)
+}
+
// upload writes a private file through execd.
func upload(ctx context.Context, live *osdk.Sandbox, path string, data []byte) error {
return live.UploadFile(ctx, bytes.NewReader(data), osdk.UploadFileOptions{FileName: filepath.Base(path), Metadata: osdk.FileMetadata{Path: path, Mode: osdk.OctalMode(0o600)}})
diff --git a/services/core/internal/sandbox/opensandbox/provider_test.go b/services/core/internal/sandbox/opensandbox/provider_test.go
index 5dd7b3349..19074de42 100644
--- a/services/core/internal/sandbox/opensandbox/provider_test.go
+++ b/services/core/internal/sandbox/opensandbox/provider_test.go
@@ -53,7 +53,9 @@ func TestProviderContract(t *testing.T) {
"pagination": map[string]any{"hasNextPage": false}})
return true
}
- writeJSON(w, 500, map[string]string{"code": "INTERNAL", "message": "injected"})
+ // A transport failure drops the connection after the request arrives.
+ conn, _, _ := w.(http.Hijacker).Hijack()
+ conn.Close()
return true
}
return contracttest.Fixture{Provider: p, Bootstrap: bootstrap(r), Calls: f.Calls, WantCalls: want}
@@ -140,6 +142,37 @@ func TestOnlyAnUnsentCreateIsAbsent(t *testing.T) {
}
}
+func TestNativeCreateErrorSettlesByListing(t *testing.T) {
+ for name, leftover := range map[string]bool{"rolled back": false, "left behind": true} {
+ f := newFake(t)
+ p, r := testProvider(t, f)
+ f.fault = func(call string, w http.ResponseWriter) bool {
+ if call != "create" {
+ return false
+ }
+ if leftover {
+ f.addLocked(p.ownerMetadata(r), nil)
+ }
+ writeJSON(w, 500, map[string]string{"code": "DOCKER::SANDBOX_IMAGE_PULL_FAILED", "message": "pull failed"})
+ return true
+ }
+ got, err := p.Create(bounded(t), bootstrap(r))
+ if want := map[bool]string{false: "absent", true: "running"}[leftover]; err == nil || !got.CreateSettled || got.State != want {
+ t.Fatalf("%s: create = %+v, %v", name, got, err)
+ }
+ }
+}
+
+func TestPendingSandboxIsSettled(t *testing.T) {
+ f := newFake(t)
+ f.state = "Pending"
+ p, r := testProvider(t, f)
+ f.add(r, p.installation, nil)
+ if got, err := p.GetInfo(bounded(t), r); err != nil || !got.CreateSettled || got.State != "pending" || got.BootstrapComplete {
+ t.Fatalf("info = %+v, %v", got, err)
+ }
+}
+
func TestVerifyCredentialNeedsListedOwnership(t *testing.T) {
f := newFake(t)
config := testConfig(f.server.URL)
From c6f9d66a59823704df87fcd5956ef6bb7d8b4cbe Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Tue, 3 Nov 2026 11:07:43 +0000
Subject: [PATCH 7/7] Trust only the OpenSandbox server's own create errors and
fix public URL copy
---
apps/web/e2e/public-url.spec.ts | 2 +-
apps/web/src/i18n/locales/en/core-errors.ts | 2 +-
.../web/src/i18n/locales/zh-CN/core-errors.ts | 2 +-
apps/web/src/lib/sandbox-labels.ts | 2 +-
contracts/agents-api/sandbox-deployment.md | 2 +-
contracts/agents-api/zh/sandbox-deployment.md | 4 ++--
docs/sandbox-provider.md | 2 +-
docs/zh/sandbox-provider.md | 4 ++--
services/core/deploy/opensandbox/README.md | 3 ++-
.../deployment/placement/placement.go | 2 +-
.../internal/sandbox/opensandbox/provider.go | 16 ++++++++------
.../sandbox/opensandbox/provider_test.go | 21 +++++++++++++++----
.../internal/sandbox/opensandbox/verify.go | 3 +--
13 files changed, 41 insertions(+), 24 deletions(-)
diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts
index 408d2aab2..2acb6af30 100644
--- a/apps/web/e2e/public-url.spec.ts
+++ b/apps/web/e2e/public-url.spec.ts
@@ -31,7 +31,7 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy
await expect(address).toContainText("Set a public address before connecting; E2B sandboxes reach Core through it.");
await page.getByRole("button", { name: "Save configuration" }).click();
const rejection = page.locator(".wizard-rejection");
- await expect(rejection).toContainText("This sandbox provider needs a public HTTPS address.");
+ await expect(rejection).toContainText("This sandbox provider needs a public address.");
await expect(rejection.getByRole("button", { name: "Managed in Settings" })).toBeVisible();
// Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review.
await expect(page.getByRole("dialog")).toHaveCount(0);
diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts
index 48d6b906a..2825f246e 100644
--- a/apps/web/src/i18n/locales/en/core-errors.ts
+++ b/apps/web/src/i18n/locales/en/core-errors.ts
@@ -37,7 +37,7 @@ export const coreErrors = {
"sandbox_credential_invalid": "The sandbox provider credential was rejected. The saved configuration is unchanged.",
"sandbox_configuration_invalid": "Select a ready immutable provider configuration with matching resources.",
"sandbox_verification_unconfirmed": "Sandbox provider verification could not be confirmed. Refresh before submitting again.",
- "sandbox_configuration_error": "This sandbox provider needs a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin.",
+ "sandbox_configuration_error": "This sandbox provider needs a public address. Set OAC_PUBLIC_URL to an origin its sandboxes reach, not loopback.",
"sandbox_deployment_conflict": "The sandbox deployment cannot change in its current state. Refresh and check its reset and resource state.",
"sandbox_specification_mismatch": "The saved sandbox specification does not match the deployment. Refresh to check the configuration.",
"sandbox_operation_unsupported": "The selected sandbox provider does not support this operation.",
diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts
index cd4120883..8e80c3a38 100644
--- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts
+++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts
@@ -36,7 +36,7 @@ export const coreErrors = {
"sandbox_credential_invalid": "沙箱提供商凭据被拒绝。已保存的配置未改变。",
"sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变提供商配置。",
"sandbox_verification_unconfirmed": "无法确认沙箱提供商的验证结果。请刷新后再提交。",
- "sandbox_configuration_error": "此沙箱提供商需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。",
+ "sandbox_configuration_error": "此沙箱提供商需要公开地址,请把 OAC_PUBLIC_URL 设为其沙箱可访问的非回环源地址。",
"sandbox_deployment_conflict": "沙箱部署在当前状态下无法更改。请刷新并检查重置和资源状态。",
"sandbox_specification_mismatch": "已保存的沙箱规格与部署不一致。请刷新检查配置。",
"sandbox_operation_unsupported": "所选沙箱提供商不支持此操作。",
diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts
index ba1e6197d..d1313c2ae 100644
--- a/apps/web/src/lib/sandbox-labels.ts
+++ b/apps/web/src/lib/sandbox-labels.ts
@@ -40,7 +40,7 @@ export function sandboxWriteUncertain(error: unknown): boolean {
/**
* The reason for a refused deployment configuration whose code the console
- * explains, such as a Provider that needs a public HTTPS address behind a loopback public_url; null for any other failure.
+ * explains, such as a Provider that needs a public address behind a loopback public_url; null for any other failure.
* Nothing was saved, so the administrator corrects the cause and saves again.
*/
export function sandboxConfigurationRejection(error: unknown, locale: SupportedLanguage = "en"): string | null {
diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md
index bbb2c4b68..be7a5b743 100644
--- a/contracts/agents-api/sandbox-deployment.md
+++ b/contracts/agents-api/sandbox-deployment.md
@@ -129,7 +129,7 @@ No provider text or credential is returned. The write and its `change` or `repla
### OpenSandbox key replacement
-The request rules are those of E2B key replacement. The candidate key must authenticate with the server of every retained generation and list the sandboxes of each unreleased allocation, so a key that lists none, such as one of another OpenSandbox tenant, is refused. A 401 or 403 returns 400 `sandbox_credential_invalid`, sandboxes of another installation 409 `sandbox_credential_ownership`, and any other failure 503 `sandbox_verification_unconfirmed`, including an allocation whose sandboxes are gone. Server URL and image changes do not drain lifecycles.
+The request rules are those of E2B key replacement. The candidate key must authenticate with the server of every retained generation and list the sandboxes of each unreleased allocation; an empty listing proves nothing. A 401 or 403 returns 400 `sandbox_credential_invalid`, and any other failure 503 `sandbox_verification_unconfirmed`, including an allocation whose sandboxes are gone. Server URL and image changes do not drain lifecycles.
## Suspension policy
diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md
index 61ecbab97..70b077bc3 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: d58c78e62c5d4ac3a3bf6d231de8fc1e26ce33d95ff276df0cc2bc428824355b
+source_hash: 9ae9f467b9e62291dcb31e3b5a8c1c64c21ef3f790ad804c85a2939119c0a218
---
沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。
@@ -132,7 +132,7 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建
### OpenSandbox 密钥替换 {#opensandbox-key-replacement}
-请求规则与 E2B 密钥替换相同。候选密钥必须能通过每个保留代次的服务器的身份验证,并列出每个未释放分配的 sandbox,因此列不出任何 sandbox 的密钥(例如另一个 OpenSandbox 租户的密钥)会被拒绝。401 或 403 返回 400 `sandbox_credential_invalid`,属于其他安装的 sandbox 返回 409 `sandbox_credential_ownership`,其他任何失败(包括某个分配的 sandbox 已不存在)返回 503 `sandbox_verification_unconfirmed`。服务地址和镜像的更改不会排空生命周期。
+请求规则与 E2B 密钥替换相同。候选密钥必须能通过每个保留代次的服务器的身份验证,并列出每个未释放分配的 sandbox;空列表不能证明任何事。401 或 403 返回 400 `sandbox_credential_invalid`,其他任何失败(包括某个分配的 sandbox 已不存在)返回 503 `sandbox_verification_unconfirmed`。服务地址和镜像的更改不会排空生命周期。
## 挂起策略 {#suspension-policy}
diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md
index 413f66635..82af55c1a 100644
--- a/docs/sandbox-provider.md
+++ b/docs/sandbox-provider.md
@@ -206,7 +206,7 @@ The Provider enforces exactly this on top of the always-allowed connection to Co
microsandbox applies the rules with first-match order and default deny, on create and restore alike, with strict hostname enforcement and DNS rebind protection off. The sandbox reaches only the host's DNS server: DNS to any other server and every other host port are refused, so only the host's answers bind names to addresses. That server answers every name whatever the policy, so a refused name still resolves and only its connections fail. A name becomes a port-less domain allow for the addresses those answers resolve, private ones included, and a `*.example.com` entry a subdomain allow that never matches `example.com`. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all IPv4 and IPv6 traffic except the Core host, `Endpoints` and `Domains`. E2B matches a host name only on ports 80 and 443, so the adapter also allows the global IPv4 addresses of the Core host and of each Endpoint on any other port. An IP literal is allowed only as a global address range of one address, and a private literal gets no rule.
-OpenSandbox also declares `Restrict` only and requires its egress sidecar in `dns+nft` mode ([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)). Its deny rules win over every allow, and a domain rule opens every port of the addresses its answers resolve. Every sandbox therefore denies the private, shared and link-local ranges, so the adapter cannot honor `PrivateDomains`. Create returns `ErrInvalid` before dispatch when the Core host, as its host resolves it, has an address outside the global ranges, because allowing that address would open all of its ports. `Public` allows everything else by default. Not `Public` denies by default and allows the Core host, `Endpoints` and `Domains`: a name as a domain rule, a global IP literal on its port. Loopback stays open because inside the sandbox it is the sandbox's own network namespace.
+OpenSandbox also declares `Restrict` only and requires its egress sidecar in `dns+nft` mode ([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)). Its deny rules win over every allow, and a domain rule opens every port of the addresses its answers resolve. Every sandbox therefore denies the private, shared and link-local ranges, so the adapter cannot honor `PrivateDomains`. Create returns `ErrInvalid` before dispatch when the Core host, as its host resolves it, has an address outside the global ranges: the private denies win over any allow for it. `Public` allows everything else by default. Not `Public` denies by default and allows the Core host, `Endpoints` and `Domains`: a name as a domain rule, a global IP literal on its port. Loopback stays open because inside the sandbox it is the sandbox's own network namespace.
## Checkpoint lifecycle
diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md
index 184328f9e..ea20645a4 100644
--- a/docs/zh/sandbox-provider.md
+++ b/docs/zh/sandbox-provider.md
@@ -1,7 +1,7 @@
---
title: "沙箱提供商协议"
source: docs/sandbox-provider.md
-source_hash: 4574b75eba13665db11b98498c90a9329dabdd4714dcf6383f777c54143f2772
+source_hash: 8d1fe4e681774523fb1544201f2ef950ac29ed2a8d055c5bf0b63855e30a24c7
---
沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox、E2B 和 OpenSandbox 实现了该协议。
@@ -208,7 +208,7 @@ Provider 在始终允许到 Core 的连接之上严格执行这些内容,并
microsandbox 按首条匹配的顺序和默认拒绝来应用规则,创建与恢复相同,并关闭严格主机名校验和 DNS rebind 保护。沙箱只能访问主机的 DNS 服务器:发往其他服务器的 DNS 和主机的其他端口都被拒绝,因此只有主机的应答能把名称绑定到地址。该服务器不论策略都会应答任何名称,因此被拒绝的名称仍能解析,只是连接会失败。名称成为不带端口的域名放行规则,放行这些应答解析出的地址(包括私有地址);`*.example.com` 条目成为子域名放行规则,永远不匹配 `example.com`。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部 IPv4 和 IPv6 流量。E2B 只在 80 和 443 端口匹配主机名,因此 adapter 还会为 Core 主机和每个 Endpoint 在其他端口上允许其全球 IPv4 地址。IP 字面量只作为单地址的全球地址范围被允许,私有字面量没有规则。
-OpenSandbox 同样只声明 `Restrict`,并要求其出口 sidecar 以 `dns+nft` 模式运行([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)),其拒绝规则优先于所有放行规则,而域名规则会放行其应答解析出的地址的所有端口。因此每个沙箱都拒绝私有、共享和链路本地网段,adapter 无法遵守 `PrivateDomains`。当 Core 主机由其主机解析出的地址不全在全局网段时,Create 在派发前返回 `ErrInvalid`,因为放行该地址会放行它的所有端口。`Public` 时其余流量默认放行。非 `Public` 时默认拒绝,并放行 Core 主机、`Endpoints` 和 `Domains`:名称作为域名规则,全局 IP 字面量在其端口上放行。回环保持开放,因为在沙箱内部它就是沙箱自己的网络命名空间。
+OpenSandbox 同样只声明 `Restrict`,并要求其出口 sidecar 以 `dns+nft` 模式运行([OpenSandbox egress](https://open-sandbox.ai/architecture/network/egress)),其拒绝规则优先于所有放行规则,而域名规则会放行其应答解析出的地址的所有端口。因此每个沙箱都拒绝私有、共享和链路本地网段,adapter 无法遵守 `PrivateDomains`。当 Core 主机由其主机解析出的地址不全在全局网段时,Create 在派发前返回 `ErrInvalid`:私有网段的拒绝规则优先于对它的任何放行。`Public` 时其余流量默认放行。非 `Public` 时默认拒绝,并放行 Core 主机、`Endpoints` 和 `Domains`:名称作为域名规则,全局 IP 字面量在其端口上放行。回环保持开放,因为在沙箱内部它就是沙箱自己的网络命名空间。
## 检查点生命周期 {#checkpoint-lifecycle}
diff --git a/services/core/deploy/opensandbox/README.md b/services/core/deploy/opensandbox/README.md
index f581f8632..95743e190 100644
--- a/services/core/deploy/opensandbox/README.md
+++ b/services/core/deploy/opensandbox/README.md
@@ -9,12 +9,13 @@ Core's OpenSandbox Sandbox Provider ([`sandbox/opensandbox`](../../internal/sand
| Version | OpenSandbox release 1.2.0 or newer |
| `server.api_key` | Set; Core sends it as `OPEN-SANDBOX-API-KEY` ([Configuration](https://open-sandbox.ai/getting-started/configuration)) |
| `[egress] mode` | `dns+nft`. Core's network policy needs packet-level enforcement ([Egress](https://open-sandbox.ai/architecture/network/egress)), and Create fails on a server that reports another mode |
+| `[egress] disable_ipv6` | `true`, the default: the adapter's deny list covers IPv4 and IPv6 private ranges, not translated IPv6 prefixes |
| `server.max_sandbox_timeout_seconds` | Unset or at least 3600: Core creates each sandbox with a one-hour lease and renews it |
| `[docker] publish_host` | `127.0.0.1` on the Docker runtime, with the server on the host network. execd answers on each sandbox's published port without authentication, and Core reaches it only through the server's proxy |
| Kubernetes | Pod and service ranges outside the private ranges added to the egress `deny.always` list ([Network isolation](https://open-sandbox.ai/guides/network-isolation)); the adapter denies only private, shared and link-local ranges |
| Network | Core reaches the server URL; sandboxes reach Core's [public address](../../../../docs/getting-started/public-address.md), which must resolve to global addresses ([network cap](../../../../contracts/agents-api/sandbox-deployment.md#network)) |
-The isolation is OpenSandbox's: its Docker runtime runs plain containers, and a [secure container runtime](https://open-sandbox.ai/guides/secure-container) gives each sandbox its own kernel. The adapter does not request [Secure Access](https://open-sandbox.ai/guides/secure-access), so restrict who can reach the server and its ingress.
+The isolation is OpenSandbox's: its Docker runtime runs plain containers, and a [secure container runtime](https://open-sandbox.ai/guides/secure-container) gives each sandbox its own kernel. The adapter does not request [Secure Access](https://open-sandbox.ai/guides/secure-access), and the server's sandbox proxy route accepts requests without the API key, so only Core may reach the server and its ingress; on Kubernetes, a NetworkPolicy also keeps other workloads off the sandbox pods. Use an HTTPS `api_url` unless Core and the server share a trusted network: the key and the Runtime credential travel over it.
## Build the Runtime image
diff --git a/services/core/internal/deployment/placement/placement.go b/services/core/internal/deployment/placement/placement.go
index 151deea6a..6ab855383 100644
--- a/services/core/internal/deployment/placement/placement.go
+++ b/services/core/internal/deployment/placement/placement.go
@@ -24,7 +24,7 @@ var (
ErrAdmissionClosed = errors.New("environment is no longer available")
// ErrPublicURLUnreachable rejects selection and admission when the provider
// requires a reachable public origin and the installation is loopback.
- ErrPublicURLUnreachable = errors.New("This sandbox provider needs a reachable HTTPS public URL before they can connect to Core.")
+ ErrPublicURLUnreachable = errors.New("This sandbox provider needs a public URL that is not loopback before its sandboxes can connect to Core.")
// ErrNodesPreparing rejects placement while no node serves the target
// generation and at least one is preparing it.
ErrNodesPreparing = errors.New("sandbox nodes are preparing the target generation")
diff --git a/services/core/internal/sandbox/opensandbox/provider.go b/services/core/internal/sandbox/opensandbox/provider.go
index 59a08f5b7..63c3bf27d 100644
--- a/services/core/internal/sandbox/opensandbox/provider.go
+++ b/services/core/internal/sandbox/opensandbox/provider.go
@@ -324,19 +324,23 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
}
// failedCreate classifies a create request that returned no sandbox. A request
-// that never reached the server created nothing. When the server answered with
-// its own error, its rollback has finished, so a listing shows whether anything
-// outlived it. Any other failure may hide a sandbox that appears later.
+// that never reached the server created nothing, and the server checks the key
+// before it handles a request. Only the server's handlers answer with a
+// namespaced code such as DOCKER::SANDBOX_IMAGE_PULL_FAILED, after their
+// rollback, so a listing then shows whether anything outlived it. Any other
+// failure, a gateway's answer included, may hide a sandbox that appears later.
func (p *Provider) failedCreate(ctx context.Context, r sandbox.Reference, err error, rejected func(error) (sandbox.Info, error)) (sandbox.Info, error) {
if notSent(err) {
return rejected(sandbox.ErrComputeUnconfirmed)
}
var native *osdk.APIError
- if !errors.As(err, &native) || native.Response.Code == "" {
+ switch {
+ case !errors.As(err, &native):
return sandbox.Info{Reference: r}, sandbox.ErrComputeUnconfirmed
- }
- if native.StatusCode == http.StatusUnauthorized || native.StatusCode == http.StatusForbidden {
+ case native.StatusCode == http.StatusUnauthorized:
return rejected(sandbox.ErrCredentialRejected)
+ case !strings.Contains(native.Response.Code, "::"):
+ return sandbox.Info{Reference: r}, sandbox.ErrComputeUnconfirmed
}
outcome := sandbox.ErrComputeUnconfirmed
if native.StatusCode >= 400 && native.StatusCode < 500 {
diff --git a/services/core/internal/sandbox/opensandbox/provider_test.go b/services/core/internal/sandbox/opensandbox/provider_test.go
index 19074de42..de4af81cb 100644
--- a/services/core/internal/sandbox/opensandbox/provider_test.go
+++ b/services/core/internal/sandbox/opensandbox/provider_test.go
@@ -143,21 +143,34 @@ func TestOnlyAnUnsentCreateIsAbsent(t *testing.T) {
}
func TestNativeCreateErrorSettlesByListing(t *testing.T) {
- for name, leftover := range map[string]bool{"rolled back": false, "left behind": true} {
+ for name, tc := range map[string]struct {
+ status int
+ code string
+ leftover bool
+ state string // "" means unsettled
+ }{
+ "rolled back": {500, "DOCKER::SANDBOX_IMAGE_PULL_FAILED", false, "absent"},
+ "left behind": {403, "KUBERNETES::QUOTA_EXCEEDED", true, "running"},
+ "gateway reply": {504, "", false, ""},
+ } {
f := newFake(t)
p, r := testProvider(t, f)
f.fault = func(call string, w http.ResponseWriter) bool {
if call != "create" {
return false
}
- if leftover {
+ if tc.leftover {
f.addLocked(p.ownerMetadata(r), nil)
}
- writeJSON(w, 500, map[string]string{"code": "DOCKER::SANDBOX_IMAGE_PULL_FAILED", "message": "pull failed"})
+ if tc.code == "" {
+ w.WriteHeader(tc.status)
+ return true
+ }
+ writeJSON(w, tc.status, map[string]string{"code": tc.code, "message": "injected"})
return true
}
got, err := p.Create(bounded(t), bootstrap(r))
- if want := map[bool]string{false: "absent", true: "running"}[leftover]; err == nil || !got.CreateSettled || got.State != want {
+ if err == nil || got.CreateSettled != (tc.state != "") || got.State != tc.state {
t.Fatalf("%s: create = %+v, %v", name, got, err)
}
}
diff --git a/services/core/internal/sandbox/opensandbox/verify.go b/services/core/internal/sandbox/opensandbox/verify.go
index 9d3159f6b..213426c06 100644
--- a/services/core/internal/sandbox/opensandbox/verify.go
+++ b/services/core/internal/sandbox/opensandbox/verify.go
@@ -44,8 +44,7 @@ func (ConfigurationAdapter) VerifyCredential(ctx context.Context, c sandbox.Dire
return sandbox.ErrConfigurationUnconfirmed
}
for _, r := range refs {
- // An empty listing never proves the key sees the reference's sandboxes:
- // a key of another OpenSandbox tenant lists nothing too.
+ // An empty listing never proves the key sees the reference's sandboxes.
switch items, err := p.owned(ctx, r); {
case errors.Is(err, sandbox.ErrOwnership):
return sandbox.ErrCredentialOwnership