Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/web/e2e/sandbox-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ test("edits the sandbox network cap for new sandboxes", async ({ page, request }
const domains = area.getByLabel("Extra allowed domains");
await expect(area.getByRole("switch", { name: "Allow public internet" })).toBeChecked();
await domains.fill("Git.example.com\n*.example.com");
await expect(area.getByText("Git.example.com must be lowercase.")).toBeVisible();
await expect(area.getByText("*.example.com is not a valid host name.")).toBeVisible();
await expect(area.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
await domains.fill("gitlab.example.com");
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/sandbox/NetworkSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ const deployment = (provider: "microsandbox" | "e2b") => ({ provider, specificat

describe("network policy", () => {
it("validates domains with the generated rule", () => {
expect(domainIssues(parseDomains("gitlab.example.com\n\n a.b-c.example "))).toEqual([]);
expect(domainIssues(["*.example.com", "-x.com", "Git.example.com", "a.com", "a.com", "a".repeat(254)]).map((issue) => issue.problem)).toEqual(["invalid", "invalid", "uppercase", "duplicate", "too_long"]);
expect(domainIssues(parseDomains("gitlab.example.com\n\n a.b-c.example \nGit.Example.com"))).toEqual([]);
expect(domainIssues(["*.example.com", "-x.com", "a.com", "a.com", "a".repeat(254)]).map((issue) => issue.problem)).toEqual(["invalid", "invalid", "duplicate", "too_long"]);
expect(domainIssues(Array.from({ length: 101 }, (_, index) => `h${index}.example.com`))).toEqual([{ problem: "too_many" }]);
});

Expand Down
1 change: 0 additions & 1 deletion apps/web/src/features/sandbox/NetworkSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export function NetworkForm({ deployment, disabled, onSave }: { deployment: Sand
switch (issue.problem) {
case "too_many": return t("Add at most {{count}} domains.", { count: maxDomains });
case "duplicate": return t("{{domain}} is listed twice.", { domain: issue.domain });
case "uppercase": return t("{{domain}} must be lowercase.", { domain: issue.domain });
case "too_long": return t("{{domain}} is too long.", { domain: issue.domain });
default: return t("{{domain}} is not a valid host name.", { domain: issue.domain });
}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/features/sandbox/network-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { deploymentContract, type SandboxProvider } from "@oac/agents-client";
const rule = deploymentContract.network;
const pattern = new RegExp(rule.domain_pattern);

export type DomainProblem = "invalid" | "uppercase" | "too_long" | "duplicate";
export type DomainProblem = "invalid" | "too_long" | "duplicate";
export type DomainIssue = { domain: string; problem: DomainProblem } | { problem: "too_many" };

/** One host name per line; blank lines are ignored. */
/** One host name per line, lowercased like the API does; blank lines are ignored. */
export function parseDomains(text: string): string[] {
return text.split("\n").map((line) => line.trim()).filter(Boolean);
return text.split("\n").map((line) => line.trim().toLowerCase()).filter(Boolean);
}

/** Checks a domain list against the generated rule while it is typed. */
Expand All @@ -20,7 +20,7 @@ export function domainIssues(domains: readonly string[]): DomainIssue[] {
if (seen.has(domain)) issues.push({ domain, problem: "duplicate" });
seen.add(domain);
if (domain.length > rule.max_domain_length) issues.push({ domain, problem: "too_long" });
else if (!pattern.test(domain)) issues.push({ domain, problem: pattern.test(domain.toLowerCase()) ? "uppercase" : "invalid" });
else if (!pattern.test(domain)) issues.push({ domain, problem: "invalid" });
}
return issues;
}
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/i18n/locales/zh-CN/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export const sandbox = {
"{{provider}} sandboxes run in the provider's cloud and cannot reach your private network.": "{{provider}} 的沙箱运行在提供商的云端,无法访问你的内网。",
"Add at most {{count}} domains.": "最多添加 {{count}} 个域名。",
"{{domain}} is listed twice.": "{{domain}} 重复了。",
"{{domain}} must be lowercase.": "{{domain}} 必须为小写。",
"{{domain}} is too long.": "{{domain}} 太长。",
"{{domain}} is not a valid host name.": "{{domain}} 不是有效的主机名。",
"New sandboxes will run with this policy": "新沙箱将按此策略运行",
Expand Down
7 changes: 7 additions & 0 deletions contracts/agents-api/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,13 @@ A hosted Session's effective `network` narrows the deployment's [network cap](./
- `disabled`: the sandbox reaches only Core and the Session's model provider. The model provider stays reachable on its `base_url` port at public addresses; a private model provider host is reachable only when the cap lists its exact name in the extra domains. Package installation and setup commands that need the network fail as ordinary setup errors.
- `restricted`: the sandbox reaches the listed domains, Core and the model provider (on the same terms as `disabled`). A domain is allowed on public addresses when the cap allows the public internet, and on its resolved addresses when it is one of the cap's extra domains. Any other domain is rejected at Session creation with `sandbox_network_outside_cap`, whose `param` is that `allowed_domains` entry. If the cap shrinks later, the sandbox attempt fails with the same error.

Limits ([adapter rules](../../docs/sandbox-provider.md#network-egress)):

- Under `disabled`, the host's DNS server stays reachable on port 53.
- A private model provider host that the cap lists is reachable on all its ports.
- Reserved ranges such as 198.18.0.0/15 and the documentation ranges are never reachable, not even through a listed name or the model provider.
- 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.

`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.

### Env and setup commands
Expand Down
9 changes: 8 additions & 1 deletion contracts/agents-api/zh/environments.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Environment 与模板"
source: contracts/agents-api/environments.md
source_hash: 41335469992a2f5262e73ea7e61d4fb0fd101cab48b844f458e4313d50c50647
source_hash: cfbfc9f651036fb7e4e528717631e1f1baa30c19115bf8f5c8cacc3ce26a4417
---

Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。
Expand Down Expand Up @@ -311,6 +311,13 @@ Core 可以用同一 Project 中较早一次准备的结果启动托管 Environm
- `disabled`:沙箱只能访问 Core 和 Session 的模型提供商。模型提供商仅在公开地址上的 `base_url` 端口可达;私有的模型提供商主机只有在上限的额外域名列出其精确名称时才可达。需要网络的包安装和设置命令按普通设置错误失败。
- `restricted`:沙箱可访问所列域名、Core 和模型提供商(条件同 `disabled`)。上限允许公网时,域名在公开地址上被允许;域名属于上限的额外域名时,在其解析出的地址上被允许。其他域名在创建 Session 时被拒绝,错误码为 `sandbox_network_outside_cap`,`param` 指向该 `allowed_domains` 条目。上限之后缩小时,沙箱尝试以同一错误失败。

限制([适配器规则](../../../docs/zh/sandbox-provider.md#network-egress)):

- `disabled` 下,主机的 DNS 服务器仍可通过 53 端口访问。
- 上限列出的私有模型提供商主机,所有端口均可达。
- 198.18.0.0/15、文档保留地址段等保留范围始终不可达,经所列域名或模型提供商也不行。
- 在 E2B 上,受限域名只能通过 80 和 443 端口访问;模型提供商使用其他端口时,其公网地址的所有端口均可达。

部署的沙箱 Provider 无法限制出站时,`disabled` 和 `restricted` 返回 `sandbox_network_unsupported`。`self_hosted` Environment 只接受 `enabled`,因为 Core 无法在不由它运行的机器上强制策略。

### Env 与设置命令 {#env-and-setup-commands}
Expand Down
2 changes: 1 addition & 1 deletion deploy/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,4 @@ The manifest's `images` records each exported image's config digest, and `image_

Docker's classic image store identifies images by config digest, and its containerd store by the OCI descriptor. The build therefore takes the digest the local store resolves from BuildKit's build metadata, never the `--iidfile` config digest alone, and disables provenance attestations so each image and archive holds one platform manifest in both stores.

The node installer confirms Linux amd64 and the returned immutable local ID, and service and provider configuration and Runtime launches use that ID. Tags never replace identity verification. The microsandbox `runtime_ref` is independent of Docker's local store identity.
The Core installer confirms Linux amd64 and the returned immutable local ID, and the Compose configuration uses that ID. Tags never replace identity verification.
2 changes: 1 addition & 1 deletion docs/sandbox-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ Core sends the same `Egress` in `Bootstrap` and in every `ResumeRequest`, derive

The Provider enforces exactly this on top of the always-allowed connection to Core, and blocks loopback, link-local, cloud-metadata and every private address that `Egress` does not name, except that E2B cannot block the metadata address 169.254.169.254. It resolves names with its host's DNS when it creates and when it resumes, and cannot follow later changes of an answer. A name that does not resolve gets no rule. MCP traffic has no entry: it follows `Public`, `PrivateDomains` and `Domains`.

microsandbox applies the rules with first-match order, default deny, and host DNS reachable; it allows the resolved private addresses without a port so that DNS rebind protection passes them. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all 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 public IPv4 addresses of the Core host and of each Endpoint on any other port.
microsandbox applies the rules with first-match order, default deny, and host DNS reachable; it allows the resolved private addresses without a port so that DNS rebind protection passes them. 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.

## Checkpoint lifecycle

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/sandbox-provider.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "沙箱提供商协议"
source: docs/sandbox-provider.md
source_hash: 45dd97e9627942748787865fc257bb2f25ee72e874d2c14a639d46f631b7028e
source_hash: 12713a187e000d21ad8a466d986d71297183347a772848104ac0d365b328fbbc
---

沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox 和 E2B 实现了该协议。
Expand Down Expand Up @@ -206,7 +206,7 @@ Core 在 `Bootstrap` 和每个 `ResumeRequest` 中发送同一个 `Egress`,它

Provider 在始终允许到 Core 的连接之上严格执行这些内容,并阻止回环、链路本地、云元数据以及 `Egress` 未列出的所有私有地址,但 E2B 无法阻止元数据地址 169.254.169.254。它在创建和恢复时用主机 DNS 解析名称,无法跟随之后应答的变化。无法解析的名称没有规则。MCP 流量没有条目:它遵循 `Public`、`PrivateDomains` 和 `Domains`。

microsandbox 按首条匹配的顺序、默认拒绝并保持主机 DNS 可达来应用规则;它不带端口地允许解析出的私有地址,使 DNS rebind 保护放行它们。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部流量。E2B 只在 80 和 443 端口匹配主机名,因此 adapter 还会为 Core 主机和每个 Endpoint 在其他端口上允许其公开 IPv4 地址。
microsandbox 按首条匹配的顺序、默认拒绝并保持主机 DNS 可达来应用规则;它不带端口地允许解析出的私有地址,使 DNS rebind 保护放行它们。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部 IPv4 和 IPv6 流量。E2B 只在 80 和 443 端口匹配主机名,因此 adapter 还会为 Core 主机和每个 Endpoint 在其他端口上允许其全球 IPv4 地址。IP 字面量只作为单地址的全球地址范围被允许,私有字面量没有规则。

## 检查点生命周期 {#checkpoint-lifecycle}

Expand Down
4 changes: 2 additions & 2 deletions services/core/cmd/server/managed_generations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ q=json.load(sys.stdin)
with (pathlib.Path(q['Config']['StateDir'])/'requests').open('a') as f: f.write(json.dumps(q)+'\n')
info=dict(q['Reference'],State='running',ProviderID='owned',CreateSettled=True)
if q['Operation']=='kill': info['State']='absent'
print(json.dumps({'Version':2,'Info':info}))
print(json.dumps({'Version':3,'Info':info}))
`
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -132,7 +132,7 @@ code = ''
if k == 'revoked': code = 'unauthorized'
elif k == 'unconfirmed': code = 'unconfirmed'
elif not (k.startswith('team-a') and template in ('owned-a', 'new-a') or k == 'team-b' and template == 'public-b'): code = 'team_mismatch'
result = {'Version': 2, 'ErrorCode': code}
result = {'Version': 3, 'ErrorCode': code}
if not code:
result['DeploymentValid'] = True
if q['Operation'] == 'validate_deployment':
Expand Down
10 changes: 5 additions & 5 deletions services/core/cmd/server/managed_setup_preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (

func TestE2BRejectedSpecificationHasSafeActionableDiagnostic(t *testing.T) {
helper := filepath.Join(t.TempDir(), "provider")
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"invalid\"}'\n"), 0700); err != nil {
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":3,\"ErrorCode\":\"invalid\"}'\n"), 0700); err != nil {
t.Fatal(err)
}
state := filepath.Join(t.TempDir(), "e2b")
Expand Down Expand Up @@ -49,12 +49,12 @@ op = q['Operation']
with (pathlib.Path(q['Config']['StateDir']) / 'operations').open('a') as log:
log.write(op + '\n')
if op == 'validate_deployment':
print(json.dumps({'Version': 2, 'ErrorCode': 'invalid'}))
print(json.dumps({'Version': 3, 'ErrorCode': 'invalid'}))
else:
info = dict(q['Reference'], ProviderID='owned-compute', State='stopped', CreateSettled=True)
if op == 'kill':
info.update(ProviderID='', State='absent')
print(json.dumps({'Version': 2, 'Info': info}))
print(json.dumps({'Version': 3, 'Info': info}))
`
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -101,7 +101,7 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) {
}
helper := filepath.Join(t.TempDir(), "provider")
requests := filepath.Join(state, "requests")
script := "#!/bin/sh\ncat >>" + requests + "\necho >>" + requests + "\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"\",\"DeploymentValid\":true,\"TemplateBuild\":{\"Status\":\"ready\",\"CPUs\":4,\"MemoryMiB\":4096,\"RootDiskMiB\":24063}}'\n"
script := "#!/bin/sh\ncat >>" + requests + "\necho >>" + requests + "\nprintf '%s' '{\"Version\":3,\"ErrorCode\":\"\",\"DeploymentValid\":true,\"TemplateBuild\":{\"Status\":\"ready\",\"CPUs\":4,\"MemoryMiB\":4096,\"RootDiskMiB\":24063}}'\n"
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -133,7 +133,7 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) {

func TestInitialE2BPublicTemplateOutsideTeamIsRejected(t *testing.T) {
helper := filepath.Join(t.TempDir(), "provider")
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"team_mismatch\"}'\n"), 0700); err != nil {
if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":3,\"ErrorCode\":\"team_mismatch\"}'\n"), 0700); err != nil {
t.Fatal(err)
}
state := filepath.Join(t.TempDir(), "e2b")
Expand Down
2 changes: 1 addition & 1 deletion services/core/deploy/e2b/helper_contract_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
MAX_REQUEST = 75497472
MAX_RESPONSE = 16777216
OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential","compute","renew_compute","suspend","resume","kill_compute","delete_snapshot","command_compute","resume_compute"]
PROTOCOL_VERSION = 2
PROTOCOL_VERSION = 3
REFERENCE_FIELDS = ["TenantID","EnvironmentID","AllocationID"]
REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Compute","Suspend","Resume","Snapshot","Deadline"]
RESPONSE_FIELDS = ["Version","Info","Command","State","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"]
Expand Down
5 changes: 3 additions & 2 deletions services/core/internal/execution/network_egress.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ func egressFor(network sandbox.Network, policy *v1.EnvironmentNetworkInput, mode
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
if port != "" {
egress.Endpoints = append(egress.Endpoints, net.JoinHostPort(host, port))
endpoint := net.JoinHostPort(host, port)
if _, _, err := sandbox.SplitEndpoint(endpoint); err == nil {
egress.Endpoints = append(egress.Endpoints, endpoint)
}
if slices.Contains(network.Domains, host) {
egress.PrivateDomains = append(egress.PrivateDomains, host)
Expand Down
1 change: 1 addition & 0 deletions services/core/internal/execution/network_egress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func TestEgressLimitsTheModelProviderToItsPort(t *testing.T) {
{"private host listed by the cap stays reachable when disabled", closed, disabled, "http://llm.corp.example:8000/v1", sandbox.Egress{PrivateDomains: []string{"llm.corp.example"}, Domains: []string{}, Endpoints: []string{"llm.corp.example:8000"}}},
{"private host the cap does not list", sandbox.DefaultNetwork(), disabled, "http://llm.corp.example:8000/v1", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{"llm.corp.example:8000"}}},
{"private IP literal", closed, disabled, "http://10.1.2.3:8000/v1", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{"10.1.2.3:8000"}}},
{"port out of range", closed, disabled, "http://api.model.example:99999", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{}}},
{"loopback", closed, disabled, "http://127.0.0.1:8000/v1", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{}}},
{"localhost", closed, disabled, "http://localhost:8000", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{}}},
} {
Expand Down
6 changes: 5 additions & 1 deletion services/core/internal/execution/runtime_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,11 @@ func (r *runtimeLifecycle) provision(ctx context.Context, tenant, environment, p
if err := r.lease.CheckOwnership(ctx); err != nil {
return owner, err
}
egress, err := r.allocationEgress(ctx, owner, spec.Network)
reserved, err := r.workspaceSpecification(ctx, owner.Key(), owner.ID)
if err != nil {
return owner, err
}
egress, err := r.allocationEgress(ctx, owner, reserved.Network)
if err != nil {
return owner, err
}
Expand Down
2 changes: 1 addition & 1 deletion services/core/internal/sandbox/e2b/helper_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
//go:generate go run ./internal/contractgen

// This adapter-private boundary is documented in tools/e2b-provider/README.md.
const ProtocolVersion = 2
const ProtocolVersion = 3
const MaxOutputBytes = 1024 * 1024
const MaxRequestBytes = 72 * 1024 * 1024
const MaxResponseBytes = 16 * 1024 * 1024
Expand Down
Loading
Loading