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
4 changes: 2 additions & 2 deletions contracts/agents-api/node-generation-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Every frame is one JSON text message whose `version` equals `node.ProtocolVersio

## Connection

1. The node dials `/api/v1/sandbox-node/connect?node_id=<uuid>` on its stored Core origin (`wss` for `https`) with its node credential as a Bearer header. Core answers 401 to a rejected credential, which the node treats as permanent; any other failure, including a 403 from a proxy, is retried with bounded backoff. Core refuses a second connection for a node identity while one is opening, live or closing, with 409.
1. The node dials `/api/v1/sandbox-node/connect?node_id=<uuid>` on its stored Core origin (`wss` for `https`, or `ws` for a non-loopback `http` origin admitted by the retained `allow_insecure_origin` policy) with its node credential as a Bearer header. Core answers 401 to a rejected credential, which the node treats as permanent; any other failure, including a 403 from a proxy, is retried with bounded backoff. Core refuses a second connection for a node identity while one is opening, live or closing, with 409.
2. Within 15 seconds the node sends `hello` with its identity (`node_id`, `installation_id`, `provider`, `backend_fingerprint`, the enrolled `deployment_generation` and `specification_digest`, `max_active`, `max_retained`), its first health report and, when it can prepare and retain several deployment generations, `generation_management: true`. Core closes the connection unless the identity matches the authenticated node.
3. Core records the node's presence, then replies `welcome` with a new `connection_id`, the current `owner_epoch` and, for a generation-managing node, `deployment`: the target `generation`, its `specification_digest` and a nullable `serving_generation`. The node stores a higher owner epoch and refuses a lower one.
4. Every 10 seconds the node sends `heartbeat` with `connection_id`, `owner_epoch` and health. On each heartbeat Core authenticates the node credential again and checks the owner epoch, records the health and replies `heartbeat_ack`, with `deployment` for a generation-managing node. Either peer closes the connection after 35 seconds without a frame.
Expand Down Expand Up @@ -91,7 +91,7 @@ A dropped generation can never be prepared or used again. A helper's exit is evi

## Matched fresh installation

The host program release and Core's selected Runtime release are independent. A fresh node gets its executable and private preparer from the console's current release, and reads the exact Runtime source, image identities and native runtime and firmware digests from its authenticated configuration. When that Runtime is older, the console still serves its immutable `releases/<source>/` manifest, checksums and allowlisted artifacts: the Runtime helper, firmware, seccomp profile and image bytes come from the selected release, and artifact URLs stay pinned to their verified manifest even if the console's current release changes during a download.
The host program release and Core's selected Runtime release are independent. A fresh node gets its executable and private preparer from the console's current release, and reads the exact Runtime source, image identities and native runtime and firmware digests from its authenticated configuration. Artifact transfers use the policy in the node's retained identity: HTTPS, or plaintext HTTP only from the enrolled console origin when that identity recorded `allow_insecure_origin`. When that Runtime is older, the console still serves its immutable `releases/<source>/` manifest, checksums and allowlisted artifacts: the Runtime helper, firmware, seccomp profile and image bytes come from the selected release, and artifact URLs stay pinned to their verified manifest even if the console's current release changes during a download.

A missing retained release refuses the installation rather than substituting the current Runtime, and so does a local bundle that holds only a different Runtime. These refusals happen before the installer writes the node identity, imports the Runtime, registers the node or starts its service.

Expand Down
6 changes: 3 additions & 3 deletions contracts/agents-api/zh/node-generation-protocol.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "沙箱节点协议"
source: contracts/agents-api/node-generation-protocol.md
source_hash: 1ee43dfcdd0eec0806ea3bc8a4c1227e10bd8ac5e69486505cb113a98e3f548a
source_hash: b67076a5ea3b9e13c18bc444d21f645e7af6c86c8e104ae0b6653433081c4b7b
---

沙箱节点在其主机上运行 Docker 或 microsandbox Provider,并通过一个 WebSocket 与 Core 相连。Core 通过该连接发送 Provider 操作;节点针对本地 Provider 执行这些操作,并报告就绪状态、主机测量值及其持有的部署代次。Core 始终是唯一的生命周期所有者:节点绝不重试变更操作或调度工作。帧和校验器位于 [`services/core/internal/sandbox/node`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/node)(`wire.go`、`generation_wire.go`);节点用于注册和读取配置的 HTTP 路由位于[机器连接 API](machine-api.md#node-routes)。
Expand All @@ -12,7 +12,7 @@ source_hash: 1ee43dfcdd0eec0806ea3bc8a4c1227e10bd8ac5e69486505cb113a98e3f548a

## 连接 {#connection}

1. 节点在其存储的 Core 源地址上发起对 `/api/v1/sandbox-node/connect?node_id=<uuid>` 的连接(对于 `https` 使用 `wss`),并以 Bearer 请求头发送节点凭据。Core 对被拒绝的凭据返回 401,节点将其视为永久性拒绝;其他任何失败(包括代理返回的 403)都会使用有界退避进行重试。当某个节点身份已有一个连接正在建立、存活或关闭时,Core 会以 409 拒绝第二个连接。
1. 节点在其存储的 Core 源地址上发起对 `/api/v1/sandbox-node/connect?node_id=<uuid>` 的连接(对于 `https` 使用 `wss`;保留的 `allow_insecure_origin` 策略允许时,非回环 `http` 源地址使用 `ws`),并以 Bearer 请求头发送节点凭据。Core 对被拒绝的凭据返回 401,节点将其视为永久性拒绝;其他任何失败(包括代理返回的 403)都会使用有界退避进行重试。当某个节点身份已有一个连接正在建立、存活或关闭时,Core 会以 409 拒绝第二个连接。
2. 节点须在 15 秒内发送 `hello`,其中包含节点身份(`node_id`、`installation_id`、`provider`、`backend_fingerprint`、已登记的 `deployment_generation` 和 `specification_digest`、`max_active`、`max_retained`)、首次健康报告;如果节点能够准备并保留多个部署代次,还包含 `generation_management: true`。除非身份与已认证节点匹配,否则 Core 会关闭连接。
3. Core 记录节点的存在状态,然后回复 `welcome`,其中包含新的 `connection_id` 和当前 `owner_epoch`;对于支持代次管理的节点,还包含 `deployment`:目标 `generation`、其 `specification_digest` 和可空的 `serving_generation`。节点保存更高的所有者 epoch,并拒绝更低的值。
4. 节点每 10 秒发送一次 `heartbeat`,其中包含 `connection_id`、`owner_epoch` 和健康状态。对于每次心跳,Core 都会再次认证节点凭据并检查所有者 epoch,记录健康状态并回复 `heartbeat_ack`;对于支持代次管理的节点,回复中还包含 `deployment`。任一端连续 35 秒未收到任何帧时都会关闭连接。
Expand Down Expand Up @@ -93,7 +93,7 @@ Core 的丢弃授权是回收的必要条件,但并非充分条件。排队和

## 完全匹配的新安装 {#matched-fresh-installation}

主机程序版本与 Core 选择的 Runtime 版本彼此独立。全新节点从控制台当前版本获取其可执行文件和私有准备器,并从经认证的配置中读取确切的 Runtime 源、镜像身份以及原生运行时和固件摘要。当该 Runtime 较旧时,控制台仍会提供其不可变的 `releases/<source>/` 清单、校验和以及允许列表中的制品:Runtime 辅助程序、固件、seccomp 配置文件和镜像字节都来自所选版本;即使下载期间控制台的当前版本发生变化,制品 URL 仍固定到已验证的清单。
主机程序版本与 Core 选择的 Runtime 版本彼此独立。全新节点从控制台当前版本获取其可执行文件和私有准备器,并从经认证的配置中读取确切的 Runtime 源、镜像身份以及原生运行时和固件摘要。制品传输使用节点保留身份中的策略:HTTPS,或在该身份记录了 `allow_insecure_origin` 时仅从已登记的明文控制台源地址使用明文 HTTP。当该 Runtime 较旧时,控制台仍会提供其不可变的 `releases/<source>/` 清单、校验和以及允许列表中的制品:Runtime 辅助程序、固件、seccomp 配置文件和镜像字节都来自所选版本;即使下载期间控制台的当前版本发生变化,制品 URL 仍固定到已验证的清单。

所需的保留版本缺失时,安装会被拒绝,而不会替换为当前 Runtime;仅包含另一个 Runtime 的本地捆绑包也会导致拒绝。所有这些拒绝都发生在安装器写入节点身份、导入 Runtime、注册节点或启动服务之前。

Expand Down
1 change: 1 addition & 0 deletions deploy/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The distribution manifest is the one download contract for nodes: flat versioned
- A Compose installation keeps only the node metadata from its release archive; Core's image never acquires execution-only payloads.
- A node obtains bootstrap metadata from the console that generated its command. Web serves artifacts it has locally and redirects missing declared execution artifacts to the versioned HTTPS release base in the verified manifest. Web never downloads or caches those bytes.
- Only artifact requests may follow HTTPS redirects, and only without credentials or cookies. Metadata and enrollment requests stay on the configured console. The console publishes only fixed non-secret files and declared artifact names.
- A node whose retained identity recorded `allow_insecure_origin` may transfer artifacts over plaintext HTTP, but only from the console origin it enrolled with: an artifact redirect may stay on that same plaintext origin or upgrade to HTTPS, never downgrade an HTTPS transfer and never move to another plaintext host. Metadata and enrollment requests still stay on the configured console, and TLS certificate verification is never relaxed.
- Download into private temporary files, verify size and SHA-256 before an atomic rename, resume interrupted transfers, and reuse only verified cache entries or exact image identities. Never select a release other than the pinned one.
- Release downloads are anonymous. Never add repository credentials to installed node or Runtime configuration.
- Manual builds use the `build-<full SHA>` release tag and tag builds the `v*` tag. The manifest's download base must match the release tag; artifact file names and source provenance keep the full source SHA.
Expand Down
60 changes: 47 additions & 13 deletions deploy/node/distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,21 @@ def artifact(manifest, name):
return entry


def safe_url(value):
def origin_of(value):
"""Return the normalized (scheme, host, port) origin, or None without a usable host."""
parsed = urlsplit(value)
try:
port = parsed.port
except ValueError:
return None
if not parsed.hostname:
return None
if port is None:
port = {'http': 80, 'https': 443}.get(parsed.scheme)
return (parsed.scheme, parsed.hostname, port)


def safe_url(value, allow_insecure_origin=False, source_origin=None):
try:
parsed = urlsplit(value)
parsed.port
Expand All @@ -108,7 +122,13 @@ def safe_url(value):
pass
if (not parsed.hostname or parsed.username is not None or parsed.password is not None
or parsed.fragment or any(c.isspace() for c in value)
or '\\' in value or parsed.scheme != 'https' and not (parsed.scheme == 'http' and loopback)):
or '\\' in value):
raise ValueError()
# Plaintext is admitted only for loopback testing or, behind the opt-in
# switch, for the configured console origin itself; every other scheme
# and every cross-host plaintext URL keeps the original refusal.
if parsed.scheme != 'https' and not (parsed.scheme == 'http' and (
loopback or (allow_insecure_origin and source_origin is not None and origin_of(value) == source_origin))):
raise ValueError()
except ValueError:
raise ArtifactError('Artifact downloads require HTTPS; loopback HTTP is only for local testing') from None
Expand All @@ -117,9 +137,19 @@ def safe_url(value):

class ArtifactRedirect(urllib.request.HTTPRedirectHandler):
"""Only artifact bytes may follow HTTPS redirects; metadata stays on Core."""
def __init__(self, allow_insecure_origin=False, source_origin=None):
super().__init__()
self.allow_insecure_origin = allow_insecure_origin
self.source_origin = source_origin

def redirect_request(self, request, fp, code, msg, headers, newurl):
safe_url(newurl)
if urlsplit(newurl).scheme != 'https' or request.get_method() not in ('GET', 'HEAD'):
safe_url(newurl, self.allow_insecure_origin, self.source_origin)
# A plaintext hop is only a same-origin resume of an http source; an HTTPS
# transfer never downgrades, and a plaintext hop never changes host.
if (urlsplit(newurl).scheme != 'https' and not (self.allow_insecure_origin
and urlsplit(newurl).scheme == 'http' and self.source_origin is not None
and origin_of(newurl) == self.source_origin and urlsplit(request.full_url).scheme != 'https')
or request.get_method() not in ('GET', 'HEAD')):
raise ArtifactError('Artifact redirects require HTTPS')
# Carry resume headers, never credentials or cookies, to a release/CDN host.
forwarded = {name: value for name, value in request.header_items()
Expand Down Expand Up @@ -149,7 +179,7 @@ def matches(path, entry):
return path.stat().st_size == entry['size'] and digest(path) == entry['sha256']


def obtain_artifact(manifest, logical_path, destination, offline_root=None):
def obtain_artifact(manifest, logical_path, destination, offline_root=None, allow_insecure_origin=False, source_url=None):
entry = artifact(manifest, logical_path)
target = checked_path(destination)
if target.exists():
Expand All @@ -168,13 +198,14 @@ def obtain_artifact(manifest, logical_path, destination, offline_root=None):
base = manifest.get('artifact_base_url', '')
if not isinstance(base, str) or not base or urlsplit(base).query:
raise ArtifactError('No downloadable artifact source; use the matching offline bundle')
url = safe_url(base.rstrip('/') + '/' + entry['filename'])
source_origin = origin_of(source_url) if (allow_insecure_origin and source_url) else None
url = safe_url(base.rstrip('/') + '/' + entry['filename'], allow_insecure_origin, source_origin)
# A private partial file survives interruptions and reruns; the next attempt asks
# for the missing bytes only. The complete file is still verified as a whole.
partial = target.with_name('.' + target.name + '.partial')
for attempt in range(3):
try:
download_partial(url, partial, entry, logical_path)
download_partial(url, partial, entry, logical_path, allow_insecure_origin, source_origin)
break
except urllib.error.HTTPError as error:
if error.code == 416:
Expand Down Expand Up @@ -230,7 +261,7 @@ def copy_artifact(source, target, entry, logical_path):
SLOW_SECONDS, SLOW_BYTES = 60, 64 * 1024


def download_partial(url, partial, entry, logical_path):
def download_partial(url, partial, entry, logical_path, allow_insecure_origin=False, source_origin=None):
"""Complete the partial file, asking only for the bytes it is missing."""
size = entry['size']
offset = 0
Expand All @@ -254,7 +285,7 @@ def download_partial(url, partial, entry, logical_path):
if validator:
headers['If-Range'] = validator
request = urllib.request.Request(url, headers=headers)
with urllib.request.build_opener(ArtifactRedirect()).open(request, timeout=30) as stream:
with urllib.request.build_opener(ArtifactRedirect(allow_insecure_origin, source_origin)).open(request, timeout=30) as stream:
if offset and (stream.status != 206 or not stream.headers.get('Content-Range', '').startswith(f'bytes {offset}-')):
offset = 0 # The server sent the whole file; start over.
if not offset:
Expand Down Expand Up @@ -297,7 +328,7 @@ def download_partial(url, partial, entry, logical_path):
raise http.client.IncompleteRead(b'', size - count)


def runtime_archive(manifest, cache_root, offline_root=None):
def runtime_archive(manifest, cache_root, offline_root=None, allow_insecure_origin=False, source_url=None):
entry = artifact(manifest, 'images/runtime.tar.gz')
expanded = {'sha256': entry.get('unpacked_sha256'), 'size': entry.get('unpacked_size')}
if (not re.fullmatch(r'[0-9a-f]{64}', str(expanded['sha256']))
Expand All @@ -309,7 +340,8 @@ def runtime_archive(manifest, cache_root, offline_root=None):
if not matches(target, expanded):
raise ArtifactError('Cached Runtime archive differs; preserve state and inspect it')
return target
archive = obtain_artifact(manifest, 'images/runtime.tar.gz', root / 'images/runtime.tar.gz', offline_root)
archive = obtain_artifact(manifest, 'images/runtime.tar.gz', root / 'images/runtime.tar.gz', offline_root,
allow_insecure_origin, source_url)
fd, temporary = tempfile.mkstemp(prefix='.runtime-', dir=target.parent)
try:
with os.fdopen(fd, 'wb') as output, gzip.open(archive, 'rb') as stream:
Expand All @@ -330,16 +362,18 @@ def runtime_archive(manifest, cache_root, offline_root=None):
return target


def load_manifest(source_url=None, offline_root=None):
def load_manifest(source_url=None, offline_root=None, allow_insecure_origin=False):
"""Read the matched public manifest without transmitting installation credentials."""
source_origin = origin_of(source_url) if (allow_insecure_origin and source_url) else None

def read(name):
if offline_root is not None:
with (Path(offline_root) / name).open('rb') as stream:
data = stream.read(1024 * 1024 + 1)
else:
if not source_url:
raise DistributionError('A Core source URL or offline bundle is required')
url = safe_url(source_url.rstrip('/') + '/node-install/' + name)
url = safe_url(source_url.rstrip('/') + '/node-install/' + name, allow_insecure_origin, source_origin)
for attempt in range(3):
try:
with urllib.request.build_opener(NoRedirect()).open(url, timeout=30) as stream:
Expand Down
Loading
Loading