diff --git a/apps/web/src/features/sandbox/NetworkSettings.tsx b/apps/web/src/features/sandbox/NetworkSettings.tsx index b11dfc51b..3e233c6b0 100644 --- a/apps/web/src/features/sandbox/NetworkSettings.tsx +++ b/apps/web/src/features/sandbox/NetworkSettings.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import type { SandboxDeployment, SandboxSpecification } from "@oac/agents-client"; -import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { HelpTip, Section } from "../../components/console-ui"; import { useToast } from "../../components/Toast"; diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index f475fe255..c87fae85b 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -3,12 +3,10 @@ import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { ArrowLeft, ArrowRight, Cloud, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; -import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react"; -import { createPortal } from "react-dom"; +import { useEffect, useId, useState, type FormEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; -import { Modal } from "../../components/Modal"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; @@ -73,14 +71,12 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese * which backend (own machines, the recommended one preselected) or the * Provider's connection, how big each sandbox is (only for a Provider that * declares a default size), then a review. A Provider's setup declaration - * decides its connection fields, services, cautions and discovered options; the + * decides its connection fields, services and discovered options; the * Provider's resource declarations decide the size and disk inputs. Advanced * settings hold the complete size form. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size; another backend starts from its defaults. * An update can retain the saved credential. - * A Provider that declares cautions takes a confirmation, once per wizard - * session; a saved deployment has already made it. * Core's address is `OAC_PUBLIC_URL`: the review only shows it, and * a configuration Core rejects for it is explained here, where it was saved. */ @@ -115,9 +111,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const [replacementRequested, setReplacementRequested] = useState(false); const [checking, setChecking] = useState(false); const [busy, setBusy] = useState(false); - const [confirmed, setConfirmed] = useState(current?.provider ?? null); - const [confirming, setConfirming] = useState(null); - const goBack = useRef(null); // Core's reason for rejecting the saved configuration, such as a loopback public_url. const [rejection, setRejection] = useState(null); const [fieldRejection, setFieldRejection] = useState(null); @@ -171,15 +164,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setProvider(next); } - function proceed(next: SandboxProvider) { - setConfirming(null); + function select(next: SandboxProvider) { choose(next); setStep(deploymentContract.providers[next].mode === "direct" ? "connection" : "size"); } - function select(next: SandboxProvider) { - if (sandboxProviderSetup(next).cautions.length > 0 && confirmed !== next) setConfirming(next); - else proceed(next); - } async function save(event?: FormEvent) { event?.preventDefault(); @@ -395,24 +383,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {page} - {/* Portaled: the sliding page's transform would otherwise contain the fixed backdrop. */} - {createPortal( - setConfirming(null)} - footer={<> - - - } - > -
    - {confirming ? sandboxProviderSetup(confirming).cautions.map((caution: Parameters[0]) =>
  • {text(caution)}
  • ) : null} -
-
, - document.body, - )} ); } diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css index 4e780dc9e..a41bb0f98 100644 --- a/apps/web/src/features/sandbox/sandbox-wizard.css +++ b/apps/web/src/features/sandbox/sandbox-wizard.css @@ -350,18 +350,3 @@ .wizard-choice-heading .pill { margin: 0; } - -/* The risks a Provider declares, in its confirmation. */ -.wizard-cautions { - display: grid; - gap: 12px; - margin: 0; - padding: 0; - font-size: 13px; - line-height: 1.5; - list-style: none; -} - -.wizard-cautions li { - color: var(--ink-2); -} diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index 0d3d84492..0dc827f1f 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -343,8 +343,6 @@ export const sandbox = { "Where should sandboxes run?": "沙箱在哪里运行?", "Which sandbox backend?": "用哪个沙箱提供商?", "Recommended": "推荐", - "Use {{provider}}?": "使用 {{provider}}?", - "Use {{provider}}": "使用 {{provider}}", "{{cpus}} CPU · {{memory}}": "{{cpus}} 核 · {{memory}}", "Save configuration": "保存配置", "Change the sandbox configuration": "修改沙箱配置", diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index d8a2f2cb9..a93a3a99f 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -70,7 +70,7 @@ Select the filesystem configuration before the microsandbox deployment. Selectin | `public` | Boolean. `true` allows public addresses; `false` allows only Core, the model provider and `domains` | | `domains` | At most 100 exact host names of at most 253 characters, in lowercase letters, digits, hyphens and dots, whose last label starts with a letter. Wildcards, IP literals, ports and URLs are rejected. Core stores them lowercase, deduplicated and sorted. Every address a name resolves to is allowed, private addresses included | -The default is `{"public": true, "domains": []}`. Under every cap, loopback, link-local and cloud-metadata addresses and all other private addresses stay blocked. Addresses are resolved when a sandbox starts and when it resumes, so a name whose answer changes in between is not followed. A sandbox keeps the cap of the generation that created it. +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. Addresses are resolved when a sandbox starts and when it resumes, so a name whose answer changes in between is not followed. 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`. diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 88c4bc6b7..a41907a4d 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: 7b92833b5d4b9c66df5e93c34dfd58d6be5c8cc1223ba5922abbe31228185525 +source_hash: ad85ff2713327c6b1e171088b84829308a50d6b0bca93cae531252745e3c6b4e --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -73,7 +73,7 @@ microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/enviro | `public` | 布尔值。`true` 允许公开地址;`false` 只允许 Core、模型提供商和 `domains` | | `domains` | 最多 100 个精确主机名,每个不超过 253 个字符,由小写字母、数字、连字符和点组成,且最后一个标签以字母开头。通配符、IP 字面量、端口和 URL 会被拒绝。Core 以小写、去重并排序的形式存储。名称解析出的每个地址都被允许,包括私有地址 | -默认值为 `{"public": true, "domains": []}`。无论哪种上限,回环、链路本地、云元数据地址以及其他所有私有地址始终被阻止。地址在沙箱启动和恢复时解析,因此两次解析之间应答变化的名称不会被跟随。沙箱保留创建它的代次的上限。 +默认值为 `{"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`。 diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md index dbadf5ad9..59c8a6a36 100644 --- a/docs/getting-started/troubleshooting.md +++ b/docs/getting-started/troubleshooting.md @@ -42,7 +42,7 @@ Add node shows no command: see [Before you add a node](./nodes.md#before-you-add A microsandbox sandbox reaches a private address only for the model provider's host and for the names in the deployment's [network cap](../../contracts/agents-api/sandbox-deployment.md#network) `domains`. Any other name that resolves to a private address does not resolve in the sandbox, because DNS rebind protection discards the answer, and a Turn that depends on it fails once the Harness stops retrying. This includes an MCP server at a private address. -Add the server's exact host name to `domains` with `PUT /core/v1/sandbox/deployment`. The node resolves those names with the nameservers in the host's `/etc/resolv.conf` when a sandbox starts and when it resumes. A name that does not resolve there gets no rule, and the node logs `sandbox network names without an address` with the names. +Add the server's exact host name to the domains in **Settings** → **Sandbox and scheduling** → **Network**. The node resolves those names with the nameservers in the host's `/etc/resolv.conf` when a sandbox starts and when it resumes. A name that does not resolve there gets no rule, and the node logs `sandbox network names without an address` with the names. ### Connection failures diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 55bde09e3..673bf4fd9 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -160,7 +160,7 @@ The backend fingerprint identifies a native resource namespace, not capacity. Co ### Distribution artifacts and process paths -Each node adapter's registration owns its typed `NodeArtifacts` declaration, the shared node artifacts plus the native files its package exports: logical distribution path, release filename suffix and installation role (`node`, `runtime`, `policy` or `image`). Registration rejects missing declarations, unsafe paths and unknown roles. `go run ./services/core/cmd/provider-artifacts -write` generates the shared Web catalog and Python projection. Run the command without `-write` to check freshness. Distribution packaging, Web availability and node installation read this projection; adding a provider's payload does not add a provider-name branch to those consumers. +Each node adapter's registration owns its typed `NodeArtifacts` declaration, the shared node artifacts plus the native files its package exports: logical distribution path, release filename suffix and installation role (`node`, `runtime` or `image`). Registration rejects missing declarations, unsafe paths and unknown roles. `go run ./services/core/cmd/provider-artifacts -write` generates the shared Web catalog and Python projection. Run the command without `-write` to check freshness. Distribution packaging, Web availability and node installation read this projection; adding a provider's payload does not add a provider-name branch to those consumers. The launcher supplies `sandbox.ProcessPaths` from the [derived process environment](./configuration.md). Core reads these paths once and passes them to direct construction and setup operations. They are fixed distribution properties, not deployment settings or user-selectable helper paths. Each adapter resolves its own relative helper and state locations; E2B uses `e2b/oac-e2b-provider` and `e2b/`. Missing or nonabsolute roots fail before helper execution. Provider construction and discovery never read process environment variables. diff --git a/docs/zh/getting-started/troubleshooting.md b/docs/zh/getting-started/troubleshooting.md index 25369b161..d473a29b9 100644 --- a/docs/zh/getting-started/troubleshooting.md +++ b/docs/zh/getting-started/troubleshooting.md @@ -1,7 +1,7 @@ --- title: "故障排查" source: docs/getting-started/troubleshooting.md -source_hash: d5191995fac17c30e4d6235b3d5e7d5c91d7d95f8a1600591b5faef8ba7ae3dc +source_hash: c77f0eb6c07c2f262183d690928e03bf51442e59fb04b0acca955dd0177d3d31 --- 按症状查找安装、访问、节点和 Session 问题的原因与解决方法。本页面向 Core 安装的运维人员。 @@ -44,7 +44,7 @@ source_hash: d5191995fac17c30e4d6235b3d5e7d5c91d7d95f8a1600591b5faef8ba7ae3dc microsandbox 沙箱只能访问模型提供商的主机,以及部署[网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network) `domains` 中的名称所对应的私有地址。任何其他解析为私有地址的名称在沙箱内都无法解析,因为 DNS rebind 保护会丢弃该应答,依赖它的 Turn 会在 Harness 停止重试后失败,私有地址上的 MCP 服务器也是如此。 -用 `PUT /core/v1/sandbox/deployment` 把该服务器的精确主机名加入 `domains`。节点在沙箱启动和恢复时,用主机 `/etc/resolv.conf` 中的域名服务器解析这些名称。无法解析的名称没有规则,节点会记录 `sandbox network names without an address` 并列出这些名称。 +在 **设置** → **沙箱与调度** → **网络** 中把该服务器的精确主机名加入域名列表。节点在沙箱启动和恢复时,用主机 `/etc/resolv.conf` 中的域名服务器解析这些名称。无法解析的名称没有规则,节点会记录 `sandbox network names without an address` 并列出这些名称。 ### 连接失败 {#connection-failures} diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index c1c91f02a..1d4bcaf3e 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: 1e6284f09a11fa6ca6ba433537ed2b09135c10e473c4173fd4a6f4ca858e1f0e +source_hash: 45f9a9c4f99c0e764390d832c547ab1303cacb090fc7159d3fb0e5b5c3b25db8 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox 和 E2B 实现了该协议。 @@ -162,7 +162,7 @@ backend fingerprint 标识原生资源命名空间,不表示容量。Core 保 ### 发行产物与进程路径 {#distribution-artifacts-and-process-paths} -每个 node adapter 注册负责其类型化 `NodeArtifacts` 声明,即共享 node artifact 加上其包导出的原生文件:逻辑发行路径、release filename suffix 和安装角色(`node`、`runtime`、`policy` 或 `image`)。注册拒绝缺失声明、不安全路径和未知角色。`go run ./services/core/cmd/provider-artifacts -write` 生成共享 Web catalog 和 Python projection。不带 `-write` 运行可检查是否最新。发行打包、Web availability 和 node 安装读取此投影;添加提供商 payload 不在这些消费者中增加按提供商名称的分支。 +每个 node adapter 注册负责其类型化 `NodeArtifacts` 声明,即共享 node artifact 加上其包导出的原生文件:逻辑发行路径、release filename suffix 和安装角色(`node`、`runtime` 或 `image`)。注册拒绝缺失声明、不安全路径和未知角色。`go run ./services/core/cmd/provider-artifacts -write` 生成共享 Web catalog 和 Python projection。不带 `-write` 运行可检查是否最新。发行打包、Web availability 和 node 安装读取此投影;添加提供商 payload 不在这些消费者中增加按提供商名称的分支。 launcher 从[派生进程环境](configuration.md)提供 `sandbox.ProcessPaths`。Core 读取这些路径一次,并传给 direct construction 和 setup 操作。它们是固定发行属性,不是部署设置或用户可选 helper 路径。每个 adapter 解析自己的相对 helper 和 state 位置;E2B 使用 `e2b/oac-e2b-provider` 和 `e2b/`。root 缺失或不是绝对路径时,在执行 helper 前失败。提供商构造与发现不读取进程环境变量。 diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 70bd913e7..c74a8787f 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-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 兼容云端:无需管理机器,由服务方计费。"},"cautions":[],"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。"},"cautions":[],"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-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; diff --git a/services/core/internal/agentnetwork/policy.go b/services/core/internal/agentnetwork/policy.go index e266d23eb..6691edfa5 100644 --- a/services/core/internal/agentnetwork/policy.go +++ b/services/core/internal/agentnetwork/policy.go @@ -3,7 +3,7 @@ package agentnetwork import ( "errors" - "net/netip" + "regexp" "slices" "strings" ) @@ -37,24 +37,15 @@ func (p Policy) Validate() error { return errInvalid } +// HostPattern accepts an exact host name: dot-separated RFC 1123 labels whose +// last label starts with a letter, which also rejects IP literals. It is the one +// exact-host rule; the sandbox deployment cap shares it. +const HostPattern = `^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$` + +var hostRule = regexp.MustCompile(HostPattern) + func hostname(value string) bool { - if len(value) == 0 || len(value) > 253 { - return false - } - if _, err := netip.ParseAddr(value); err == nil { - return false - } - for _, label := range strings.Split(value, ".") { - if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return false - } - for _, c := range label { - if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '-' { - return false - } - } - } - return true + return len(value) <= 253 && hostRule.MatchString(strings.ToLower(value)) } // hosts is the case-folded, sorted allowlist. The caller must validate first. diff --git a/services/core/internal/agentnetwork/policy_test.go b/services/core/internal/agentnetwork/policy_test.go index fa6d26cf7..9db7c6c92 100644 --- a/services/core/internal/agentnetwork/policy_test.go +++ b/services/core/internal/agentnetwork/policy_test.go @@ -6,7 +6,7 @@ import ( ) func TestPolicyValidationRejectsPatternsAndAddressSyntax(t *testing.T) { - for _, host := range []string{"", "*.example.com", "example.com:443", "https://example.com", "example.com/path", "example.com?x", "example.com#x", "user@example.com", " example.com", "example.com\n", "example..com", "-example.com", "example-.com", "example_com", "127.0.0.1", "::1", "example.com.", "例子.com", strings.Repeat("x", 64) + ".com"} { + for _, host := range []string{"", "*.example.com", "example.com:443", "https://example.com", "example.com/path", "example.com?x", "example.com#x", "user@example.com", " example.com", "example.com\n", "example..com", "-example.com", "example-.com", "example_com", "127.0.0.1", "x.1", "::1", "example.com.", "例子.com", strings.Repeat("x", 64) + ".com"} { if (Policy{Access: "restricted", AllowedDomains: []string{host}}).Validate() == nil { t.Fatalf("accepted an unqualified hostname %q", host) } diff --git a/services/core/internal/api/sandbox_deployment_changes_test.go b/services/core/internal/api/sandbox_deployment_changes_test.go index ba3ddaf9f..c4a2a2079 100644 --- a/services/core/internal/api/sandbox_deployment_changes_test.go +++ b/services/core/internal/api/sandbox_deployment_changes_test.go @@ -138,3 +138,25 @@ func TestSandboxDeploymentNetworkSelection(t *testing.T) { } } } + +// A submitted network without public or domains is a 400 naming the member. +func TestSandboxDeploymentNetworkRequiresMembers(t *testing.T) { + deps, _ := sandboxFakes(t) + h := newTestHandler(t, deps) + const base = `{"provider":"e2b","expected_generation":2,"configuration":{"template":"qualified:build"}` + for _, tc := range []struct{ network, param string }{ + {`{}`, "network.public"}, + {`{"domains":[]}`, "network.public"}, + {`{"public":true}`, "network.domains"}, + } { + for _, method := range []string{"POST", "PUT"} { + r := httptest.NewRequest(method, "/core/v1/sandbox/deployment", strings.NewReader(base+`,"network":`+tc.network+`}`)) + r.Header.Set("Authorization", "Bearer administrator") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 400 || !strings.Contains(w.Body.String(), tc.param) { + t.Fatalf("%s %s: status=%d %s", method, tc.network, w.Code, w.Body.String()) + } + } + } +} diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index 8b0d2617f..3c2328415 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -30,6 +30,23 @@ type SandboxDeploymentChangeInput struct { SandboxDeploymentInput } +// requireNetworkMembers rejects a submitted network that omits public or domains, +// so a missing member never reads as false or empty. +func requireNetworkMembers(raw json.RawMessage) error { + var body struct { + Network map[string]json.RawMessage `json:"network"` + } + if json.Unmarshal(raw, &body) != nil || body.Network == nil { + return nil + } + for _, member := range []string{"public", "domains"} { + if _, ok := body.Network[member]; !ok { + return &sandbox.ValidationError{Param: "network." + member, Message: fmt.Sprintf("%s: network.%s is required", sandbox.ErrInvalid, member)} + } + } + return nil +} + // selection decodes the submitted configuration with the provider's declared codec. func (h *Handler) selection(v SandboxDeploymentInput) (sandbox.Selection, error) { c, err := h.Sandboxes.Deployment.DecodeConfiguration(v.Provider, v.Configuration, v.Credential) @@ -81,6 +98,10 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req writeDeploymentError(w, r, deployment.ErrInvalidInput) return } + if err := requireNetworkMembers(raw); err != nil { + writeDeploymentError(w, r, err) + return + } selection, err := h.selection(input) if err != nil { writeDeploymentError(w, r, err) @@ -114,6 +135,10 @@ func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request writeDeploymentError(w, r, deployment.ErrInvalidInput) return } + if err := requireNetworkMembers(raw); err != nil { + writeDeploymentError(w, r, err) + return + } selection, err := h.selection(input.SandboxDeploymentInput) if err != nil { writeDeploymentError(w, r, err) diff --git a/services/core/internal/sandbox/microsandbox/process.go b/services/core/internal/sandbox/microsandbox/process.go index fd5ac3285..c622cc2aa 100644 --- a/services/core/internal/sandbox/microsandbox/process.go +++ b/services/core/internal/sandbox/microsandbox/process.go @@ -5,8 +5,6 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "io" "os" "os/exec" @@ -14,6 +12,9 @@ import ( "strings" "sync/atomic" "syscall" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) // ProcessCaller never kills a mutating helper on a Core response timeout. @@ -69,9 +70,15 @@ func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { return Response{}, errors.New("helper result unconfirmed") } } - // The helper reports only host names that had no address on stderr. - if stderr.Len() > 0 { - log.Bg().Warn("sandbox network names without an address", "operation", q.Operation, "names", stderr.String()) + // The helper reports host names it could not resolve or support as "unresolved " and "unsupported " lines. + var names []string + for _, line := range strings.Split(stderr.String(), "\n") { + if strings.HasPrefix(line, "unresolved ") || strings.HasPrefix(line, "unsupported ") { + names = append(names, line) + } + } + if len(names) > 0 { + log.Bg().Warn("sandbox network names without an address", "operation", q.Operation, "names", strings.Join(names, "; ")) } var out Response decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) diff --git a/services/core/internal/sandbox/network.go b/services/core/internal/sandbox/network.go index 3374078b8..2c8263b18 100644 --- a/services/core/internal/sandbox/network.go +++ b/services/core/internal/sandbox/network.go @@ -5,6 +5,8 @@ import ( "regexp" "slices" "strings" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agentnetwork" ) // The deployment network rule is authored here once. The node installer and the @@ -12,9 +14,8 @@ import ( const ( maxNetworkDomains = 100 maxDomainLength = 253 - // domainPattern accepts an exact host name: dot-separated RFC 1123 labels - // whose last label starts with a letter, which also rejects IP literals. - domainPattern = `^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$` + // domainPattern is the exact-host rule shared with the Environment network field. + domainPattern = agentnetwork.HostPattern ) var domainRule = regexp.MustCompile(domainPattern) diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index 8dac6b760..3cba8feff 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -141,7 +141,7 @@ func (r *Registry) DeploymentContract() (python, typescript string, err error) { } requirements := a.Configuration.Requirements() setup := a.Setup - setup.Cautions, setup.Fields, setup.Presets = nonNil(setup.Cautions), nonNil(setup.Fields), nonNil(setup.Presets) + setup.Fields, setup.Presets = nonNil(setup.Fields), nonNil(setup.Presets) providers[kind] = sandbox.ProviderProjection{Mode: a.Mode, DeploymentPolicy: a.Policy, Setup: &sandbox.ProviderSetup{SetupDeclaration: setup, Credential: requirements.Credential, PublicOrigin: requirements.PublicOrigin, Discovery: requirements.Discovery.State == providercontract.Supported}} } diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 2ac394da2..73e86437f 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -71,8 +71,9 @@ type Bootstrap struct { // Egress is what one allocation may reach besides its Core connection. Core // derives it from the allocation's deployment generation and the Session's // frozen configuration, and sends the same value at Create and at every Resume. -// The Provider enforces exactly it and always blocks loopback, link-local -// (cloud metadata) and every private address that Egress does not name. +// The Provider enforces exactly it and blocks loopback, link-local (cloud +// metadata) and every private address that Egress does not name, except where +// it cannot: E2B cannot block the metadata address. type Egress struct { // Public allows every public address. Public bool `json:"public"` @@ -382,13 +383,11 @@ type SetupPreset struct { } // SetupDeclaration is everything the console needs to set up one Provider. -// Label is the Provider's proper noun. Cautions are risks an operator confirms -// before choosing it. Recommended marks the default among Providers of one +// Label is the Provider's proper noun. Recommended marks the default among Providers of one // deployment mode, and at most one does. type SetupDeclaration struct { Label string `json:"label"` Summary Text `json:"summary"` - Cautions []Text `json:"cautions"` Recommended bool `json:"recommended"` Fields []SetupField `json:"fields"` Presets []SetupPreset `json:"presets"` @@ -408,9 +407,6 @@ func (d SetupDeclaration) Validate(r ConfigurationRequirements) error { secret, discovered = secret || f.Secret, discovered || f.Discovered ok = ok && !(f.Secret && (f.Optional || f.Discovered)) } - for _, c := range d.Cautions { - ok = ok && c.valid() - } for _, p := range d.Presets { ok = ok && p.Label != "" for name := range p.Values { @@ -437,7 +433,7 @@ type DeploymentPolicy struct { // NetworkSupport declares the egress restrictions a Provider enforces. Every // Provider always allows its Core connection and blocks loopback and link-local -// addresses. +// addresses, except that E2B cannot block the metadata address. type NetworkSupport struct { // Restrict: can refuse public addresses (Egress.Public false) while still // allowing Core and the Egress hosts that resolve to public addresses.