diff --git a/deploy/node/node_generations.py b/deploy/node/node_generations.py index 158eaf827..70e61fe0d 100644 --- a/deploy/node/node_generations.py +++ b/deploy/node/node_generations.py @@ -136,8 +136,8 @@ def root_runtime_files(root, value, others, installer): """Return only verified original Runtime files that no retained config uses.""" def paths(configuration): if configuration["provider"] == "docker": - return [Path(configuration["docker"]["seccomp_file"])] - return [Path(configuration["microsandbox"][key]) for key in ("helper_path", "runtime_path", "firmware_path")] + return [Path(configuration["native"]["seccomp_file"])] + return [Path(configuration["native"][key]) for key in ("helper_path", "runtime_path", "firmware_path")] names = ["runtime/seccomp.json", "images/runtime.tar.gz", "images/runtime.tar"] if value["provider"] == "microsandbox": names.extend(installer.MICRO) @@ -227,14 +227,14 @@ def validate_preparation_plan(root, plan, base, installer): if not re.fullmatch(r"[a-f0-9]{40}", source): raise installer.InstallError("Preparation release identity differs") if plan["provider"] == "docker": - policy = Path(plan["docker"]["seccomp_file"]) + policy = Path(plan["native"]["seccomp_file"]) if policy not in (root / "runtime/seccomp.json", root / "releases" / source / "runtime/seccomp.json"): raise installer.InstallError("Preparation policy is outside its immutable release") - if plan["docker"]["image"] not in (runtime["image_id"], runtime["image_manifest_digest"]): + if plan["native"]["image"] not in (runtime["image_id"], runtime["image_manifest_digest"]): raise installer.InstallError("Preparation image differs from the specification") installer.no_links(policy) else: - micro = plan["microsandbox"] + micro = plan["native"] paths = [Path(micro[key]) for key in ("helper_path", "runtime_path", "firmware_path")] if not any(paths == [release / name for name in installer.MICRO] for release in (root, root / "releases" / source)): raise installer.InstallError("Preparation artifacts are outside their immutable release") @@ -244,17 +244,17 @@ def validate_preparation_plan(root, plan, base, installer): for path in paths + [expected_home]: installer.no_links(path) configuration = dict(plan, core_url=plan["core_url"].removesuffix("/api/v1")) - installer.node_spec.verify_provider(plan, configuration, plan.get("docker", {}).get("image")) + installer.node_spec.verify_provider(plan, configuration, plan.get("native", {}).get("image")) def verify_plan_final(plan, final, installer): expected = copy.deepcopy(plan) if plan["provider"] == "docker": - image = final.get("docker", {}).get("image") + image = final.get("native", {}).get("image") runtime = plan["specification"]["runtime"] if image not in (runtime["image_id"], runtime["image_manifest_digest"]): raise installer.InstallError("Final Docker image differs from the preparation specification") - expected["docker"]["image"] = image + expected["native"]["image"] = image if expected != final: raise installer.InstallError("Final generation differs from its immutable preparation plan") @@ -274,9 +274,9 @@ def owned_root(args, installer): def generation_home(root, configuration, base, installer): runtime = configuration["specification"]["runtime"] - previous = base["microsandbox"] + previous = base["specification"]["runtime"] if (runtime["runtime_sha256"], runtime["firmware_sha256"]) == (previous["runtime_sha256"], previous["firmware_sha256"]): - return Path(previous["runtime_home"]) + return Path(base["native"]["runtime_home"]) material = ":".join((configuration["installation_id"], runtime["runtime_sha256"], runtime["firmware_sha256"])) home = Path.home() / ".oac/m" / hashlib.sha256(material.encode()).hexdigest()[:12] if len(os.fsencode(home)) > 48: @@ -287,21 +287,21 @@ def generation_home(root, configuration, base, installer): def image_available(value, installer): try: if value["provider"] == "docker": - seccomp = Path(value["docker"]["seccomp_file"]) + seccomp = Path(value["native"]["seccomp_file"]) installer.existing_file(seccomp) json.loads(seccomp.read_text()) - raw = installer.checked(list(installer.DOCKER) + ["image", "inspect", value["docker"]["image"], "--format", "{{.Id}} {{.Os}}/{{.Architecture}}"], "Cannot inspect pinned image") - return raw.strip() == value["docker"]["image"] + " linux/amd64" - micro = value["microsandbox"] + raw = installer.checked(list(installer.DOCKER) + ["image", "inspect", value["native"]["image"], "--format", "{{.Id}} {{.Os}}/{{.Architecture}}"], "Cannot inspect pinned image") + return raw.strip() == value["native"]["image"] + " linux/amd64" + micro, runtime = value["native"], value["specification"]["runtime"] for key in ("helper_path", "runtime_path", "firmware_path"): if not installer.existing_file(Path(micro[key])): return False - if (installer.file_digest(Path(micro["runtime_path"])) != micro["runtime_sha256"] - or installer.file_digest(Path(micro["firmware_path"])) != micro["firmware_sha256"]): + if (installer.file_digest(Path(micro["runtime_path"])) != runtime["runtime_sha256"] + or installer.file_digest(Path(micro["firmware_path"])) != runtime["firmware_sha256"]): return False env = dict(os.environ, MSB_BACKEND="local", MSB_HOME=micro["runtime_home"], MSB_PATH=micro["runtime_path"], MSB_LIBKRUNFW_PATH=micro["firmware_path"]) - image = json.loads(installer.checked([micro["runtime_path"], "image", "inspect", micro["image"], "--format", "json"], "Cannot inspect pinned image", env=env)) - return image.get("digest") == micro["image"].split("@", 1)[1] and image.get("architecture") == "amd64" and image.get("os") == "linux" + image = json.loads(installer.checked([micro["runtime_path"], "image", "inspect", runtime["microsandbox_ref"], "--format", "json"], "Cannot inspect pinned image", env=env)) + return image.get("digest") == runtime["microsandbox_ref"].split("@", 1)[1] and image.get("architecture") == "amd64" and image.get("os") == "linux" except (installer.InstallError, OSError, ValueError): return False @@ -312,13 +312,13 @@ def runtime_files(root, value, args, manifest, sums, installer): release = root / "releases" / source if value is not None: if args.provider == "microsandbox": - release = Path(value["microsandbox"]["helper_path"]).parents[2] - if any(Path(value["microsandbox"][key]) != release / name for key, name in zip( + release = Path(value["native"]["helper_path"]).parents[2] + if any(Path(value["native"][key]) != release / name for key, name in zip( ("helper_path", "runtime_path", "firmware_path"), installer.MICRO)): raise installer.InstallError("Retained Runtime artifact paths differ") else: - release = Path(value["docker"]["seccomp_file"]).parents[1] - if Path(value["docker"]["seccomp_file"]) != release / "runtime/seccomp.json": + release = Path(value["native"]["seccomp_file"]).parents[1] + if Path(value["native"]["seccomp_file"]) != release / "runtime/seccomp.json": raise installer.InstallError("Retained Runtime seccomp path differs") if release not in (root, root / "releases" / source): raise installer.InstallError("Retained Runtime artifacts are outside this installation") @@ -377,7 +377,7 @@ def prepare(args, installer): value = configurations.get(args.generation) finalized = target.exists() or base["generation"] == args.generation if value is not None: - installer.node_spec.verify_provider(value, args.configuration, value.get("docker", {}).get("image")) + installer.node_spec.verify_provider(value, args.configuration, value.get("native", {}).get("image")) else: for candidate in configurations.values(): # Unpublished plans must never be used as ready reuse candidates. @@ -387,8 +387,6 @@ def prepare(args, installer): value = copy.deepcopy(candidate) value["generation"] = args.generation value["specification"] = args.configuration["specification"] - if args.provider == "microsandbox": - value["microsandbox"].update(value["specification"]["resources"]) break if not finalized or value is None or not image_available(value, installer): settings = installer.private_json(root / "preparation.json") @@ -400,9 +398,9 @@ def prepare(args, installer): except installer.node_spec.SpecificationError as error: raise installer.RuntimeDownloadError("Runtime release provenance differs") from error if args.provider == "microsandbox": - args.runtime_home = Path(value["microsandbox"]["runtime_home"]) if value else generation_home(root, args.configuration, base, installer) + args.runtime_home = Path(value["native"]["runtime_home"]) if value else generation_home(root, args.configuration, base, installer) if value is None: - value = installer.provider_config(root / "releases" / runtime["source_commit"], args, manifest, runtime["image_id"]) + value = installer.provider_config(root / "releases" / runtime["source_commit"], args, runtime["image_id"]) if preparation is None: preparation = dict(marker_identity(args), import_started=False, configuration=copy.deepcopy(value)) atomic_json(directory / (str(args.generation) + ".preparing"), preparation) @@ -422,7 +420,7 @@ def prepare(args, installer): if runtime_image not in (runtime["image_id"], runtime["image_manifest_digest"]): raise installer.InstallError("Resolved Docker image is outside the authorized specification") value = copy.deepcopy(value) - value["docker"]["image"] = runtime_image + value["native"]["image"] = runtime_image if preparation and preparation.get("configuration"): verify_plan_final(preparation["configuration"], value, installer) if installer.existing_file(target): @@ -560,26 +558,26 @@ def collect(args, installer): def collect_image(args, value, others, installer): if value["provider"] == "microsandbox": - micro = value["microsandbox"] - shared = [item for item in others if item["microsandbox"]["runtime_home"] == micro["runtime_home"]] + micro, image = value["native"], value["specification"]["runtime"]["microsandbox_ref"] + shared = [item for item in others if item["native"]["runtime_home"] == micro["runtime_home"]] home = Path(micro["runtime_home"]) installer.no_links(home) if not home.is_dir() or installer.private_json(home / "oac-installation.json") != {"installation_id": args.installation_id}: raise installer.InstallError("Microsandbox store ownership differs") runtime_path = Path(micro["runtime_path"]) installer.no_links(runtime_path) - if not installer.existing_file(runtime_path) or installer.file_digest(runtime_path) != micro["runtime_sha256"]: + if not installer.existing_file(runtime_path) or installer.file_digest(runtime_path) != value["specification"]["runtime"]["runtime_sha256"]: raise installer.InstallError("Cannot verify retained microsandbox executable") env = dict(os.environ, MSB_BACKEND="local", MSB_HOME=micro["runtime_home"], MSB_PATH=micro["runtime_path"], MSB_LIBKRUNFW_PATH=micro["firmware_path"]) - if not any(item["microsandbox"]["image"] == micro["image"] for item in shared): + if not any(item["specification"]["runtime"]["microsandbox_ref"] == image for item in shared): # A failed inspect/remove is not proof of absence. A successful full # inventory must contain only understood immutable references. raw = installer.checked([micro["runtime_path"], "image", "list", "--quiet"], "Cannot verify microsandbox image inventory", env=env) references = raw.splitlines() if any(not re.fullmatch(r"[^\s@]+@sha256:[a-f0-9]{64}", item) for item in references): raise installer.InstallError("Cannot verify microsandbox image inventory") - if any(item.split("@", 1)[1] == micro["image"].split("@", 1)[1] for item in references): - installer.checked([micro["runtime_path"], "image", "remove", micro["image"], "--quiet"], "Runtime image is still in use", env=env) + if any(item.split("@", 1)[1] == image.split("@", 1)[1] for item in references): + installer.checked([micro["runtime_path"], "image", "remove", image, "--quiet"], "Runtime image is still in use", env=env) if not shared: raw = installer.checked([micro["runtime_path"], "sandbox", "list", "--format", "json"], "Cannot verify empty microsandbox store", env=env) if json.loads(raw) != []: diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py index 7944db376..d563a29cf 100644 --- a/deploy/node/node_install.py +++ b/deploy/node/node_install.py @@ -279,11 +279,11 @@ def micro_home(installation_id): return directory -def provider_config(root, args, manifest, runtime_image): +def provider_config(root, args, runtime_image): result = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url + "/api/v1", "specification": args.configuration["specification"], "generation": args.configuration["generation"]} if args.provider == "docker": - result["docker"] = {"host": "unix:///var/run/docker.sock", "image": runtime_image, + result["native"] = {"host": "unix:///var/run/docker.sock", "image": runtime_image, "network": "oac-node-" + args.installation_id, "seccomp_file": str(root / "runtime/seccomp.json"), "nested_sandbox": True} else: @@ -291,11 +291,9 @@ def provider_config(root, args, manifest, runtime_image): port = endpoint.port or (443 if endpoint.scheme == "https" else 80) addresses = sorted({entry[4][0] for entry in socket.getaddrinfo(endpoint.hostname, port, type=socket.SOCK_STREAM)}) core_rules = [{"action": "allow", "direction": "egress", "destination": address, "protocol": "tcp", "port": str(port)} for address in addresses] - result["microsandbox"] = { + result["native"] = { "helper_path": str(root / MICRO[0]), "runtime_path": str(root / MICRO[1]), "firmware_path": str(root / MICRO[2]), - "runtime_sha256": manifest["microsandbox"]["runtime_sha256"], "firmware_sha256": manifest["microsandbox"]["firmware_sha256"], - "runtime_home": str(getattr(args, "runtime_home", micro_home(args.installation_id))), "image": manifest["runtime_ref"], - **args.configuration["specification"]["resources"], + "runtime_home": str(getattr(args, "runtime_home", micro_home(args.installation_id))), "network": {"default_egress": "deny", "default_ingress": "deny", "rules": core_rules + [ {"action": "allow", "direction": "egress", "destination": "public"}, {"action": "allow", "direction": "egress", "destination": "host", "protocol": "udp", "port": "53"}, @@ -417,7 +415,7 @@ def register_node(root, args, token, helper_archive=None, *, secret_path): runtime_image = prepare_runtime(root, args, manifest) # Retain the original network policy when recovering a partial installation. if not existing_file(root / "provider.json"): - write_once(root / "provider.json", json_text(provider_config(root, args, manifest, runtime_image))) + write_once(root / "provider.json", json_text(provider_config(root, args, runtime_image))) else: node_spec.verify_provider(json.loads((root / "provider.json").read_text()), args.configuration, runtime_image) marker = root / "registered.json" @@ -1092,7 +1090,7 @@ def remove_node_files(root, installation_id): Runs as the node's own user, so a link it planted can never reach another user's files.""" no_links(root) provider = private_json(root / "provider.json") or {} - image = (provider.get("docker") or {}).get("image") + image = (provider.get("native") or {}).get("image") runtime_home = micro_home(installation_id) if root.exists(): shutil.rmtree(root) diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index 9d59d41cb..ba28d64c4 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -158,14 +158,5 @@ def verify_provider(stored, configuration, runtime_image): or stored.get("generation") != configuration["generation"] or stored.get("core_url") != configuration["core_url"] + "/api/v1"): raise SpecificationError("Retained node configuration differs from Core; preserve its state") - if provider == "docker": - if stored.get("docker", {}).get("image") != runtime_image: - raise SpecificationError("Retained Docker image differs; preserve the node and inspect its configuration") - else: - micro = stored.get("microsandbox", {}) - if any(key in micro for key in ("max_active", "max_retained", "idle_seconds", "retention_seconds")): - raise SpecificationError("Node capacity and lifecycle policy belong to Core; regenerate the stale provider file") - expected = dict(spec["resources"], image=spec["runtime"]["microsandbox_ref"], - runtime_sha256=spec["runtime"]["runtime_sha256"], firmware_sha256=spec["runtime"]["firmware_sha256"]) - if any(micro.get(key) != value for key, value in expected.items()): - raise SpecificationError("Retained microsandbox configuration differs from Core; preserve its state") + if provider == "docker" and stored.get("native", {}).get("image") != runtime_image: + raise SpecificationError("Retained Docker image differs; preserve the node and inspect its configuration") diff --git a/deploy/node/test_generation_review_regressions.py b/deploy/node/test_generation_review_regressions.py index 548e47a9e..f2f730ff1 100644 --- a/deploy/node/test_generation_review_regressions.py +++ b/deploy/node/test_generation_review_regressions.py @@ -46,7 +46,7 @@ def interrupted(path, value): with mock.patch.object(node_generations, 'atomic_json', side_effect=interrupted): with self.assertRaises(OSError): node_generations.prepare(case.args, installer) - self.assertEqual(installer.private_json(path)['docker']['image'], case.manifest['image_manifest_digests']['runtime']) + self.assertEqual(installer.private_json(path)['native']['image'], case.manifest['image_manifest_digests']['runtime']) self.assertTrue(installer.private_json(path.with_suffix('.preparing'))['import_started']) node_generations.prepare(case.args, installer) saved = path.read_bytes() @@ -217,7 +217,7 @@ def test_unpublished_plan_refuses_path_or_installation_drift(self): changed=json.loads(json.dumps(original)); changed['configuration'][field]=value node_generations.atomic_json(path,changed) with self.assertRaises(installer.InstallError): node_generations.retained_configs(case.root,installer) - changed=json.loads(json.dumps(original)); changed['configuration']['docker']['seccomp_file']=str(case.home/'foreign') + changed=json.loads(json.dumps(original)); changed['configuration']['native']['seccomp_file']=str(case.home/'foreign') node_generations.atomic_json(path,changed) with self.assertRaises(installer.InstallError): node_generations.collect(case.args,installer) self.assertFalse((case.root/'state/node/generations/2.dropped').exists()) @@ -259,7 +259,7 @@ def available(value,installer): with mock.patch.object(installer.node_spec,'fetch',return_value=cfg),mock.patch.object(node_generations,'image_available',side_effect=available): node_generations.prepare(case.args,installer) final=installer.private_json(case.root/'state/node/generations/2.json') - self.assertNotEqual(final['microsandbox']['helper_path'],original['microsandbox']['helper_path']) + self.assertNotEqual(final['native']['helper_path'],original['native']['helper_path']) self.assertIn(1,node_generations.retained_configs(case.root,installer)) def test_operator_update_refuses_before_download_or_update(self): diff --git a/deploy/node/test_node_generations.py b/deploy/node/test_node_generations.py index ca64cb5a7..c83ff6f0d 100644 --- a/deploy/node/test_node_generations.py +++ b/deploy/node/test_node_generations.py @@ -21,11 +21,11 @@ def setUp(self): self.root = Path(temporary.name) self.directory = self.root / "state/node/generations" self.directory.mkdir(parents=True, mode=0o700) - self.value = {"installation_id": "test-installation", "generation": 1, "provider": "docker", "docker": {"image": "sha256:" + "a" * 64}, + self.value = {"installation_id": "test-installation", "generation": 1, "provider": "docker", "native": {"image": "sha256:" + "a" * 64}, "specification": {"resources": {"cpus": 1, "memory_mib": 1024}, "runtime": {"source_commit": "b" * 40, "image_id": "sha256:" + "a" * 64, "image_manifest_digest": "sha256:" + "c" * 64, "microsandbox_ref": "oac-runtime@sha256:" + "d" * 64, "runtime_sha256": "e" * 64, "firmware_sha256": "f" * 64}}} self.args = SimpleNamespace(installation_id="test-installation", generation=1, specification_digest=node_spec.digest("docker", self.value["specification"])) self.release = self.root / "releases" / ("b" * 40) - self.value["docker"]["seccomp_file"] = str(self.release / "runtime/seccomp.json") + self.value["native"]["seccomp_file"] = str(self.release / "runtime/seccomp.json") self.release.mkdir(parents=True) (self.release / "artifact").write_bytes(b"immutable bytes") node_generations.atomic_json(self.root / "provider.json", self.value) @@ -166,7 +166,6 @@ def test_never_imported_generation_can_collect_missing_executable(self): def micro_fixture(self): self.value["provider"] = "microsandbox" - del self.value["docker"] home = self.root / "micro-store" home.mkdir(mode=0o700) node_generations.atomic_json(home / "oac-installation.json", {"installation_id": self.args.installation_id}) @@ -174,7 +173,8 @@ def micro_fixture(self): runtime.write_bytes(b"verified native executable") runtime.chmod(0o700) image = self.value["specification"]["runtime"]["microsandbox_ref"] - self.value["microsandbox"] = {"helper_path": str(self.release / "helper"), "runtime_home": str(home), "runtime_path": str(runtime), "firmware_path": str(self.release / "firmware"), "runtime_sha256": hashlib.sha256(runtime.read_bytes()).hexdigest(), "image": image} + self.value["specification"]["runtime"]["runtime_sha256"] = hashlib.sha256(runtime.read_bytes()).hexdigest() + self.value["native"] = {"helper_path": str(self.release / "helper"), "runtime_home": str(home), "runtime_path": str(runtime), "firmware_path": str(self.release / "firmware")} self.args.specification_digest = node_spec.digest("microsandbox", self.value["specification"]) node_generations.atomic_json(self.root / "provider.json", self.value) # This fixture changes provider before any helper exists. @@ -217,14 +217,14 @@ def test_docker_collection_preserves_another_installations_idle_serving_image(se other = dict(self.value, installation_id="second-installation") node_generations.atomic_json(second / "provider.json", other) before = (second / "provider.json").read_bytes() - host_images = {self.value["docker"]["image"]} + host_images = {self.value["native"]["image"]} def docker(command, *_args, **_kwargs): if "rm" in command or "prune" in command: host_images.clear() raise AssertionError("generation GC must not manage shared Docker images") installer.checked.side_effect = docker node_generations.collect(self.args, installer) installer.checked.assert_not_called() - self.assertEqual(host_images, {other["docker"]["image"]}) + self.assertEqual(host_images, {other["native"]["image"]}) self.assertEqual((second / "provider.json").read_bytes(), before) self.assertFalse(self.release.exists()) self.assertEqual(node_generations.retained_configs(self.root, installer), {}) diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py index b17d6fd80..203db53bf 100644 --- a/deploy/node/test_node_install.py +++ b/deploy/node/test_node_install.py @@ -315,7 +315,7 @@ def test_shared_original_firmware_survives_until_its_last_generation(self): self.args.provider = "microsandbox" self.install() successor = self.prepare_successor_runtime() - successor["microsandbox"]["firmware_path"] = str(self.root / installer.MICRO[2]) + successor["native"]["firmware_path"] = str(self.root / installer.MICRO[2]) installer.node_generations.atomic_json(self.root / "state/node/generations/2.json", successor) original = installer.private_json(self.root / "provider.json") self.args.generation = 1 @@ -377,9 +377,9 @@ def interrupted(manifest, name, path): self.assertFalse((directory / "2.json").exists()) self.assertEqual(saved["specification"], config["specification"]) self.assertFalse(json.loads((directory / "2.preparing").read_text())["import_started"]) - helper = Path(saved["microsandbox"]["helper_path"]) + helper = Path(saved["native"]["helper_path"]) inode = helper.stat().st_ino - self.assertFalse(Path(saved["microsandbox"]["runtime_path"]).exists()) + self.assertFalse(Path(saved["native"]["runtime_path"]).exists()) installer.node_generations.prepare(self.args, installer) self.assertEqual(helper.stat().st_ino, inode) self.assertEqual(json.loads((directory / "2.json").read_text()), saved) @@ -436,7 +436,7 @@ def test_docker_installs_matched_payload_registers_and_starts_persistent_service system = self.sudo_host() installer.install_system(self.args, "synthetic-once-token") config = json.loads((self.root / "provider.json").read_text()) - self.assertEqual(config["docker"]["image"], self.manifest["images"]["runtime"]) + self.assertEqual(config["native"]["image"], self.manifest["images"]["runtime"]) self.assertEqual(config["core_url"], self.args.core_url + "/api/v1") self.assertEqual(config["installation_id"], self.args.installation_id) self.assertFalse((self.root / installer.MICRO[0]).exists()) @@ -455,7 +455,7 @@ def test_containerd_node_persists_actual_id_and_warm_retry_avoids_archive(self): self.containerd = True self.install() expected = self.manifest['image_manifest_digests']['runtime'] - self.assertEqual(json.loads((self.root / 'provider.json').read_text())['docker']['image'], expected) + self.assertEqual(json.loads((self.root / 'provider.json').read_text())['native']['image'], expected) before = (self.root / 'provider.json').read_bytes() with mock.patch.object(installer.distribution, 'runtime_archive', side_effect=AssertionError('warm Runtime download')): self.install() @@ -464,7 +464,7 @@ def test_containerd_node_persists_actual_id_and_warm_retry_avoids_archive(self): def test_retained_provider_image_cannot_bypass_verified_selection(self): self.install() config = json.loads((self.root / 'provider.json').read_text()) - config['docker']['image'] = 'sha256:' + 'f' * 64 + config['native']['image'] = 'sha256:' + 'f' * 64 (self.root / 'provider.json').write_text(json.dumps(config)) self.calls.clear() with self.assertRaisesRegex(node_spec.SpecificationError, 'Retained Docker image differs'): @@ -483,14 +483,9 @@ def test_wrong_loaded_runtime_cannot_register_or_write_provider_config(self): def test_microsandbox_imports_image_and_allows_only_explicit_private_core_endpoint(self): self.args.provider = "microsandbox" self.install() - config = json.loads((self.root / "provider.json").read_text())["microsandbox"] - self.assertEqual(config["runtime_sha256"], self.manifest["microsandbox"]["runtime_sha256"]) - self.assertEqual(config["cpus"], 3) - self.assertEqual(config["memory_mib"], 6144) - self.assertEqual(config["root_disk_mib"], 10240) - self.assertEqual(config["environment_disk_mib"], 12288) - for field in ("idle_seconds", "retention_seconds", "max_active", "max_retained"): - self.assertNotIn(field, config) + config = json.loads((self.root / "provider.json").read_text())["native"] + # Resources, the image and artifact hashes are read from the specification. + self.assertEqual(set(config), {"helper_path", "runtime_path", "firmware_path", "runtime_home", "network"}) rules = config["network"]["rules"] self.assertIn({"action": "allow", "direction": "egress", "destination": "172.29.144.1", "protocol": "tcp", "port": "24443"}, rules) self.assertNotIn("private", [rule["destination"] for rule in rules]) @@ -963,7 +958,7 @@ def forge(): def test_uninstall_prints_only_an_image_id_from_the_service_home(self): root = self.home / "node" root.mkdir() - (root / "provider.json").write_text(json.dumps({"docker": {"image": "x\nFinish with: sudo sh"}})) + (root / "provider.json").write_text(json.dumps({"native": {"image": "x\nFinish with: sudo sh"}})) output = io.StringIO() with mock.patch.object(installer.sys, "stdout", output): installer.remove_node_files(root, self.args.installation_id) @@ -1083,20 +1078,6 @@ def test_offline_bundle_uses_same_bootstrap_and_verified_artifacts(self): self.install() self.assertEqual(json.loads((self.root / "provider.json").read_text())["specification"], self.args.configuration["specification"]) - def test_changed_local_micro_resources_cannot_reconnect(self): - self.args.provider = "microsandbox" - self.install() - target = self.root / "provider.json" - stored = json.loads(target.read_text()) - stored["microsandbox"]["cpus"] += 1 - target.write_text(json.dumps(stored)) - before = target.read_bytes() - self.calls.clear() - with self.assertRaisesRegex(node_spec.SpecificationError, "microsandbox configuration differs"): - self.install() - self.assertEqual(target.read_bytes(), before) - self.assertFalse(any("register" in call or "enable" in call for call, _ in self.calls)) - def test_origin_rejects_other_schemes_credentials_paths_and_redirects(self): for value in ("ftp://core.example", "https://user@core.example", "https://@core.example", "https://core.example/v1", "https://core.example?", "https://core.example#", "https://core.example\\path", "https://core.example:bad", ""): with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): diff --git a/docs/configuration.md b/docs/configuration.md index 50378039e..f98a71daf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -118,11 +118,12 @@ The named Docker volume `_data` contains these paths. Docker manages Li ## Docker node configuration -The node installer writes Docker’s provider configuration into the node’s configuration file. Deployment resources, Runtime images and capacity remain in [Core’s database](#runtime-settings-web). +The node installer writes Docker’s host settings into the `native` object of the node’s configuration file, which only the Docker adapter reads. Deployment resources, the Runtime release and capacity remain in [Core’s database](#runtime-settings-web). | Field | Installer value | Meaning | | --- | --- | --- | | `host` | `unix:///var/run/docker.sock` | Explicit Docker Engine socket | +| `image` | The Runtime image’s local ID after loading | The release’s `image_id` or `image_manifest_digest`. The host’s image store decides which digest names the loaded image, so the value is node-local; the adapter accepts only these two | | `network` | `oac-node-` | Runtime container network | | `seccomp_file` | `/runtime/seccomp.json` | Matched distribution’s seccomp profile | | `nested_sandbox` | `true` | Enables the Docker adapter’s init process and proc-mask configuration | diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 6410138f6..0aa74c4a8 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -123,9 +123,9 @@ Use manual registration when you manage the node's files and service yourself in 1. Take `oac-node` from the same release as Core. 2. Get an enrollment token: the token in a command from **Add node**, or `POST /core/v1/sandbox/enrollment-tokens` with the Core key. It is single-use and carries the node's approved capacity; the response's `expires_at` says when it expires. Save it in a `0600` file on the host. 3. Read the node configuration with the token, which does not consume it: `GET /api/v1/sandbox-node/configuration` with `Authorization: Bearer `. -4. Write a private provider file. Copy `provider`, `installation_id`, `core_url`, `generation` and `specification` from the response, and add one adapter object for the host: - - `docker`: `host` (an explicit Unix socket), `image` (the locally imported Runtime image of the approved release), `network`, `extra_hosts`, an absolute `seccomp_file` and `nested_sandbox`. - - `microsandbox`: absolute `helper_path`, `runtime_path` and `firmware_path` with their `runtime_sha256` and `firmware_sha256`, `image`, the sandbox `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib`, a `network` policy, and `runtime_home`: a private directory, which the helper creates with mode `0700` when it is missing. microsandbox places Unix sockets under it, so keep its path within 48 bytes; the installer refuses a longer one for its own nodes. +4. Write a private provider file. Copy `provider`, `installation_id`, `core_url`, `generation` and `specification` from the response, and add a `native` object with the host settings of that provider. The adapter reads sandbox size, the Runtime image and artifact hashes from `specification`: + - Docker: the [Docker node configuration](../configuration.md#docker-node-configuration) fields, with `host` an explicit Unix socket, `image` the local ID of the imported Runtime image and `seccomp_file` absolute. + - microsandbox: absolute `helper_path`, `runtime_path` and `firmware_path`, a `network` policy, and `runtime_home`: a private directory, which the helper creates with mode `0700` when it is missing. microsandbox places Unix sockets under it, so keep its path within 48 bytes; the installer refuses a longer one for its own nodes. 5. Register, then run the node under the host's service supervisor, with real absolute paths: ```sh diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 52536e9e5..a3c49dc59 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -16,7 +16,7 @@ Core owns durable Environment, allocation, placement and cleanup state; the Prov ## Steps 1. **Read the contract.** Implement every method, support the required operations and declare a decision for each of the others in [Implement the interface](#implement-the-interface). -2. **Write the adapter package** under `services/core/internal/sandbox/`: native SDK calls, ownership checks, identity translation and private configuration. Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`. An out-of-process helper lives in `services/core/tools/-provider`. +2. **Write the adapter package** under `services/core/internal/sandbox/`: native SDK calls, ownership checks, identity translation, private configuration and, for a node adapter, its node-local construction. Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`. An out-of-process helper lives in `services/core/tools/-provider`. 3. **Register the kind** once, following [Register the provider kind](#register-the-provider-kind). Registration is explicit construction, not an init-time plugin registry. 4. **Label owned resources** with the provider ownership labels in the [Runtime names](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#openagentcore-runtime-names) table, and never accept older label names as a fallback. 5. **Run the contract suite** with `make check-sandbox-provider-contract`; see [Validate the integration](#validate-the-integration). @@ -109,12 +109,13 @@ A new provider takes these steps: 1. Implement the operation contracts in the adapter package, with native contract tests. 2. Add its specification and resource validators. 3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. -4. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode and deployment policy with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads them from `deploy/node/node_spec.py`, and the TypeScript client and Web from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. -5. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. +4. For a node adapter, export from its package the `BuildLocal` constructor, the typed `native` object it decodes and the native files it adds to the shared node artifacts. Node-local settings, such as host paths, live only in that object; resources and the Runtime release are read from the node configuration's `specification`. +5. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode and deployment policy with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads them from `deploy/node/node_spec.py`, and the TypeScript client and Web from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. +6. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. **Known design gap:** Web still names providers in the setup wizard's backend choice, the Docker confirmation and E2B's configuration fields wherever Web shows or parses them (the setup step with its service presets, the deployment summary and the client's deployment projection), because the protocol declares no configuration fields yet. Exposing another provider through that surface currently requires a shared Web edit. This coupling does not meet [Complexity stays in the adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. -`providers.Build` passes persisted node configuration and ephemeral `LocalOptions` to `BuildLocal`. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. +A node configuration, `sandbox.NodeConfig`, holds `provider`, `generation`, `installation_id`, `core_url`, `specification` and the adapter's opaque `native` object. `providers.Build` validates `provider`, `generation`, `installation_id` and `specification`, and passes the configuration with ephemeral `sandbox.LocalOptions` to `BuildLocal`, which decodes `native` strictly. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. ### Registration validation @@ -133,13 +134,13 @@ 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 `providers.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. Docker and microsandbox run 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. Docker and microsandbox run 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. 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. ### Distribution artifacts and process paths -Each node adapter's registration owns its typed `NodeArtifacts` declaration: 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`, `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. 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/configuration.md b/docs/zh/configuration.md index 1c1bbea6e..8261fc738 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: d4c7fc0fddf79b32ee628cc641df148d873380ecf62c59bb6b6a323750d0a41b +source_hash: e10fcef53a7baf4f77d914bbda7f4158fcf49fc07546596ea78222939d580a6c --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -122,11 +122,12 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 ## Docker 节点配置 {#docker-node-configuration} -节点安装程序会将 Docker 的提供商配置写入节点的配置文件。部署资源、Runtime 镜像和容量仍存储在 [Core 的数据库](#runtime-settings-web)中。 +节点安装程序会将 Docker 的主机设置写入节点配置文件的 `native` 对象,只有 Docker 适配器读取它。部署资源、Runtime 发行版本和容量仍存储在 [Core 的数据库](#runtime-settings-web)中。 | 字段 | 安装程序设置的值 | 含义 | | --- | --- | --- | | `host` | `unix:///var/run/docker.sock` | 显式 Docker Engine 套接字 | +| `image` | 加载后 Runtime 镜像的本地 ID | 发行版本的 `image_id` 或 `image_manifest_digest`。主机的镜像存储决定由哪个 digest 指代已加载的镜像,因此该值属于节点本地;适配器只接受这两个值 | | `network` | `oac-node-` | Runtime 容器网络 | | `seccomp_file` | `/runtime/seccomp.json` | 所匹配发行版的 seccomp 配置文件 | | `nested_sandbox` | `true` | 启用 Docker 适配器的 init 进程和 proc-mask 配置 | diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 871fed81b..d0c39328b 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: 24b505c131f398dc8340d4f32616152a7964a783cd4fc06cfdeb3ccd422448db +source_hash: cd12954864bf9c15cf2d700fc3d9ebaf6126c0ab4297784850cfdd32b86ae656 --- 节点是一台 Linux 主机,在沙箱后端为 Docker 或 microsandbox 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 @@ -125,9 +125,9 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 1. 使用与 Core 同一发行版本的 `oac-node`。 2. 获取注册令牌:使用 **Add node** 命令中的令牌,或通过 Core 密钥调用 `POST /core/v1/sandbox/enrollment-tokens`。令牌一次性使用,包含批准的节点容量;响应的 `expires_at` 给出过期时间。在主机上存入权限为 `0600` 的文件。 3. 使用令牌读取节点配置,不会消耗令牌:`GET /api/v1/sandbox-node/configuration`,带 `Authorization: Bearer `。 -4. 写入私有提供商文件。从响应复制 `provider`、`installation_id`、`core_url`、`generation` 和 `specification`,并为主机添加一个适配器对象: - - `docker`:`host`(显式 Unix 套接字)、`image`(本地导入的批准发行版 Runtime 镜像)、`network`、`extra_hosts`、绝对路径 `seccomp_file` 和 `nested_sandbox`。 - - `microsandbox`:绝对路径 `helper_path`、`runtime_path` 和 `firmware_path`,以及对应的 `runtime_sha256` 和 `firmware_sha256`;`image`;沙箱的 `cpus`、`memory_mib`、`root_disk_mib` 和 `environment_disk_mib`;`network` 策略;以及 `runtime_home` 私有目录。目录缺失时辅助程序以 `0700` 创建。microsandbox 在其中放置 Unix 套接字,因此路径不要超过 48 字节;安装程序对自管节点拒绝更长路径。 +4. 写入私有提供商文件。从响应复制 `provider`、`installation_id`、`core_url`、`generation` 和 `specification`,并添加 `native` 对象,写入该提供商的主机设置。适配器从 `specification` 读取沙箱规格、Runtime 镜像和产物哈希: + - Docker:[Docker 节点配置](../configuration.md#docker-node-configuration)中的字段,其中 `host` 是显式 Unix 套接字,`image` 是导入的 Runtime 镜像的本地 ID,`seccomp_file` 是绝对路径。 + - microsandbox:绝对路径 `helper_path`、`runtime_path` 和 `firmware_path`;`network` 策略;以及 `runtime_home` 私有目录。目录缺失时辅助程序以 `0700` 创建。microsandbox 在其中放置 Unix 套接字,因此路径不要超过 48 字节;安装程序对自管节点拒绝更长路径。 5. 使用真实绝对路径注册,然后通过主机服务管理器运行节点: ```sh diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 0c2606f11..d273df9d0 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "添加 Sandbox Provider" source: docs/sandbox-provider.md -source_hash: 5218bf81d54117718753e28fc77a4014fe4a173d567ac9ab13bca65b511a7643 +source_hash: 305d35b08388d682347f7bce263262e65031cfe69d09cd2acf0e8dae6893ff6f --- **Sandbox Provider** 为 Core 管理的 Environment 提供 Runtime daemon 运行所需的外层计算资源,以及启动 daemon 的有界引导流程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -18,7 +18,7 @@ Core 拥有持久 Environment、allocation、placement 和 cleanup 状态;Prov ## 步骤 {#steps} 1. **阅读契约。** 实现每个方法,支持必需操作,并按[实现接口](#implement-the-interface)对其余每项操作声明决定。 -2. **编写 adapter 包**,放在 `services/core/internal/sandbox/`:包括原生 SDK 调用、所有权检查、身份转换和私有配置。声明 `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`。进程外 helper 放在 `services/core/tools/-provider`。 +2. **编写 adapter 包**,放在 `services/core/internal/sandbox/`:包括原生 SDK 调用、所有权检查、身份转换、私有配置,以及 node adapter 的 node-local 构造。声明 `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)`。进程外 helper 放在 `services/core/tools/-provider`。 3. **注册 kind** 一次,遵循[注册 provider kind](#register-the-provider-kind)。注册是明确构造,不是 init 时 plugin registry。 4. **标记所属资源**,使用 [Runtime 名称](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#openagentcore-runtime-names)表中的 provider ownership label,不接受旧 label 名称作为回退。 5. **运行契约套件** `make check-sandbox-provider-contract`;参见[验证集成](#validate-the-integration)。 @@ -111,12 +111,13 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` 1. 在 adapter 包中实现 operation 契约,并编写原生契约测试。 2. 添加 specification 和 resource validator。 3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node provider 仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 -4. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式和 deployment policy 与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取,因此 Web 读取这些声明,而不比较 provider kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 -5. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。 +4. node adapter 从自己的包中导出 `BuildLocal` constructor、它解码的类型化 `native` 对象,以及它在共享 node artifact 之外添加的原生文件。node-local 设置(如主机路径)只存放在该对象中;resources 和 Runtime release 从 node 配置的 `specification` 读取。 +5. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式和 deployment policy 与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取,因此 Web 读取这些声明,而不比较 provider kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 +6. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。 **已知设计缺口:** Web 仍在 setup 向导的后端选择、Docker 确认,以及所有显示或解析 E2B 配置字段的地方(带服务预设的 setup 步骤、部署摘要和客户端的部署投影)中指名 provider,因为协议尚未声明配置字段。通过该界面提供另一 provider 目前需要修改共享的 Web。此耦合不符合[复杂性留在 adapter 内](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter);新集成必须通过协议表达配置,把厂商专有行为留在 adapter。不得添加 Session 或 Turn 调度路径、厂商专有 column 或 API field,或 store 中的厂商 switch。 -`providers.Build` 将持久化 node 配置与临时 `LocalOptions` 传给 `BuildLocal`。调用方通过 `Standalone` 明确选择独立注册或单 provider 执行,或通过 `GenerationStateDirectory` 中的规范绝对 node state directory 选择 generation 拥有的执行。context 缺失或混合时拒绝。adapter 负责 generation 特有的原生 preparation 和 readiness 检查。Microsandbox 将 helper lease 绑定到安装实例、generation 和 specification digest,然后在平台、容量和 artifact readiness 后检查固定 image。 +node 配置 `sandbox.NodeConfig` 包含 `provider`、`generation`、`installation_id`、`core_url`、`specification`,以及 adapter 的不透明 `native` 对象。`providers.Build` 验证 `provider`、`generation`、`installation_id` 和 `specification`,并将配置与临时 `sandbox.LocalOptions` 传给 `BuildLocal`,由它严格解码 `native`。调用方通过 `Standalone` 明确选择独立注册或单 provider 执行,或通过 `GenerationStateDirectory` 中的规范绝对 node state directory 选择 generation 拥有的执行。context 缺失或混合时拒绝。adapter 负责 generation 特有的原生 preparation 和 readiness 检查。Microsandbox 将 helper lease 绑定到安装实例、generation 和 specification digest,然后在平台、容量和 artifact readiness 后检查固定 image。 ### 注册验证 {#registration-validation} @@ -135,13 +136,13 @@ configuration adapter 必须非 nil,包括其具体值。每个 `Configuration 具有凭据的 direct adapter 在替换 key 前验证全部保留 generation 与 allocation reference。公共 `sandbox.CallFence` 排除原生调用并等待 helper 完成,包括调用方已超时的调用;execution 调用已准备的 verification 和 fencing callback,不按厂商分支。 -厂商部署验证和 SDK setup 留在构造边界,构造不创建 Environment。node-local adapter 的 `providers.Built` 返回 provider、probe、installation identity、backend fingerprint 和 specification digest;helper 可能比调用方存活更久时,还返回 `Quiescent` 检查,generation 回收会等待它。factory 还返回 close 函数。`execution.RuntimeProvider` 将 adapter 绑定到 kind、installation ID、backend fingerprint、generation、mode 和 node ownership;选择由数据库负责,内存副本不构成另一权限来源。Docker 与 microsandbox 在 node 上运行,E2B 直接构造。node proxy 仅对注册声明支持 checkpoint 的 backend 暴露 checkpoint 操作,公共 lifecycle 通过 checkpoint 声明准入 suspension,不通过 provider name。 +厂商部署验证和 SDK setup 留在构造边界,构造不创建 Environment。node-local adapter 的 `sandbox.Built` 返回 provider、probe、installation identity、backend fingerprint 和 specification digest;helper 可能比调用方存活更久时,还返回 `Quiescent` 检查,generation 回收会等待它。factory 还返回 close 函数。`execution.RuntimeProvider` 将 adapter 绑定到 kind、installation ID、backend fingerprint、generation、mode 和 node ownership;选择由数据库负责,内存副本不构成另一权限来源。Docker 与 microsandbox 在 node 上运行,E2B 直接构造。node proxy 仅对注册声明支持 checkpoint 的 backend 暴露 checkpoint 操作,公共 lifecycle 通过 checkpoint 声明准入 suspension,不通过 provider name。 backend fingerprint 标识原生资源命名空间,不表示容量。Core 保留部署 generation,使所属 allocation 继续解析到原 backend;不要将保留 allocation 重新指向替代 backend。 ### 发行产物与进程路径 {#distribution-artifacts-and-process-paths} -每个 node adapter 注册负责其类型化 `NodeArtifacts` 声明:逻辑发行路径、release filename suffix 和安装角色(`node`、`runtime`、`policy` 或 `image`)。注册拒绝缺失声明、不安全路径和未知角色。`go run ./services/core/cmd/provider-artifacts -write` 生成共享 Web catalog 和 Python projection。不带 `-write` 运行可检查是否最新。发行打包、Web availability 和 node 安装读取此投影;添加 provider payload 不在这些消费者中增加 provider-name 分支。 +每个 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 安装读取此投影;添加 provider payload 不在这些消费者中增加 provider-name 分支。 launcher 从[派生进程环境](configuration.md)提供 `sandbox.ProcessPaths`。Core 读取这些路径一次,并传给 direct construction 和 setup 操作。它们是固定发行属性,不是部署设置或用户可选 helper 路径。每个 adapter 解析自己的相对 helper 和 state 位置;E2B 使用 `e2b/oac-e2b-provider` 和 `e2b/`。root 缺失或不是绝对路径时,在执行 helper 前失败。Provider 构造与发现不读取进程环境变量。 diff --git a/services/core/cmd/sandbox-node/generations.go b/services/core/cmd/sandbox-node/generations.go index 78f41af37..e6b88a62c 100644 --- a/services/core/cmd/sandbox-node/generations.go +++ b/services/core/cmd/sandbox-node/generations.go @@ -47,7 +47,7 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf values := map[uint64]node.GenerationProvider{} recovery := []sandbox.GenerationReference{} collection := []sandbox.GenerationReference{} - seen := map[uint64]providerconfig.Config{} + seen := map[uint64]sandbox.NodeConfig{} closeValues := func() { for _, v := range values { if v.Close != nil { @@ -56,7 +56,7 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf } } for _, path := range paths { - var config providerconfig.Config + var config sandbox.NodeConfig if strings.HasSuffix(path, ".preparing") { journal, readErr := readGenerationJournal(path) err = readErr @@ -166,8 +166,8 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: stateDir, Identity: stored.Identity, Credential: stored.Credential, Generations: manager}) } -func buildGeneration(registry *providerconfig.Registry, config providerconfig.Config, stateDir string) (node.GenerationProvider, error) { - built, closeProvider, err := registry.Build(config, providerconfig.LocalOptions{GenerationStateDirectory: stateDir}) +func buildGeneration(registry *providerconfig.Registry, config sandbox.NodeConfig, stateDir string) (node.GenerationProvider, error) { + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: stateDir}) if err != nil { return node.GenerationProvider{}, err } @@ -175,12 +175,12 @@ func buildGeneration(registry *providerconfig.Registry, config providerconfig.Co } type generationJournal struct { - InstallationID string `json:"installation_id"` - Generation uint64 `json:"generation"` - SpecificationDigest string `json:"specification_digest"` - NativeComplete json.RawMessage `json:"native_complete,omitempty"` - ImportStarted json.RawMessage `json:"import_started,omitempty"` - Configuration *providerconfig.Config `json:"configuration,omitempty"` + InstallationID string `json:"installation_id"` + Generation uint64 `json:"generation"` + SpecificationDigest string `json:"specification_digest"` + NativeComplete json.RawMessage `json:"native_complete,omitempty"` + ImportStarted json.RawMessage `json:"import_started,omitempty"` + Configuration *sandbox.NodeConfig `json:"configuration,omitempty"` } func readGenerationJournal(path string) (generationJournal, error) { @@ -211,23 +211,17 @@ func readGenerationJournal(path string) (generationJournal, error) { } // The preparation configuration is an immutable plan, never a usable provider. -// Only Docker's two specification-proven local IDs can differ at publication. -func sameGenerationPlan(final, plan providerconfig.Config) bool { - if plan.Docker != nil && final.Docker != nil { - runtime := plan.Specification.Runtime - if final.Docker.Image != runtime.ImageID && final.Docker.Image != runtime.ImageManifestDigest { - return false - } - copyDocker := *plan.Docker - copyDocker.Image = final.Docker.Image - plan.Docker = ©Docker - } +// A generation's Core-visible envelope is fixed; native is the adapter's +// node-local state, which preparation may resolve and the adapter validates +// when it builds. +func sameGenerationPlan(final, plan sandbox.NodeConfig) bool { + final.Native, plan.Native = nil, nil return reflect.DeepEqual(final, plan) } // Pending-only entries stay recovery/collection-only. No journal is readiness // or authority to collect without a fresh current-connection Core grant. -func generationLocalState(config providerconfig.Config, stateDir string) (string, error) { +func generationLocalState(config sandbox.NodeConfig, stateDir string) (string, error) { directory := filepath.Join(stateDir, "generations") info, err := os.Lstat(directory) if os.IsNotExist(err) { diff --git a/services/core/cmd/sandbox-node/generations_test.go b/services/core/cmd/sandbox-node/generations_test.go index 56a015270..ae08f7ad0 100644 --- a/services/core/cmd/sandbox-node/generations_test.go +++ b/services/core/cmd/sandbox-node/generations_test.go @@ -7,15 +7,15 @@ import ( "os" "os/exec" "path/filepath" + "strings" "syscall" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - providerconfig "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" ) func TestGenerationJournalRestartIdentity(t *testing.T) { - config := providerconfig.Config{InstallationID: "installation", Generation: 9, Provider: "docker"} + config := sandbox.NodeConfig{InstallationID: "installation", Generation: 9, Provider: "docker"} stateDir := t.TempDir() directory := filepath.Join(stateDir, "generations") if err := os.Mkdir(directory, 0700); err != nil { @@ -118,7 +118,7 @@ func TestGenerationHelperUsesOnlyTypedExit(t *testing.T) { } func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { - config := providerconfig.Config{InstallationID: "installation", Generation: 2, Provider: "docker"} + config := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "docker"} stateDir := t.TempDir() directory := filepath.Join(stateDir, "generations") if err := os.Mkdir(directory, 0700); err != nil { @@ -144,6 +144,22 @@ func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { } } +// Preparation may resolve native state, such as the digest a containerd image +// store names the loaded Runtime image by; the envelope stays fixed. +func TestGenerationPlanFixesOnlyTheEnvelope(t *testing.T) { + release := sandbox.RuntimeRelease{ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64)} + plan := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "docker", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &release}, Native: json.RawMessage(`{"image":"` + release.ImageID + `"}`)} + final := plan + final.Native = json.RawMessage(`{"image":"` + release.ImageManifestDigest + `"}`) + if !sameGenerationPlan(final, plan) { + t.Fatal("refused a resolved native image") + } + final.Specification.Resources.CPUs = 4 + if sameGenerationPlan(final, plan) { + t.Fatal("accepted a different specification") + } +} + func TestGenerationStateDirectoryOwnership(t *testing.T) { for _, mode := range []string{"private", "public", "symlink", "symlink_parent"} { t.Run(mode, func(t *testing.T) { @@ -170,7 +186,7 @@ func TestGenerationStateDirectoryOwnership(t *testing.T) { } stateDir = link } - _, err := generationLocalState(providerconfig.Config{Generation: 1}, stateDir) + _, err := generationLocalState(sandbox.NodeConfig{Generation: 1}, stateDir) if mode == "private" && err != nil || mode != "private" && !errors.Is(err, sandbox.ErrOwnership) { t.Fatalf("directory ownership: %v", err) } diff --git a/services/core/cmd/sandbox-node/main.go b/services/core/cmd/sandbox-node/main.go index 11657f156..1cef71944 100644 --- a/services/core/cmd/sandbox-node/main.go +++ b/services/core/cmd/sandbox-node/main.go @@ -81,7 +81,7 @@ func run(ctx context.Context, args []string) error { if err != nil { return err } - built, closeProvider, err := registry.Build(config, providerconfig.LocalOptions{Standalone: true}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{Standalone: true}) if err != nil { return err } diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go index af749281f..dbfda8746 100644 --- a/services/core/internal/sandbox/deployment.go +++ b/services/core/internal/sandbox/deployment.go @@ -113,3 +113,8 @@ func (s DeploymentSpec) Digest(provider string) string { type Description struct { Mode, BackendFingerprint string } + +func BackendFingerprint(kind, namespace string) string { + digest := sha256.Sum256([]byte(kind + "\x00" + namespace)) + return hex.EncodeToString(digest[:]) +} diff --git a/services/core/internal/sandbox/docker/node.go b/services/core/internal/sandbox/docker/node.go new file mode 100644 index 000000000..63a2570fe --- /dev/null +++ b/services/core/internal/sandbox/docker/node.go @@ -0,0 +1,108 @@ +package docker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/moby/moby/client" +) + +// Native is the node configuration's native object for Docker. Image is the +// local ID of the release's Runtime image: the host's image store decides +// whether the config digest (image_id) or the manifest digest +// (image_manifest_digest) names the loaded image, so the installer records the +// one this host resolves. +type Native struct { + Host string `json:"host"` + Image string `json:"image"` + Network string `json:"network"` + SeccompFile string `json:"seccomp_file"` + ExtraHosts []string `json:"extra_hosts"` + NestedSandbox bool `json:"nested_sandbox"` +} + +func decodeNative(config sandbox.NodeConfig) (Native, error) { + var entry Native + if sandbox.DecodeConfigurationObject(config.Native, &entry, "host", "image", "network", "seccomp_file", "extra_hosts", "nested_sandbox") != nil { + return entry, errors.New("invalid managed Docker node configuration") + } + release := config.Specification.Runtime + if entry.Image != release.ImageID && entry.Image != release.ImageManifestDigest { + return entry, errors.New("Docker Runtime image differs from the deployment release") + } + return entry, nil +} + +// BuildNode constructs the node-local Docker adapter from a validated node configuration. +func BuildNode(config sandbox.NodeConfig, _ sandbox.LocalOptions, result *sandbox.Built) (func(), error) { + closeProvider := func() {} + entry, err := decodeNative(config) + if err != nil { + return closeProvider, err + } + host, err := url.Parse(entry.Host) + if err != nil || host.Scheme != "unix" || host.Host != "" || host.User != nil || host.RawQuery != "" || host.Fragment != "" || host.RawPath != "" || host.Path == "/" || !filepath.IsAbs(host.Path) || filepath.Clean(host.Path) != host.Path || entry.Host != "unix://"+host.Path { + return closeProvider, errors.New("managed Docker host must be an explicit canonical unix socket") + } + seccomp, err := os.ReadFile(entry.SeccompFile) + if err != nil { + return closeProvider, fmt.Errorf("cannot read managed Docker seccomp JSON: %w", err) + } + if !json.Valid(seccomp) { + return closeProvider, errors.New("invalid managed Docker seccomp JSON") + } + c, err := client.New(client.WithHost(entry.Host)) + if err != nil { + return closeProvider, errors.New("invalid managed Docker endpoint") + } + closeProvider = func() { _ = c.Close() } + provider, err := New(c, Config{InstallationID: config.InstallationID, Image: entry.Image, Network: entry.Network, Seccomp: string(seccomp), ExtraHosts: entry.ExtraHosts, NestedSandbox: entry.NestedSandbox, Resources: &config.Specification.Resources}) + if err != nil { + closeProvider() + return func() {}, errors.New("invalid managed Docker provider configuration") + } + result.Provider = provider + result.Probe = dockerProbe(c, entry.Image, config.Specification.Resources) + result.BackendFingerprint = sandbox.BackendFingerprint(config.Provider, entry.Host) + return closeProvider, nil +} + +// The probe reports its first failed check as its readiness class: the Docker +// daemon, then limit support, then host capacity for one sandbox of the +// deployment specification, then the pinned Runtime image. Unclassified +// failures stay provider_unavailable. The returned text is local; only its +// class is reported. +func dockerProbe(c *client.Client, image string, resources sandbox.Resources) func(context.Context) error { + return func(ctx context.Context) error { + if _, err := c.Ping(ctx, client.PingOptions{}); err != nil { + return fmt.Errorf("%w: Docker daemon is unreachable", sandbox.ErrProviderUnavailable) + } + host, err := c.Info(ctx, client.InfoOptions{}) + if err != nil { + return fmt.Errorf("%w: cannot inspect Docker host resource support", sandbox.ErrProviderUnavailable) + } + if !host.Info.MemoryLimit || !host.Info.CPUCfsQuota { + return fmt.Errorf("%w: Docker does not enforce CPU and memory limits", sandbox.ErrHostUnsupported) + } + if host.Info.MemTotal <= 0 { + return errors.New("Docker host memory capacity is unavailable") + } + if err := sandbox.CheckCapacity(resources, host.Info.NCPU, uint64(host.Info.MemTotal)); err != nil { + return err + } + if _, err = c.ImageInspect(ctx, image); errdefs.IsNotFound(err) { + return sandbox.ErrRuntimeImageUnavailable + } else if err != nil { + // The daemon did not answer; the image may still be present. + return fmt.Errorf("%w: cannot inspect the pinned Runtime image", sandbox.ErrProviderUnavailable) + } + return nil + } +} diff --git a/services/core/internal/sandbox/docker/node_test.go b/services/core/internal/sandbox/docker/node_test.go new file mode 100644 index 000000000..0d5f576c2 --- /dev/null +++ b/services/core/internal/sandbox/docker/node_test.go @@ -0,0 +1,85 @@ +package docker + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/moby/moby/client" +) + +// The host's image store decides which release digest names the loaded image. +func TestNativeImageIsAReleaseIdentity(t *testing.T) { + release := sandbox.RuntimeRelease{ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64)} + config := sandbox.NodeConfig{Specification: sandbox.DeploymentSpec{Runtime: &release}} + for _, image := range []string{release.ImageID, release.ImageManifestDigest} { + config.Native = json.RawMessage(`{"image":"` + image + `"}`) + if entry, err := decodeNative(config); err != nil || entry.Image != image { + t.Fatalf("rejected release image %s: %v", image, err) + } + } + for _, native := range []string{`{"image":"sha256:` + strings.Repeat("a", 64) + `"}`, `{"image":"` + release.ImageID + `","cpus":2}`, `{"image":null}`} { + config.Native = json.RawMessage(native) + if _, err := decodeNative(config); err == nil { + t.Fatalf("accepted native %s", native) + } + } +} + +func TestDockerProbeDiagnostics(t *testing.T) { + image := "sha256:" + strings.Repeat("c", 64) + for _, tc := range []struct { + name string + limits bool + cpus int + imageStatus int + want string + unreachable, infoFails bool + }{ + {name: "unreachable", unreachable: true, want: "provider_unavailable"}, + {name: "info", infoFails: true, want: "provider_unavailable"}, + // The pinned image is also missing below; earlier checks take precedence. + {name: "limits", cpus: 8, imageStatus: 404, want: "host_unsupported"}, + {name: "capacity", limits: true, cpus: 1, imageStatus: 404, want: "capacity_insufficient"}, + {name: "image", limits: true, cpus: 8, imageStatus: 404, want: "runtime_image_unavailable"}, + {name: "image_inspect_fails", limits: true, cpus: 8, imageStatus: 500, want: "provider_unavailable"}, + {name: "ready", limits: true, cpus: 8, imageStatus: 200}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch path := strings.TrimPrefix(r.URL.Path, "/v1.52"); { + case path == "/_ping": + _, _ = w.Write([]byte("OK")) + case path == "/info" && !tc.infoFails: + _ = json.NewEncoder(w).Encode(map[string]any{"MemoryLimit": tc.limits, "CpuCfsQuota": tc.limits, "NCPU": tc.cpus, "MemTotal": int64(64) << 30}) + case path == "/images/"+image+"/json" && tc.imageStatus == 200: + _ = json.NewEncoder(w).Encode(map[string]string{"Id": image}) + case path == "/images/"+image+"/json": + w.WriteHeader(tc.imageStatus) + _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) + default: + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) + } + })) + defer server.Close() + host := server.URL + if tc.unreachable { + host = "unix://" + filepath.Join(t.TempDir(), "missing.sock") + } + c, err := client.New(client.WithHost(host), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if got := sandbox.NodeDiagnostic(dockerProbe(c, image, sandbox.Resources{CPUs: 2, MemoryMiB: 1024})(t.Context())); got != tc.want { + t.Fatalf("diagnostic = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/services/core/internal/sandbox/providers/capacity_linux.go b/services/core/internal/sandbox/microsandbox/capacity_linux.go similarity index 73% rename from services/core/internal/sandbox/providers/capacity_linux.go rename to services/core/internal/sandbox/microsandbox/capacity_linux.go index fbadd8abb..3edc4b4a4 100644 --- a/services/core/internal/sandbox/providers/capacity_linux.go +++ b/services/core/internal/sandbox/microsandbox/capacity_linux.go @@ -1,6 +1,6 @@ //go:build linux -package providers +package microsandbox import ( "fmt" @@ -15,5 +15,5 @@ func hostCapacity(r sandbox.Resources) error { if err := syscall.Sysinfo(&info); err != nil { return fmt.Errorf("cannot verify node memory capacity") } - return checkCapacity(r, runtime.NumCPU(), uint64(info.Totalram)*uint64(info.Unit)) + return sandbox.CheckCapacity(r, runtime.NumCPU(), uint64(info.Totalram)*uint64(info.Unit)) } diff --git a/services/core/internal/sandbox/providers/capacity_other.go b/services/core/internal/sandbox/microsandbox/capacity_other.go similarity index 90% rename from services/core/internal/sandbox/providers/capacity_other.go rename to services/core/internal/sandbox/microsandbox/capacity_other.go index 6fe10fc56..68f15f265 100644 --- a/services/core/internal/sandbox/providers/capacity_other.go +++ b/services/core/internal/sandbox/microsandbox/capacity_other.go @@ -1,6 +1,6 @@ //go:build !linux -package providers +package microsandbox import ( "errors" diff --git a/services/core/internal/sandbox/providers/generation_probe_test.go b/services/core/internal/sandbox/microsandbox/generation_probe_test.go similarity index 90% rename from services/core/internal/sandbox/providers/generation_probe_test.go rename to services/core/internal/sandbox/microsandbox/generation_probe_test.go index 0c0004e09..951cfd85a 100644 --- a/services/core/internal/sandbox/providers/generation_probe_test.go +++ b/services/core/internal/sandbox/microsandbox/generation_probe_test.go @@ -1,4 +1,4 @@ -package providers +package microsandbox import ( "context" @@ -14,7 +14,7 @@ import ( func TestMicrosandboxGenerationImageProbe(t *testing.T) { dir := t.TempDir() imageDigest := "sha256:" + strings.Repeat("d", 64) - entry := Microsandbox{RuntimePath: filepath.Join(dir, "msb"), RuntimeHome: dir, FirmwarePath: filepath.Join(dir, "firmware"), Image: "oac-runtime@" + imageDigest} + entry := Config{RuntimePath: filepath.Join(dir, "msb"), RuntimeHome: dir, FirmwarePath: filepath.Join(dir, "firmware"), Image: "oac-runtime@" + imageDigest} // Check the native argument/environment boundary, including ambient isolation. script := `#!/bin/sh [ "$#" = 5 ] && [ "$1" = image ] && [ "$2" = inspect ] && [ "$3" = 'oac-runtime@` + imageDigest + `' ] && [ "$4" = --format ] && [ "$5" = json ] || exit 1 @@ -74,7 +74,7 @@ cat "$MSB_HOME/output" func TestMicrosandboxGenerationProbeRetainsEarlierFailures(t *testing.T) { for _, failure := range []error{context.Canceled, sandbox.ErrHostUnsupported, sandbox.ErrCapacityInsufficient, sandbox.ErrArtifactsUnavailable, errors.New("microsandbox state directory is unavailable")} { - probe := microsandboxGenerationProbe(Microsandbox{RuntimePath: "/missing"}, func(context.Context) error { return failure }) + probe := microsandboxGenerationProbe(Config{RuntimePath: "/missing"}, func(context.Context) error { return failure }) if got := probe(t.Context()); got != failure { t.Fatalf("earlier failure changed: got %v, want %v", got, failure) } diff --git a/services/core/internal/sandbox/microsandbox/node.go b/services/core/internal/sandbox/microsandbox/node.go new file mode 100644 index 000000000..69b331df2 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/node.go @@ -0,0 +1,100 @@ +package microsandbox + +import ( + "errors" + "os" + "path/filepath" + "strconv" + + "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +// NodeArtifacts are the native files a microsandbox node installs beside the +// shared node payload. +var NodeArtifacts = []providerassets.Artifact{ + {Path: "native/bin/oac-microsandbox-provider", Suffix: "microsandbox-provider", Role: "runtime"}, + {Path: "native/microsandbox/msb", Suffix: "msb", Role: "runtime"}, + {Path: "native/microsandbox/libkrunfw.so.5.6.1", Suffix: "libkrunfw.so.5.6.1", Role: "runtime"}, +} + +// Native is the node configuration's native object for microsandbox: the +// helper, Runtime and firmware paths, the private store and the network policy. +// The helper owns local paths; no ambient backend is selected. Resources, the +// image and the artifact hashes come from the deployment specification. +type Native struct { + HelperPath string `json:"helper_path"` + RuntimeHome string `json:"runtime_home"` + RuntimePath string `json:"runtime_path"` + FirmwarePath string `json:"firmware_path"` + Network Network `json:"network"` +} + +type Network struct { + DefaultEgress string `json:"default_egress"` + DefaultIngress string `json:"default_ingress"` + Rules []Rule `json:"rules"` +} + +type Rule struct { + Action string `json:"action"` + Direction string `json:"direction"` + Destination string `json:"destination"` + Protocol string `json:"protocol"` + Port string `json:"port"` +} + +func configureMicrosandbox(entry Native, spec sandbox.DeploymentSpec, caller *ProcessCaller, result *sandbox.Built) (Config, error) { + if !filepath.IsAbs(entry.RuntimeHome) || filepath.Clean(entry.RuntimeHome) != entry.RuntimeHome { + return Config{}, errors.New("managed microsandbox runtime_home must be a canonical absolute path") + } + network := NetworkPolicy{DefaultEgress: entry.Network.DefaultEgress, DefaultIngress: entry.Network.DefaultIngress} + for _, rule := range entry.Network.Rules { + network.Rules = append(network.Rules, NetworkRule{Action: rule.Action, Direction: rule.Direction, Destination: rule.Destination, Protocol: rule.Protocol, Port: rule.Port}) + } + release, resources := spec.Runtime, spec.Resources + config := Config{ + InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath, + RuntimeSHA256: release.RuntimeSHA256, FirmwareSHA256: release.FirmwareSHA256, Image: release.MicrosandboxRef, + MemoryMiB: resources.MemoryMiB, CPUs: uint8(resources.CPUs), RootDiskMiB: resources.RootDiskMiB, EnvironmentDiskMiB: resources.EnvironmentDiskMiB, Network: network, + } + provider, err := NewWithCaller(config, caller) + if err != nil { + return Config{}, errors.New("invalid managed microsandbox provider configuration") + } + result.Provider = provider + result.Probe = microsandboxProbe(config, resources) + result.Quiescent = caller.Quiescent + result.BackendFingerprint = sandbox.BackendFingerprint("microsandbox", entry.RuntimeHome) + return config, nil +} + +// BuildNode constructs the node-local microsandbox adapter from a validated node configuration. +func BuildNode(c sandbox.NodeConfig, options sandbox.LocalOptions, result *sandbox.Built) (func(), error) { + closeProvider := func() {} + var entry Native + if sandbox.DecodeConfigurationObject(c.Native, &entry, "helper_path", "runtime_home", "runtime_path", "firmware_path", "network") != nil { + return closeProvider, errors.New("invalid managed microsandbox node configuration") + } + caller := &ProcessCaller{} + if options.GenerationStateDirectory != "" { + directory := filepath.Join(options.GenerationStateDirectory, "generations") + if err := os.MkdirAll(directory, 0700); err != nil { + return closeProvider, err + } + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + return closeProvider, sandbox.ErrOwnership + } + caller.LeasePath = filepath.Join(directory, strconv.FormatUint(c.Generation, 10)+".lease") + caller.LeaseIdentity = LeaseIdentity{InstallationID: result.InstallationID, Generation: c.Generation, SpecificationDigest: result.SpecificationDigest} + } + config, err := configureMicrosandbox(entry, c.Specification, caller, result) + if err != nil { + return closeProvider, err + } + if options.GenerationStateDirectory != "" { + result.Probe = microsandboxGenerationProbe(config, result.Probe) + } + return closeProvider, nil +} diff --git a/services/core/internal/sandbox/microsandbox/node_test.go b/services/core/internal/sandbox/microsandbox/node_test.go new file mode 100644 index 000000000..082bfd5a7 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/node_test.go @@ -0,0 +1,53 @@ +package microsandbox + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("microsandbox requires Linux") + } + useKVM(t, true) + dir := t.TempDir() + native := Native{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), Network: Network{DefaultEgress: "allow", DefaultIngress: "deny"}} + raw, err := json.Marshal(native) + if err != nil { + t.Fatal(err) + } + script := []byte("#!/bin/sh\nprintf '%s' '{}'\n") + digest := sha256.Sum256(script) + release := sandbox.RuntimeRelease{MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:])} + config := sandbox.NodeConfig{Provider: "microsandbox", Generation: 9, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}, Runtime: &release}, Native: raw} + for _, path := range []string{native.RuntimePath, native.FirmwarePath, native.HelperPath} { + if err := os.WriteFile(path, script, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(native.RuntimeHome, 0700); err != nil { + t.Fatal(err) + } + for _, options := range []sandbox.LocalOptions{{Standalone: true}, {GenerationStateDirectory: t.TempDir()}} { + built := &sandbox.Built{InstallationID: uuid.NewString(), SpecificationDigest: config.Specification.Digest(config.Provider)} + closeProvider, err := BuildNode(config, options, built) + if err != nil { + t.Fatal(err) + } + err = built.Probe(t.Context()) + closeProvider() + if options.Standalone && err != nil || !options.Standalone && !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { + t.Fatalf("readiness for %+v: %v", options, err) + } + } +} diff --git a/services/core/internal/sandbox/providers/probe.go b/services/core/internal/sandbox/microsandbox/probe.go similarity index 63% rename from services/core/internal/sandbox/providers/probe.go rename to services/core/internal/sandbox/microsandbox/probe.go index c7e34316a..4e7c5772f 100644 --- a/services/core/internal/sandbox/providers/probe.go +++ b/services/core/internal/sandbox/microsandbox/probe.go @@ -1,4 +1,4 @@ -package providers +package microsandbox import ( "context" @@ -15,53 +15,21 @@ import ( "sync" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - "github.com/containerd/errdefs" - "github.com/moby/moby/client" ) -// Probes report the first failed check as its readiness class. Precedence runs -// from the provider platform (Docker daemon or KVM), through Docker limit -// support and host capacity for one sandbox of the deployment specification, to -// the installed Runtime content (image or microsandbox artifacts). Unclassified -// failures stay provider_unavailable. The returned text is local; only its class -// is reported. +// The probe reports its first failed check as its readiness class: KVM, then +// host capacity for one sandbox of the deployment specification, then the +// installed Runtime artifacts. Unclassified failures stay provider_unavailable. +// The returned text is local; only its class is reported. // kvmDevice is replaceable only by tests. var kvmDevice = "/dev/kvm" -func dockerProbe(c *client.Client, image string, resources sandbox.Resources) func(context.Context) error { - return func(ctx context.Context) error { - if _, err := c.Ping(ctx, client.PingOptions{}); err != nil { - return fmt.Errorf("%w: Docker daemon is unreachable", sandbox.ErrProviderUnavailable) - } - host, err := c.Info(ctx, client.InfoOptions{}) - if err != nil { - return fmt.Errorf("%w: cannot inspect Docker host resource support", sandbox.ErrProviderUnavailable) - } - if !host.Info.MemoryLimit || !host.Info.CPUCfsQuota { - return fmt.Errorf("%w: Docker does not enforce CPU and memory limits", sandbox.ErrHostUnsupported) - } - if host.Info.MemTotal <= 0 { - return errors.New("Docker host memory capacity is unavailable") - } - if err := checkCapacity(resources, host.Info.NCPU, uint64(host.Info.MemTotal)); err != nil { - return err - } - if _, err = c.ImageInspect(ctx, image); errdefs.IsNotFound(err) { - return sandbox.ErrRuntimeImageUnavailable - } else if err != nil { - // The daemon did not answer; the image may still be present. - return fmt.Errorf("%w: cannot inspect the pinned Runtime image", sandbox.ErrProviderUnavailable) - } - return nil - } -} - // A successful Runtime integrity check is cached for this immutable // configuration. A failure is checked again on the next heartbeat, so repaired // artifacts recover without a restart. Lifecycle calls still verify the exact // artifact themselves. -func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(context.Context) error { +func microsandboxProbe(entry Config, resources sandbox.Resources) func(context.Context) error { var integrity sync.Mutex verified := false return func(ctx context.Context) error { @@ -100,7 +68,7 @@ func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(con } } -func verifyMicrosandboxArtifacts(entry Microsandbox) error { +func verifyMicrosandboxArtifacts(entry Config) error { for _, artifact := range []struct{ path, hash string }{{entry.RuntimePath, entry.RuntimeSHA256}, {entry.FirmwarePath, entry.FirmwareSHA256}} { f, err := os.Open(artifact.path) if err != nil { @@ -118,7 +86,7 @@ func verifyMicrosandboxArtifacts(entry Microsandbox) error { // Image availability is checked on every retained-generation probe, after the // platform, capacity and local artifact checks. -func microsandboxGenerationProbe(entry Microsandbox, probe func(context.Context) error) func(context.Context) error { +func microsandboxGenerationProbe(entry Config, probe func(context.Context) error) func(context.Context) error { return func(ctx context.Context) error { if err := probe(ctx); err != nil { return err diff --git a/services/core/internal/sandbox/providers/probe_test.go b/services/core/internal/sandbox/microsandbox/probe_test.go similarity index 57% rename from services/core/internal/sandbox/providers/probe_test.go rename to services/core/internal/sandbox/microsandbox/probe_test.go index b258aaf39..53b767e42 100644 --- a/services/core/internal/sandbox/providers/probe_test.go +++ b/services/core/internal/sandbox/microsandbox/probe_test.go @@ -1,11 +1,8 @@ -package providers +package microsandbox import ( "crypto/sha256" "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" "os" "path/filepath" "runtime" @@ -13,7 +10,6 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - "github.com/moby/moby/client" ) var smallSandbox = sandbox.Resources{CPUs: 1, MemoryMiB: 512} @@ -72,7 +68,7 @@ func TestMicrosandboxProbeRequiresPrivateRuntimeDirectory(t *testing.T) { } home = link } - probe := microsandboxProbe(Microsandbox{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) + probe := microsandboxProbe(Config{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) err := probe(t.Context()) if tc.rejected { // An unclassified cause keeps the generic readiness code. @@ -112,7 +108,7 @@ func TestMicrosandboxProbeDiagnostics(t *testing.T) { t.Skip("microsandbox requires Linux") } dir := t.TempDir() - missing := Microsandbox{HelperPath: filepath.Join(dir, "helper"), RuntimePath: filepath.Join(dir, "runtime"), FirmwarePath: filepath.Join(dir, "firmware"), RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), RuntimeHome: dir} + missing := Config{HelperPath: filepath.Join(dir, "helper"), RuntimePath: filepath.Join(dir, "runtime"), FirmwarePath: filepath.Join(dir, "firmware"), RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), RuntimeHome: dir} for _, tc := range []struct { name string kvm bool @@ -133,60 +129,6 @@ func TestMicrosandboxProbeDiagnostics(t *testing.T) { } } -func TestDockerProbeDiagnostics(t *testing.T) { - image := "sha256:" + strings.Repeat("c", 64) - for _, tc := range []struct { - name string - limits bool - cpus int - imageStatus int - want string - unreachable, infoFails bool - }{ - {name: "unreachable", unreachable: true, want: "provider_unavailable"}, - {name: "info", infoFails: true, want: "provider_unavailable"}, - // The pinned image is also missing below; earlier checks take precedence. - {name: "limits", cpus: 8, imageStatus: 404, want: "host_unsupported"}, - {name: "capacity", limits: true, cpus: 1, imageStatus: 404, want: "capacity_insufficient"}, - {name: "image", limits: true, cpus: 8, imageStatus: 404, want: "runtime_image_unavailable"}, - {name: "image_inspect_fails", limits: true, cpus: 8, imageStatus: 500, want: "provider_unavailable"}, - {name: "ready", limits: true, cpus: 8, imageStatus: 200}, - } { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch path := strings.TrimPrefix(r.URL.Path, "/v1.52"); { - case path == "/_ping": - _, _ = w.Write([]byte("OK")) - case path == "/info" && !tc.infoFails: - _ = json.NewEncoder(w).Encode(map[string]any{"MemoryLimit": tc.limits, "CpuCfsQuota": tc.limits, "NCPU": tc.cpus, "MemTotal": int64(64) << 30}) - case path == "/images/"+image+"/json" && tc.imageStatus == 200: - _ = json.NewEncoder(w).Encode(map[string]string{"Id": image}) - case path == "/images/"+image+"/json": - w.WriteHeader(tc.imageStatus) - _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) - default: - w.WriteHeader(500) - _, _ = w.Write([]byte(`{"message":"private daemon detail"}`)) - } - })) - defer server.Close() - host := server.URL - if tc.unreachable { - host = "unix://" + filepath.Join(t.TempDir(), "missing.sock") - } - c, err := client.New(client.WithHost(host), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - defer c.Close() - if got := sandbox.NodeDiagnostic(dockerProbe(c, image, sandbox.Resources{CPUs: 2, MemoryMiB: 1024})(t.Context())); got != tc.want { - t.Fatalf("diagnostic = %q, want %q", got, tc.want) - } - }) - } -} - // A failed integrity check is repeated, so repaired artifacts recover without a restart. func TestMicrosandboxProbeRecoversRepairedArtifacts(t *testing.T) { if runtime.GOOS != "linux" { @@ -200,7 +142,7 @@ func TestMicrosandboxProbeRecoversRepairedArtifacts(t *testing.T) { if err := os.Mkdir(home, 0700); err != nil { t.Fatal(err) } - probe := microsandboxProbe(Microsandbox{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) + probe := microsandboxProbe(Config{HelperPath: artifact, RuntimePath: artifact, FirmwarePath: artifact, RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), RuntimeHome: home}, smallSandbox) if got := sandbox.NodeDiagnostic(probe(t.Context())); got != "artifacts_unavailable" { t.Fatalf("missing artifact diagnostic = %q", got) } diff --git a/services/core/internal/sandbox/node/generations.go b/services/core/internal/sandbox/node/generations.go index 42bd03f8c..f538f0643 100644 --- a/services/core/internal/sandbox/node/generations.go +++ b/services/core/internal/sandbox/node/generations.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) @@ -363,7 +364,9 @@ func (m *GenerationManager) probeLoop() { cancel() m.mu.Lock() g.refs-- + changed := false if !errors.Is(err, context.Canceled) { + state, diagnostic := g.state, g.diagnostic g.state = "ready" g.diagnostic = "" if err != nil { @@ -373,8 +376,14 @@ func (m *GenerationManager) probeLoop() { g.repairing = true } } + changed = g.state != state || g.diagnostic != diagnostic } + generation, diagnostic := g.value.Generation, g.diagnostic m.mu.Unlock() + if err != nil && changed { + // The local error stays in this host's journal; it may name host paths. + log.Ctx(m.ctx).Warn("sandbox node generation provider unavailable; check local runtime configuration and permissions", "generation", generation, "diagnostic", diagnostic, "error", err) + } } } diff --git a/services/core/internal/sandbox/node_diagnostic.go b/services/core/internal/sandbox/node_diagnostic.go index b13fbe34b..efcdedc58 100644 --- a/services/core/internal/sandbox/node_diagnostic.go +++ b/services/core/internal/sandbox/node_diagnostic.go @@ -1,6 +1,9 @@ package sandbox -import "errors" +import ( + "errors" + "fmt" +) // Node readiness classes. A node probe returns or wraps the class of its first // failed check and keeps the Provider's detail, such as host paths or daemon @@ -21,6 +24,14 @@ var ( ErrCapacityInsufficient = errors.New("node cannot provide one sandbox of the deployment specification") ) +// CheckCapacity reports whether a host can hold one sandbox of the given resources. +func CheckCapacity(r Resources, cpus int, memory uint64) error { + if cpus < int(r.CPUs) || memory < uint64(r.MemoryMiB)*1024*1024 { + return fmt.Errorf("%w: one sandbox requires %d CPUs and %d MiB memory; available host capacity is %d CPUs and %d MiB", ErrCapacityInsufficient, r.CPUs, r.MemoryMiB, cpus, memory/1024/1024) + } + return nil +} + // NodeProviderUnavailable also reports every readiness failure without a class. const NodeProviderUnavailable = "provider_unavailable" diff --git a/services/core/internal/sandbox/providers/capacity.go b/services/core/internal/sandbox/providers/capacity.go deleted file mode 100644 index 1b569c132..000000000 --- a/services/core/internal/sandbox/providers/capacity.go +++ /dev/null @@ -1,14 +0,0 @@ -package providers - -import ( - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" -) - -func checkCapacity(r sandbox.Resources, cpus int, memory uint64) error { - if cpus < int(r.CPUs) || memory < uint64(r.MemoryMiB)*1024*1024 { - return fmt.Errorf("%w: one sandbox requires %d CPUs and %d MiB memory; available host capacity is %d CPUs and %d MiB", sandbox.ErrCapacityInsufficient, r.CPUs, r.MemoryMiB, cpus, memory/1024/1024) - } - return nil -} diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go index 32a3b1f33..07b0359b1 100644 --- a/services/core/internal/sandbox/providers/config.go +++ b/services/core/internal/sandbox/providers/config.go @@ -3,11 +3,9 @@ package providers import ( "bytes" - "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" @@ -16,28 +14,9 @@ import ( "github.com/google/uuid" ) -type Config struct { - Specification sandbox.DeploymentSpec `json:"specification"` - Generation uint64 `json:"generation"` - CoreURL string `json:"core_url"` - Provider string `json:"provider"` - InstallationID string `json:"installation_id"` - Docker *Docker `json:"docker,omitempty"` - Microsandbox *Microsandbox `json:"microsandbox,omitempty"` -} - -type Docker struct { - Host string `json:"host"` - Image string `json:"image"` - Network string `json:"network"` - SeccompFile string `json:"seccomp_file"` - ExtraHosts []string `json:"extra_hosts"` - NestedSandbox bool `json:"nested_sandbox"` -} - -// Load rejects unknown fields, mixed adapters and explicit null configuration. -func Load(file string) (Config, error) { - var config Config +// Load rejects unknown fields. The selected adapter decodes native at Build. +func Load(file string) (sandbox.NodeConfig, error) { + var config sandbox.NodeConfig raw, err := os.ReadFile(file) if err != nil { return config, errors.New("cannot read sandbox configuration") @@ -47,50 +26,32 @@ func Load(file string) (Config, error) { if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF { return config, errors.New("invalid sandbox configuration") } - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { - return config, errors.New("invalid sandbox configuration") - } - _, docker := fields["docker"] - _, micro := fields["microsandbox"] - if docker && micro { - return config, errors.New("only one sandbox provider can be configured") - } return config, nil } -type Built struct { - SpecificationDigest string - Provider sandbox.SandboxProvider - InstallationID, BackendFingerprint string - Probe func(context.Context) error - // Quiescent is nil when no helper can outlive its caller. - Quiescent func() bool -} - -// LocalOptions supplies process-local context without changing persisted configuration. -type LocalOptions struct { - // Standalone selects registration or execution without a generation manager. - Standalone bool - // GenerationStateDirectory is the node state directory when constructing a - // retained generation. It must be canonical and absolute, and Standalone - // must be false. - GenerationStateDirectory string -} - -func (r *Registry) Build(config Config, options LocalOptions) (*Built, func(), error) { +// Build validates the Core-owned part of a node configuration and passes it to +// the registered adapter. Local paths belong to the node; Core owns reservation +// capacity, execution resources and the immutable deployment release it +// enrolled with. +func (r *Registry) Build(config sandbox.NodeConfig, options sandbox.LocalOptions) (*sandbox.Built, func(), error) { closeProvider := func() {} - if err := r.validateSpecification(config); err != nil { + adapter, err := r.Lookup(config.Provider) + if err != nil { + return nil, closeProvider, err + } + if adapter.Mode != "nodes" { + return nil, closeProvider, fmt.Errorf("%w: selected provider does not support node hosting", sandbox.ErrInvalid) + } + if config.Generation == 0 { + return nil, closeProvider, errors.New("node requires a deployment generation; obtain configuration from Core") + } + if err := adapter.ValidateSpecification(config.Specification); err != nil { return nil, closeProvider, err } id, err := uuid.Parse(config.InstallationID) if err != nil || id == uuid.Nil || id.String() != config.InstallationID { return nil, closeProvider, errors.New("sandbox requires a canonical installation_id UUID") } - adapter, err := r.Lookup(config.Provider) - if err != nil || adapter.BuildLocal == nil { - return nil, closeProvider, errors.New("sandbox provider is not node-local") - } if options.Standalone { if options.GenerationStateDirectory != "" { return nil, closeProvider, sandbox.ErrInvalid @@ -98,7 +59,7 @@ func (r *Registry) Build(config Config, options LocalOptions) (*Built, func(), e } else if !filepath.IsAbs(options.GenerationStateDirectory) || filepath.Clean(options.GenerationStateDirectory) != options.GenerationStateDirectory { return nil, closeProvider, sandbox.ErrInvalid } - result := &Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} + result := &sandbox.Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} closeProvider, err = adapter.BuildLocal(config, options, result) if err != nil { return nil, closeProvider, err @@ -109,8 +70,3 @@ func (r *Registry) Build(config Config, options LocalOptions) (*Built, func(), e } return result, closeProvider, nil } - -func BackendFingerprint(kind, namespace string) string { - digest := sha256.Sum256([]byte(kind + "\x00" + namespace)) - return hex.EncodeToString(digest[:]) -} diff --git a/services/core/internal/sandbox/providers/config_test.go b/services/core/internal/sandbox/providers/config_test.go index 0753377ef..33695ac33 100644 --- a/services/core/internal/sandbox/providers/config_test.go +++ b/services/core/internal/sandbox/providers/config_test.go @@ -3,62 +3,34 @@ package providers import ( "os" "path/filepath" - "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" ) func TestNodeRejectsCoreConfigurationAndUnknownProvider(t *testing.T) { - for _, field := range []string{`"nodes":{"local":false}`, `"maintenance":true`} { + for _, field := range []string{`"nodes":{"local":false}`, `"maintenance":true`, `"docker":{}`} { path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(`{"provider":"docker",`+field+`}`), 0600); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { - t.Fatal("accepted retired Core configuration") + t.Fatal("accepted a field outside the node configuration") } } - if BackendFingerprint("docker", "socket") == BackendFingerprint("microsandbox", "socket") { + if sandbox.BackendFingerprint("docker", "socket") == sandbox.BackendFingerprint("microsandbox", "socket") { t.Fatal("provider namespaces collide") } } -func TestNodeSpecificationCannotBeOverridden(t *testing.T) { + +func TestNodeBuildRequiresNodeProviderAndGeneration(t *testing.T) { registry := Builtin() - if err := registry.validateSpecification(Config{Generation: 1, Provider: "e2b", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Docker: &Docker{}}); err == nil { + options := sandbox.LocalOptions{Standalone: true} + if _, _, err := registry.Build(sandbox.NodeConfig{Generation: 1, Provider: "e2b", InstallationID: uuid.NewString(), Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}}, options); err == nil { t.Fatal("accepted a cloud provider on a node") } - release := sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)} - c := Config{Generation: 1, Provider: "docker", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &release}, Docker: &Docker{Image: release.ImageID}} - for _, image := range []string{release.ImageID, release.ImageManifestDigest} { - c.Docker.Image = image - if err := registry.validateSpecification(c); err != nil { - t.Fatal(err) - } - } - c.Docker.Image = "sha256:" + strings.Repeat("a", 64) - if err := registry.validateSpecification(c); err == nil { - t.Fatal("accepted different Runtime") - } - c.Provider = "microsandbox" - c.Docker = nil - c.Specification.Resources.RootDiskMiB = 8192 - c.Specification.Resources.EnvironmentDiskMiB = 8192 - c.Microsandbox = &Microsandbox{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192, Image: release.MicrosandboxRef, RuntimeSHA256: release.RuntimeSHA256, FirmwareSHA256: release.FirmwareSHA256} - if err := registry.validateSpecification(c); err != nil { - t.Fatal(err) - } - for _, change := range []func(*Microsandbox){func(m *Microsandbox) { m.CPUs = 1 }, func(m *Microsandbox) { m.MemoryMiB = 1024 }, func(m *Microsandbox) { m.EnvironmentDiskMiB = 4096 }, func(m *Microsandbox) { m.RootDiskMiB = 4096 }, func(m *Microsandbox) { m.FirmwareSHA256 = strings.Repeat("a", 64) }} { - copy := *c.Microsandbox - change(©) - other := c - other.Microsandbox = © - if err := registry.validateSpecification(other); err == nil { - t.Fatal("accepted local specification override") - } - } - c.Generation = 0 - if err := registry.validateSpecification(c); err == nil { + if _, _, err := registry.Build(sandbox.NodeConfig{Provider: "docker", InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options); err == nil { t.Fatal("accepted unbound node") } } diff --git a/services/core/internal/sandbox/providers/docker.go b/services/core/internal/sandbox/providers/docker.go deleted file mode 100644 index c0f186c00..000000000 --- a/services/core/internal/sandbox/providers/docker.go +++ /dev/null @@ -1,46 +0,0 @@ -package providers - -import ( - "encoding/json" - "errors" - "fmt" - "net/url" - "os" - "path/filepath" - - sandboxdocker "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" - "github.com/moby/moby/client" -) - -func buildDocker(config Config, _ LocalOptions, result *Built) (func(), error) { - closeProvider := func() {} - if config.Docker == nil || config.Microsandbox != nil { - return closeProvider, errors.New("managed Docker requires only the docker configuration object") - } - entry := config.Docker - host, err := url.Parse(entry.Host) - if err != nil || host.Scheme != "unix" || host.Host != "" || host.User != nil || host.RawQuery != "" || host.Fragment != "" || host.RawPath != "" || host.Path == "/" || !filepath.IsAbs(host.Path) || filepath.Clean(host.Path) != host.Path || entry.Host != "unix://"+host.Path { - return closeProvider, errors.New("managed Docker host must be an explicit canonical unix socket") - } - seccomp, err := os.ReadFile(entry.SeccompFile) - if err != nil { - return closeProvider, fmt.Errorf("cannot read managed Docker seccomp JSON: %w", err) - } - if !json.Valid(seccomp) { - return closeProvider, errors.New("invalid managed Docker seccomp JSON") - } - c, err := client.New(client.WithHost(entry.Host)) - if err != nil { - return closeProvider, errors.New("invalid managed Docker endpoint") - } - closeProvider = func() { _ = c.Close() } - provider, err := sandboxdocker.New(c, sandboxdocker.Config{InstallationID: config.InstallationID, Image: entry.Image, Network: entry.Network, Seccomp: string(seccomp), ExtraHosts: entry.ExtraHosts, NestedSandbox: entry.NestedSandbox, Resources: &config.Specification.Resources}) - if err != nil { - closeProvider() - return func() {}, errors.New("invalid managed Docker provider configuration") - } - result.Provider = provider - result.Probe = dockerProbe(c, entry.Image, config.Specification.Resources) - result.BackendFingerprint = BackendFingerprint(config.Provider, entry.Host) - return closeProvider, nil -} diff --git a/services/core/internal/sandbox/providers/generation_test.go b/services/core/internal/sandbox/providers/generation_test.go index e9b1c957c..0bacbe40d 100644 --- a/services/core/internal/sandbox/providers/generation_test.go +++ b/services/core/internal/sandbox/providers/generation_test.go @@ -2,13 +2,10 @@ package providers import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "os" "path/filepath" - "runtime" "strconv" "syscall" "testing" @@ -19,16 +16,18 @@ import ( "github.com/google/uuid" ) -func generationConfig(t *testing.T) Config { +func generationConfig(t *testing.T) (sandbox.NodeConfig, sandboxmicro.Native) { t.Helper() dir := t.TempDir() spec := validRegistrationSpec() spec.Resources.RootDiskMiB, spec.Resources.EnvironmentDiskMiB = 8192, 8192 - return Config{Provider: "microsandbox", InstallationID: uuid.NewString(), Generation: 9, Specification: spec, - Microsandbox: &Microsandbox{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), - RuntimeSHA256: spec.Runtime.RuntimeSHA256, FirmwareSHA256: spec.Runtime.FirmwareSHA256, Image: spec.Runtime.MicrosandboxRef, - CPUs: uint8(spec.Resources.CPUs), MemoryMiB: spec.Resources.MemoryMiB, RootDiskMiB: spec.Resources.RootDiskMiB, EnvironmentDiskMiB: spec.Resources.EnvironmentDiskMiB, - Network: Network{DefaultEgress: "allow", DefaultIngress: "deny"}}} + native := sandboxmicro.Native{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), + Network: sandboxmicro.Network{DefaultEgress: "allow", DefaultIngress: "deny"}} + raw, err := json.Marshal(native) + if err != nil { + t.Fatal(err) + } + return sandbox.NodeConfig{Provider: "microsandbox", InstallationID: uuid.NewString(), Generation: 9, Specification: spec, Native: raw}, native } func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { @@ -54,7 +53,8 @@ func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { t.Fatal(err) } } - built, closeProvider, err := registry.Build(generationConfig(t), LocalOptions{GenerationStateDirectory: state}) + config, _ := generationConfig(t) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state}) closeProvider() if mode == "private" { info, statErr := os.Lstat(directory) @@ -74,9 +74,9 @@ func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { // lease to this generation, installation and specification before any helper runs. func TestMicrosandboxGenerationBindsLeaseIdentity(t *testing.T) { registry := Builtin() - config := generationConfig(t) + config, native := generationConfig(t) state := t.TempDir() - built, closeProvider, err := registry.Build(config, LocalOptions{GenerationStateDirectory: state}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state}) if err != nil { t.Fatal(err) } @@ -89,7 +89,7 @@ func TestMicrosandboxGenerationBindsLeaseIdentity(t *testing.T) { t.Fatal(err) } script := "#!/bin/sh\n[ \"$OAC_NODE_GENERATION_LEASE_FD\" = 3 ] || exit 1\ncat >/dev/null\nprintf '%s' '" + string(response) + "'\n" - if err := os.WriteFile(config.Microsandbox.HelperPath, []byte(script), 0700); err != nil { + if err := os.WriteFile(native.HelperPath, []byte(script), 0700); err != nil { t.Fatal(err) } base := filepath.Join(state, "generations", strconv.FormatUint(config.Generation, 10)) @@ -152,10 +152,9 @@ func TestMicrosandboxGenerationRejectsUnpinnedImage(t *testing.T) { registry := Builtin() for _, image := range []string{"latest", "oac-runtime@sha256:bad", "oac-runtime@sha256:"} { t.Run(image, func(t *testing.T) { - config := generationConfig(t) - config.Microsandbox.Image = image + config, _ := generationConfig(t) config.Specification.Runtime.MicrosandboxRef = image - built, closeProvider, err := registry.Build(config, LocalOptions{GenerationStateDirectory: t.TempDir()}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: t.TempDir()}) closeProvider() if err == nil || built != nil { t.Fatalf("accepted image %q", image) @@ -167,55 +166,22 @@ func TestMicrosandboxGenerationRejectsUnpinnedImage(t *testing.T) { func TestLocalConstructionRequiresExplicitContext(t *testing.T) { registry := Builtin() state := t.TempDir() - for _, options := range []LocalOptions{ + config, _ := generationConfig(t) + for _, options := range []sandbox.LocalOptions{ {}, {Standalone: true, GenerationStateDirectory: state}, {GenerationStateDirectory: "relative"}, {GenerationStateDirectory: state + "/../node"}, } { - built, closeProvider, err := registry.Build(generationConfig(t), options) + built, closeProvider, err := registry.Build(config, options) closeProvider() if !errors.Is(err, sandbox.ErrInvalid) || built != nil { t.Fatalf("accepted construction context %+v: %v", options, err) } } - built, closeProvider, err := registry.Build(generationConfig(t), LocalOptions{Standalone: true}) + built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{Standalone: true}) closeProvider() if err != nil || built == nil { t.Fatalf("standalone construction: %v", err) } } - -func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) { - registry := Builtin() - if runtime.GOOS != "linux" { - t.Skip("microsandbox requires Linux") - } - useKVM(t, true) - config := generationConfig(t) - script := []byte("#!/bin/sh\nprintf '%s' '{}'\n") - digest := sha256.Sum256(script) - config.Microsandbox.RuntimeSHA256 = hex.EncodeToString(digest[:]) - config.Microsandbox.FirmwareSHA256 = hex.EncodeToString(digest[:]) - config.Specification.Runtime.RuntimeSHA256 = config.Microsandbox.RuntimeSHA256 - config.Specification.Runtime.FirmwareSHA256 = config.Microsandbox.FirmwareSHA256 - for _, path := range []string{config.Microsandbox.RuntimePath, config.Microsandbox.FirmwarePath, config.Microsandbox.HelperPath} { - if err := os.WriteFile(path, script, 0700); err != nil { - t.Fatal(err) - } - } - if err := os.Chmod(config.Microsandbox.RuntimeHome, 0700); err != nil { - t.Fatal(err) - } - for _, options := range []LocalOptions{{Standalone: true}, {GenerationStateDirectory: t.TempDir()}} { - built, closeProvider, err := registry.Build(config, options) - if err != nil { - t.Fatal(err) - } - err = built.Probe(t.Context()) - closeProvider() - if options.Standalone && err != nil || !options.Standalone && !errors.Is(err, sandbox.ErrRuntimeImageUnavailable) { - t.Fatalf("readiness for %+v: %v", options, err) - } - } -} diff --git a/services/core/internal/sandbox/providers/microsandbox.go b/services/core/internal/sandbox/providers/microsandbox.go deleted file mode 100644 index 58fbcb8cf..000000000 --- a/services/core/internal/sandbox/providers/microsandbox.go +++ /dev/null @@ -1,92 +0,0 @@ -package providers - -import ( - "errors" - "os" - "path/filepath" - "strconv" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - sandboxmicro "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" -) - -// Single-host providers pin the helper, runtime, firmware, image and resource -// limits explicitly. The helper owns local paths; no ambient backend is selected. -type Microsandbox struct { - HelperPath string `json:"helper_path"` - RuntimeHome string `json:"runtime_home"` - RuntimePath string `json:"runtime_path"` - FirmwarePath string `json:"firmware_path"` - RuntimeSHA256 string `json:"runtime_sha256"` - FirmwareSHA256 string `json:"firmware_sha256"` - Image string `json:"image"` - MemoryMiB uint32 `json:"memory_mib"` - CPUs uint8 `json:"cpus"` - RootDiskMiB uint32 `json:"root_disk_mib"` - EnvironmentDiskMiB uint32 `json:"environment_disk_mib"` - Network Network `json:"network"` -} - -type Network struct { - DefaultEgress string `json:"default_egress"` - DefaultIngress string `json:"default_ingress"` - Rules []Rule `json:"rules"` -} - -type Rule struct { - Action string `json:"action"` - Direction string `json:"direction"` - Destination string `json:"destination"` - Protocol string `json:"protocol"` - Port string `json:"port"` -} - -func configureMicrosandbox(entry Microsandbox, resources sandbox.Resources, caller *sandboxmicro.ProcessCaller, result *Built) error { - if !filepath.IsAbs(entry.RuntimeHome) || filepath.Clean(entry.RuntimeHome) != entry.RuntimeHome { - return errors.New("managed microsandbox runtime_home must be a canonical absolute path") - } - network := sandboxmicro.NetworkPolicy{DefaultEgress: entry.Network.DefaultEgress, DefaultIngress: entry.Network.DefaultIngress} - for _, rule := range entry.Network.Rules { - network.Rules = append(network.Rules, sandboxmicro.NetworkRule{Action: rule.Action, Direction: rule.Direction, Destination: rule.Destination, Protocol: rule.Protocol, Port: rule.Port}) - } - provider, err := sandboxmicro.NewWithCaller(sandboxmicro.Config{ - InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath, - RuntimeSHA256: entry.RuntimeSHA256, FirmwareSHA256: entry.FirmwareSHA256, Image: entry.Image, - MemoryMiB: entry.MemoryMiB, CPUs: entry.CPUs, RootDiskMiB: entry.RootDiskMiB, EnvironmentDiskMiB: entry.EnvironmentDiskMiB, Network: network, - }, caller) - if err != nil { - return errors.New("invalid managed microsandbox provider configuration") - } - result.Provider = provider - result.Probe = microsandboxProbe(entry, resources) - result.Quiescent = caller.Quiescent - result.BackendFingerprint = BackendFingerprint("microsandbox", entry.RuntimeHome) - return nil -} - -func buildMicrosandbox(c Config, options LocalOptions, result *Built) (func(), error) { - closeProvider := func() {} - if c.Microsandbox == nil || c.Docker != nil { - return closeProvider, errors.New("managed microsandbox requires only the microsandbox configuration object") - } - caller := &sandboxmicro.ProcessCaller{} - if options.GenerationStateDirectory != "" { - directory := filepath.Join(options.GenerationStateDirectory, "generations") - if err := os.MkdirAll(directory, 0700); err != nil { - return closeProvider, err - } - info, err := os.Lstat(directory) - if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - return closeProvider, sandbox.ErrOwnership - } - caller.LeasePath = filepath.Join(directory, strconv.FormatUint(c.Generation, 10)+".lease") - caller.LeaseIdentity = sandboxmicro.LeaseIdentity{InstallationID: result.InstallationID, Generation: c.Generation, SpecificationDigest: result.SpecificationDigest} - } - if err := configureMicrosandbox(*c.Microsandbox, c.Specification.Resources, caller, result); err != nil { - return closeProvider, err - } - if options.GenerationStateDirectory != "" { - result.Probe = microsandboxGenerationProbe(*c.Microsandbox, result.Probe) - } - return closeProvider, nil -} diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index f2b595a45..592bdf313 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -64,7 +64,7 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { a := registry.adapters["docker"] - a.BuildLocal = func(Config, LocalOptions, *Built) (func(), error) { + a.BuildLocal = func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) { t.Fatal("called local constructor") return nil, nil } @@ -96,7 +96,7 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { {"resolve change", func() error { _, err := registry.ResolveChange(selection, selection); return err }}, {"credential", func() error { _, err := registry.WithCredential(selection, selection); return err }}, {"local build", func() error { - _, _, err := registry.Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: selection.DeploymentSpec}, LocalOptions{Standalone: true}) + _, _, err := registry.Build(sandbox.NodeConfig{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: selection.DeploymentSpec}, sandbox.LocalOptions{Standalone: true}) return err }}, {"direct build", func() error { _, err := registry.BuildDirect(sandbox.DirectConfig{Selection: selection}); return err }}, @@ -129,9 +129,9 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { } const kind = "new-test-provider" calls, closes := 0, 0 - options := LocalOptions{GenerationStateDirectory: t.TempDir()} + options := sandbox.LocalOptions{GenerationStateDirectory: t.TempDir()} a := registry.adapters["docker"] - a.BuildLocal = func(_ Config, got LocalOptions, built *Built) (func(), error) { + a.BuildLocal = func(_ sandbox.NodeConfig, got sandbox.LocalOptions, built *sandbox.Built) (func(), error) { calls++ if got != options { t.Fatalf("construction options = %+v, want %+v", got, options) @@ -140,7 +140,7 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { return func() { closes++ }, nil } registry.adapters[kind] = a - built, closeProvider, err := registry.Build(Config{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options) + built, closeProvider, err := registry.Build(sandbox.NodeConfig{Provider: kind, Generation: 1, InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options) if err != nil || built.Provider == nil || calls != 1 { t.Fatalf("node build: %v calls=%d", err, calls) } diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index 51eb5aaaf..fe936c38a 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -20,7 +20,7 @@ type Adapter struct { NodeArtifacts []providerassets.Artifact Policy sandbox.DeploymentPolicy Configuration sandbox.ConfigurationAdapter - BuildLocal func(Config, LocalOptions, *Built) (func(), error) + BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) Mode string Operations func() providercontract.Operations @@ -40,16 +40,13 @@ func Builtin() *Registry { return &Registry{adapters: map[string]Adapter{ "docker": { NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, - Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: buildDocker, + Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: docker.BuildNode, ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, Configuration: nodeConfigurationAdapter{docker.ValidateSpecification}, }, "microsandbox": { - NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy, - {Path: "native/bin/oac-microsandbox-provider", Suffix: "microsandbox-provider", Role: "runtime"}, - {Path: "native/microsandbox/msb", Suffix: "msb", Role: "runtime"}, - {Path: "native/microsandbox/libkrunfw.so.5.6.1", Suffix: "libkrunfw.so.5.6.1", Role: "runtime"}}, - Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: buildMicrosandbox, + NodeArtifacts: append([]providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, microsandbox.NodeArtifacts...), + Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: microsandbox.BuildNode, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, }, @@ -139,7 +136,7 @@ func (r *Registry) Describe(kind, installation string) (sandbox.Description, err if a.Mode == "direct" { namespace = kind } - return sandbox.Description{Mode: a.Mode, BackendFingerprint: BackendFingerprint(kind, namespace+":"+installation)}, nil + return sandbox.Description{Mode: a.Mode, BackendFingerprint: sandbox.BackendFingerprint(kind, namespace+":"+installation)}, nil } // DeploymentContract projects the registered modes and policies into the node diff --git a/services/core/internal/sandbox/providers/registry_test.go b/services/core/internal/sandbox/providers/registry_test.go index bd008e63e..bd6d1ac4f 100644 --- a/services/core/internal/sandbox/providers/registry_test.go +++ b/services/core/internal/sandbox/providers/registry_test.go @@ -22,7 +22,7 @@ func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { } { t.Run(tc.kind, func(t *testing.T) { d, err := registry.Describe(tc.kind, installation) - if err != nil || d.Mode != tc.mode || d.BackendFingerprint != BackendFingerprint(tc.kind, tc.namespace+":"+installation) { + if err != nil || d.Mode != tc.mode || d.BackendFingerprint != sandbox.BackendFingerprint(tc.kind, tc.namespace+":"+installation) { t.Fatalf("wrong mode or namespace: %+v %v", d, err) } a, err := registry.Lookup(tc.kind) diff --git a/services/core/internal/sandbox/providers/specification.go b/services/core/internal/sandbox/providers/specification.go deleted file mode 100644 index 72b1540b2..000000000 --- a/services/core/internal/sandbox/providers/specification.go +++ /dev/null @@ -1,42 +0,0 @@ -package providers - -import ( - "errors" - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" -) - -// Local paths belong to the node. Core owns reservation capacity, execution -// resources and the immutable deployment release it enrolled with. -func (r *Registry) validateSpecification(c Config) error { - adapter, err := r.Lookup(c.Provider) - if err != nil { - return err - } - if adapter.Mode != "nodes" { - return fmt.Errorf("%w: selected provider does not support node hosting", sandbox.ErrInvalid) - } - if c.Generation == 0 { - return errors.New("node requires a deployment generation; obtain configuration from Core") - } - if err := r.ValidateSpecification(c.Provider, c.Specification); err != nil { - return err - } - release := c.Specification.Runtime - if d := c.Docker; d != nil { - if d.Image != release.ImageID && d.Image != release.ImageManifestDigest { - return errors.New("Docker Runtime image differs from the deployment release") - } - } - if m := c.Microsandbox; m != nil { - s := c.Specification.Resources - if uint32(m.CPUs) != s.CPUs || m.MemoryMiB != s.MemoryMiB || m.RootDiskMiB != s.RootDiskMiB || m.EnvironmentDiskMiB != s.EnvironmentDiskMiB { - return errors.New("microsandbox CPU, memory or disk limits differ from the deployment specification") - } - if m.Image != release.MicrosandboxRef || m.RuntimeSHA256 != release.RuntimeSHA256 || m.FirmwareSHA256 != release.FirmwareSHA256 { - return errors.New("microsandbox Runtime or firmware differs from the deployment release") - } - } - return nil -} diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index fd5600501..624f7e126 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -301,6 +301,40 @@ type DirectConfig struct { Fence *CallFence } +// NodeConfig is a node's configuration for one deployment generation. Native +// holds only the selected adapter's node-local settings, such as host paths; +// that adapter alone decodes it, strictly. Resources and the Runtime release +// are read from Specification, never copied into Native. +type NodeConfig struct { + Specification DeploymentSpec `json:"specification"` + Generation uint64 `json:"generation"` + CoreURL string `json:"core_url"` + Provider string `json:"provider"` + InstallationID string `json:"installation_id"` + Native json.RawMessage `json:"native"` +} + +// LocalOptions supplies process-local context without changing persisted configuration. +type LocalOptions struct { + // Standalone selects registration or execution without a generation manager. + Standalone bool + // GenerationStateDirectory is the node state directory when constructing a + // retained generation. It must be canonical and absolute, and Standalone + // must be false. + GenerationStateDirectory string +} + +// Built is a constructed node adapter. Construction fills InstallationID and +// SpecificationDigest; the adapter fills the rest. +type Built struct { + SpecificationDigest string + Provider SandboxProvider + InstallationID, BackendFingerprint string + Probe func(context.Context) error + // Quiescent is nil when no helper can outlive its caller. + Quiescent func() bool +} + // ConfigurationDiscoveryInput is a transient read-only request. Query is typed // and validated by the adapter; it cannot select a compute mutation. type ConfigurationDiscoveryInput struct {