Skip to content
Closed
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
2 changes: 1 addition & 1 deletion 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
4 changes: 2 additions & 2 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: 9313cff647dada07bcdf8f09f6c84a841a1115b752a6c9145221f4d31ebd6404
---

沙箱节点在其主机上运行 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
54 changes: 41 additions & 13 deletions deploy/install/node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class RuntimeDownloadError(InstallError):
NOTHING_CHANGED = " Nothing was changed."


def origin(value):
def origin(value, allow_insecure_origin=False):
try:
parsed = urlsplit(value)
parsed.port
Expand All @@ -84,7 +84,7 @@ def origin(value):
if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None
or parsed.password is not None or parsed.path not in ("", "/")
or any(c.isspace() for c in value) or any(c in value for c in "?#\\")
or (parsed.scheme == "http" and not local)):
or (parsed.scheme == "http" and not local and not allow_insecure_origin)):
raise argparse.ArgumentTypeError("Use an HTTPS origin, or loopback HTTP for a local node")
return value.rstrip("/")

Expand Down Expand Up @@ -397,6 +397,10 @@ def register_node(root, args, token, helper_archive=None):
state = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url,
"source_commit": manifest["source_commit"], "generation": args.configuration["generation"],
"specification_digest": args.configuration["specification_digest"]}
if args.allow_insecure_origin:
# Only an opted-in installation records the policy, so a default node's
# installation.json and registered.json stay byte-for-byte unchanged.
state["allow_insecure_origin"] = True
Comment on lines +400 to +403

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep insecure-origin policy in one record

When --allow-insecure-origin is enabled, this policy is persisted in installation.json and registered.json, while the node also persists and consumes it from identity.json and sudo mode keeps another copy in its root record. Those independent copies are subsequently validated on reruns and readiness checks, creating multiple sources of truth for a single connection policy; retain it only in the identity and derive the installer checks from there.

AGENTS.md reference: AGENTS.md:L46-L46

Useful? React with 👍 / 👎.

write_once(root / "installation.json", json_text(state))
node_generations.record_root_runtime(root, args, manifest, sums, sys.modules[__name__])
for name in names:
Expand Down Expand Up @@ -437,10 +441,13 @@ def register_node(root, args, token, helper_archive=None):
with os.fdopen(descriptor, "w") as secret:
secret.write(token)
install_display.step("Registering this node with Core")
register = [str(root / provider_assets.artifacts(args.provider, ("node",))[0]), "register", "--config", str(root / "provider.json"),
"--state-dir", str(root / "state/node"), "--core-url", args.core_url, "--name", socket.gethostname(),
"--enrollment-token-file", secret_path]
if args.allow_insecure_origin:
register.append("--allow-insecure-origin")
try:
checked([str(root / provider_assets.artifacts(args.provider, ("node",))[0]), "register", "--config", str(root / "provider.json"), "--state-dir", str(root / "state/node"),
"--core-url", args.core_url, "--name", socket.gethostname(),
"--enrollment-token-file", secret_path], REGISTRATION_UNCONFIRMED, explain=registration_failure)
checked(register, REGISTRATION_UNCONFIRMED, explain=registration_failure)
except AddressChanged:
discard_unregistered(root)
raise
Expand Down Expand Up @@ -953,10 +960,10 @@ def quote(value):
+ "\n\n[Install]\nWantedBy=multi-user.target\n")


def recorded_origin(value, source):
def recorded_origin(value, source, allow_insecure_origin=False):
"""A Core address read from a file, checked with the same rule as the command line."""
try:
return origin(value)
return origin(value, allow_insecure_origin)
except (argparse.ArgumentTypeError, TypeError, AttributeError):
raise InstallError(source + " holds an invalid Core address; preserve it and inspect the host." + NOTHING_CHANGED) from None

Expand All @@ -970,7 +977,9 @@ def node_record(installation_id):
if any(record.get(key) != value for key, value in expected.items()) or record.get("provider") not in DEVICE_GROUPS:
raise InstallError(str(SYSTEM_RECORDS / (installation_id + ".json")) + " is not this installer's record; preserve "
"it and inspect the host." + NOTHING_CHANGED)
recorded_origin(record.get("core_url"), str(SYSTEM_RECORDS / (installation_id + ".json")))
# Validate the recorded address under the policy the record itself carries, so
# uninstall can read an http record without the original command's flag.
recorded_origin(record.get("core_url"), str(SYSTEM_RECORDS / (installation_id + ".json")), bool(record.get("allow_insecure_origin", False)))
return record


Expand All @@ -990,6 +999,9 @@ def install_system(args, token):
if record["core_url"] != args.core_url:
raise InstallError("This host's node uses " + record["core_url"] + ", but this command uses " + args.core_url
+ ". Remove the node on the Nodes page, uninstall it, then run a new command." + NOTHING_CHANGED)
if bool(record.get("allow_insecure_origin", False)) != args.allow_insecure_origin:
raise InstallError("This host's node was installed with a different insecure-origin policy; remove the node on "
"the Nodes page, uninstall it, then run a new command." + NOTHING_CHANGED)
install_display.step("Checking host requirements")
group, details = provider_group(provider)
if record is None:
Expand All @@ -1006,9 +1018,13 @@ def install_system(args, token):
child_docker_config()
root = SERVICE_HOME / ".oac/nodes" / args.installation_id
unit = SYSTEM_UNITS / unit_name(args.installation_id)
root_file(SYSTEM_RECORDS / (args.installation_id + ".json"),
json_text({"format": 1, "installation_id": args.installation_id, "provider": provider,
"core_url": args.core_url, "node_root": str(root), "unit": str(unit)}))
record_state = {"format": 1, "installation_id": args.installation_id, "provider": provider,
"core_url": args.core_url, "node_root": str(root), "unit": str(unit)}
if args.allow_insecure_origin:
# An opted-in installation records the policy so uninstall and reruns can
# validate the address; a default record keeps its existing bytes.
record_state["allow_insecure_origin"] = True
root_file(SYSTEM_RECORDS / (args.installation_id + ".json"), json_text(record_state))
args.system, args.provider = True, provider
run_as(account, prepare_service_node, args, token, helper_archive)
root_file(unit, system_unit(root, provider))
Expand Down Expand Up @@ -1210,6 +1226,7 @@ def wait_ready(root, args, timeout=60):
identity = stored["identity"]
credential = stored["credential"]
if (len(raw) > 16384 or stored["core_url"] != args.core_url
or bool(stored.get("allow_insecure_origin", False)) != args.allow_insecure_origin
or identity["installation_id"] != args.installation_id or identity["provider"] != args.provider
or str(uuid.UUID(identity["node_id"])) != identity["node_id"]
or not re.fullmatch(r"[0-9a-f]{64}", credential)):
Expand Down Expand Up @@ -1257,9 +1274,11 @@ def read_token(args):
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group()
source.add_argument("--source-url", type=origin)
source.add_argument("--source-url")
source.add_argument("--bundle", type=Path)
parser.add_argument("--core-url", type=origin)
parser.add_argument("--core-url")
parser.add_argument("--allow-insecure-origin", action="store_true",
help="Allow a non-loopback plaintext http Core origin (development and test only)")
parser.add_argument("--provider", choices=("docker", "microsandbox"), help="Optional assertion; Core owns provider selection")
parser.add_argument("--installation-id", required=True)
parser.add_argument("--enrollment-token-stdin", action="store_true", help="Read the one-time enrollment token from standard input")
Expand All @@ -1273,6 +1292,15 @@ def main(argv=None):
args = parser.parse_args(argv)
if args.no_color:
os.environ["NO_COLOR"] = "1"
# The origin rule depends on --allow-insecure-origin, which argparse's per-value
# type cannot see, so validate both addresses after the whole command line is read.
for name in ("source_url", "core_url"):
value = getattr(args, name)
if value is not None:
try:
setattr(args, name, origin(value, args.allow_insecure_origin))
except argparse.ArgumentTypeError as error:
parser.error(str(error))
if str(uuid.UUID(args.installation_id)) != args.installation_id:
raise InstallError("Installation ID must be a canonical UUID")
if args.update:
Expand Down
54 changes: 53 additions & 1 deletion deploy/install/test_node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ def setUp(self):
self.addCleanup(temporary.cleanup)
self.home = Path(temporary.name).resolve()
self.args = argparse.Namespace(source_url="https://console.example", core_url="https://172.29.144.1:24443",
provider="docker", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe")
provider="docker", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe",
allow_insecure_origin=False)
self.root = self.home / ".oac/nodes" / self.args.installation_id
self.manifest = {"platform": "linux/amd64", "source_commit": "a" * 40, "images": {"runtime": "sha256:" + "b" * 64},
"image_manifest_digests": {"runtime": "sha256:" + "c" * 64},
Expand Down Expand Up @@ -1026,6 +1027,57 @@ def test_origin_rejects_remote_http_credentials_paths_and_redirects(self):
with self.assertRaisesRegex(installer.InstallError, "redirects"):
installer.NoRedirect().redirect_request(None, None, 302, "", {}, "https://other.example")

def test_origin_admits_non_loopback_http_only_with_the_explicit_flag(self):
self.assertEqual(installer.origin("http://private.example:8091/", True), "http://private.example:8091")
# The relaxed rule admits only the scheme; every other origin rule still holds.
for value in ("http://user:pass@private.example", "http://private.example/v1", "http://private.example?x=1",
"ftp://private.example", ""):
with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError):
installer.origin(value, True)

def test_main_gates_a_non_loopback_http_origin_on_the_flag(self):
base = ["--source-url", "http://console.example", "--core-url", "http://core.example",
"--installation-id", self.args.installation_id, "--enrollment-token-stdin"]
with mock.patch.object(installer.sys, "stdin", io.StringIO("synthetic-once-token\n")), \
mock.patch.object(installer.os, "geteuid", return_value=0), \
mock.patch.object(installer, "install_system") as install:
with self.assertRaises(SystemExit):
installer.main(base)
install.assert_not_called()
installer.main(base + ["--allow-insecure-origin"])
self.assertTrue(install.call_args.args[0].allow_insecure_origin)
self.assertEqual(install.call_args.args[0].core_url, "http://core.example")
self.assertEqual(install.call_args.args[0].source_url, "http://console.example")

def test_register_passes_the_insecure_origin_flag_only_when_enabled(self):
# Artifact downloads stay on HTTPS here: the distribution downloader's own
# HTTPS rule is a separate gate, reported rather than relaxed by this change.
self.args.allow_insecure_origin = True
self.install()
registers = [arguments for arguments, _ in self.calls if "register" in arguments]
self.assertEqual(len(registers), 1)
self.assertIn("--allow-insecure-origin", registers[0])
self.assertTrue(json.loads((self.root / "registered.json").read_text())["allow_insecure_origin"])

def test_default_install_omits_the_policy_and_the_register_flag(self):
self.install()
registers = [arguments for arguments, _ in self.calls if "register" in arguments]
self.assertEqual(len(registers), 1)
self.assertNotIn("--allow-insecure-origin", registers[0])
self.assertNotIn("allow_insecure_origin", json.loads((self.root / "installation.json").read_text()))
self.assertNotIn("allow_insecure_origin", json.loads((self.root / "registered.json").read_text()))

def test_node_record_validates_an_http_address_under_its_recorded_policy(self):
record = {"installation_id": self.args.installation_id, "provider": "docker", "core_url": "http://private.example",
"node_root": str(installer.SERVICE_HOME / ".oac/nodes" / self.args.installation_id),
"unit": str(installer.SYSTEM_UNITS / installer.unit_name(self.args.installation_id))}
# A record without the policy (an older install) keeps the strict rule.
with mock.patch.object(installer, "read_root_json", return_value=record):
with self.assertRaisesRegex(installer.InstallError, "invalid Core address"):
installer.node_record(self.args.installation_id)
with mock.patch.object(installer, "read_root_json", return_value=dict(record, allow_insecure_origin=True)):
self.assertEqual(installer.node_record(self.args.installation_id)["core_url"], "http://private.example")


class NodePrerequisiteTests(unittest.TestCase):
def test_preflight_rejects_missing_kvm_before_downloads(self):
Expand Down
Loading
Loading