diff --git a/deploy/distribution/Dockerfile b/deploy/distribution/Dockerfile index 3a89e1cc6..146473acd 100644 --- a/deploy/distribution/Dockerfile +++ b/deploy/distribution/Dockerfile @@ -12,7 +12,6 @@ COPY e2b/ /opt/oac/e2b/ COPY native-installers/ /opt/oac/native-installers/ ENV OAC_ADDR=:8091 -ENV OAC_E2B_PROVIDER_BIN=/opt/oac/e2b/oac-e2b-provider EXPOSE 8091 USER 65532:65532 CMD ["/usr/local/bin/oac-core"] diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index fb73ada62..0b1d83aaf 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -207,14 +207,13 @@ def core_environment(root, config, state): "OAC_CORE_KEY_DIGESTS_FILE": generated + "/core-key-digests.json", "OAC_INSTALLATION_ID": state["installation_id"], "OAC_SETTINGS_FILE": generated + "/settings.json", - "OAC_E2B_STATE_DIR": str(root / "state/e2b") if native else "/state/e2b", + "OAC_PROVIDER_ROOT": str(root / "native") if native else "/opt/oac", + "OAC_PROVIDER_STATE_ROOT": str(root / "state") if native else "/state", "OAC_DEFAULT_HARNESS": core["default_harness"], "OAC_HARNESSES": ",".join(core["harnesses"]), "OAC_EXECUTION_CONCURRENCY": str(core["execution_concurrency"]), "OAC_WRITE_AUDIT_RETENTION": core["write_audit_retention"], } - if native: - result["OAC_E2B_PROVIDER_BIN"] = str(root / "native/e2b/oac-e2b-provider") if (root / "native-installers/catalog.json").is_file(): result["OAC_NATIVE_INSTALLER_DIR"] = str(root / "native-installers") if native else "/opt/oac/native-installers" if core["oauth_trusted_origins"]: diff --git a/deploy/install/node_generations.py b/deploy/install/node_generations.py index 0a74896d7..738c9b765 100644 --- a/deploy/install/node_generations.py +++ b/deploy/install/node_generations.py @@ -45,7 +45,7 @@ def helper_archive(args, installer): with tempfile.TemporaryDirectory() as directory: package = Path(directory) source_dir = Path(installer.__file__).parent - for name in ("node_install.py", "node_spec.py", "distribution.py", "node_generations.py", "install_display.py", "node_output.py"): + for name in ("node_install.py", "node_spec.py", "distribution.py", "node_generations.py", "install_display.py", "node_output.py", "provider_assets.py"): shutil.copyfile(source_dir / name, package / ("__main__.py" if name == "node_install.py" else name)) archive = io.BytesIO() zipapp.create_archive(package, archive, compressed=True) @@ -124,8 +124,8 @@ def marker_identity(args): def record_root_runtime(root, args, manifest, sums, installer): checksums = {"runtime/seccomp.json": sums["runtime/seccomp.json"]} - if args.provider == "microsandbox": - checksums.update({name: installer.distribution.artifact(manifest, name)["sha256"] for name in installer.MICRO}) + checksums.update({name: installer.distribution.artifact(manifest, name)["sha256"] + for name in installer.provider_assets.artifacts(args.provider, ("runtime",))}) archive = installer.distribution.artifact(manifest, "images/runtime.tar.gz") checksums["images/runtime.tar.gz"] = archive["sha256"] checksums["images/runtime.tar"] = archive["unpacked_sha256"] @@ -330,7 +330,7 @@ def runtime_files(root, value, args, manifest, sums, installer): raise installer.InstallError("Retained Runtime artifacts are outside this installation") installer.no_links(release) installer.safe_directory(release) - names = ("runtime/seccomp.json",) + (installer.MICRO if args.provider == "microsandbox" else ()) + names = installer.provider_assets.artifacts(args.provider, ("policy", "runtime")) saved = installer.private_json(release / "manifest.json") if (release / "manifest.json").exists(): if saved is None: diff --git a/deploy/install/node_install.py b/deploy/install/node_install.py index 1d74b90f9..62cc7b31f 100644 --- a/deploy/install/node_install.py +++ b/deploy/install/node_install.py @@ -33,6 +33,7 @@ import uuid import distribution +import provider_assets import node_spec import node_generations import install_display @@ -47,9 +48,7 @@ class RuntimeDownloadError(InstallError): """A fixed private helper exit category for transfer/provenance failures.""" -COMMON = ("native/bin/oac-node", "runtime/seccomp.json") -MICRO = ("native/bin/oac-microsandbox-provider", "native/microsandbox/msb", - "native/microsandbox/libkrunfw.so.5.6.1") +MICRO = provider_assets.artifacts("microsandbox", ("runtime",)) DOCKER = ("docker", "--host", "unix:///var/run/docker.sock") DOCKER_SOCKET = Path("/var/run/docker.sock") KVM = Path("/dev/kvm") @@ -211,7 +210,7 @@ def read(name): or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest.get("images", {}).get("runtime", ""))): raise RuntimeDownloadError("Unsupported node distribution") distribution.image_identities(manifest, "runtime") - for name in (COMMON[0], "images/runtime.tar.gz") + MICRO: + for name in dict.fromkeys(item["path"] for items in provider_assets.CATALOG.values() for item in items if item["role"] != "policy"): distribution.artifact(manifest, name) # Metadata stays on the console; artifact requests may redirect to its pinned release. manifest["artifact_base_url"] = source + "/node-install/releases/" + manifest["source_commit"] + "/artifacts" if source else "" @@ -392,7 +391,7 @@ def register_node(root, args, token, helper_archive=None): manifest, sums = metadata(args.source_url, prefix="releases/" + selected["source_commit"] + "/") node_spec.verify_release(args.configuration, manifest) runtime_prefix = "releases/" + manifest["source_commit"] + "/" - names = COMMON + (MICRO if args.provider == "microsandbox" else ()) + names = provider_assets.artifacts(args.provider, ("node", "runtime", "policy")) if "runtime/seccomp.json" not in sums: raise InstallError("The distribution is missing required node checksums") state = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url, @@ -414,7 +413,7 @@ def register_node(root, args, token, helper_archive=None): target = root / name safe_directory(target.parent) existing_file(target) - distribution.obtain_artifact(program_manifest if name == COMMON[0] else manifest, name, target, getattr(args, "bundle", None)) + distribution.obtain_artifact(program_manifest if name in provider_assets.artifacts(args.provider, ("node",)) else manifest, name, target, getattr(args, "bundle", None)) os.chmod(target, 0o700) node_generations.install_helper(root, args, sys.modules[__name__], helper_archive) safe_directory(root / "state/node") @@ -439,7 +438,7 @@ def register_node(root, args, token, helper_archive=None): secret.write(token) install_display.step("Registering this node with Core") try: - checked([str(root / COMMON[0]), "register", "--config", str(root / "provider.json"), "--state-dir", str(root / "state/node"), + checked([str(root / provider_assets.artifacts(args.provider, ("node",))[0]), "register", "--config", str(root / "provider.json"), "--state-dir", str(root / "state/node"), "--core-url", args.core_url, "--name", socket.gethostname(), "--enrollment-token-file", secret_path], REGISTRATION_UNCONFIRMED, explain=registration_failure) except AddressChanged: @@ -948,7 +947,7 @@ def quote(value): # service runs with the account's own primary group. return ("[Unit]\nDescription=OpenAgentCore sandbox node " + root.name + "\nWants=network-online.target\nAfter=" + after + "\nStartLimitIntervalSec=0\n\n[Service]\nType=exec\nUser=" + SERVICE_USER - + "\nExecStart=:" + quote(root / COMMON[0]) + " run --config " + quote(root / "provider.json") + + "\nExecStart=:" + quote(root / provider_assets.artifacts(provider, ("node",))[0]) + " run --config " + quote(root / "provider.json") + " --state-dir " + quote(root / "state/node") + "\nWorkingDirectory=" + str(root).replace("%", "%%") + "\nRestart=on-failure\nRestartSec=5s\nRestartPreventExitStatus=78\nKillMode=process\nUMask=0077" + "\n\n[Install]\nWantedBy=multi-user.target\n") diff --git a/deploy/install/provider_assets.py b/deploy/install/provider_assets.py new file mode 100644 index 000000000..d64a3aea8 --- /dev/null +++ b/deploy/install/provider_assets.py @@ -0,0 +1,7 @@ +# Code generated by go run ./services/core/cmd/provider-artifacts -write; DO NOT EDIT. +import json + +CATALOG = json.loads("{\n \"docker\": [\n {\n \"path\": \"native/bin/oac-node\",\n \"suffix\": \"sandbox-node\",\n \"role\": \"node\"\n },\n {\n \"path\": \"images/runtime.tar.gz\",\n \"suffix\": \"runtime.tar.gz\",\n \"role\": \"image\"\n },\n {\n \"path\": \"runtime/seccomp.json\",\n \"suffix\": \"seccomp.json\",\n \"role\": \"policy\"\n }\n ],\n \"microsandbox\": [\n {\n \"path\": \"native/bin/oac-node\",\n \"suffix\": \"sandbox-node\",\n \"role\": \"node\"\n },\n {\n \"path\": \"images/runtime.tar.gz\",\n \"suffix\": \"runtime.tar.gz\",\n \"role\": \"image\"\n },\n {\n \"path\": \"runtime/seccomp.json\",\n \"suffix\": \"seccomp.json\",\n \"role\": \"policy\"\n },\n {\n \"path\": \"native/bin/oac-microsandbox-provider\",\n \"suffix\": \"microsandbox-provider\",\n \"role\": \"runtime\"\n },\n {\n \"path\": \"native/microsandbox/msb\",\n \"suffix\": \"msb\",\n \"role\": \"runtime\"\n },\n {\n \"path\": \"native/microsandbox/libkrunfw.so.5.6.1\",\n \"suffix\": \"libkrunfw.so.5.6.1\",\n \"role\": \"runtime\"\n }\n ]\n}\n") + +def artifacts(provider, roles=None): + return tuple(item["path"] for item in CATALOG[provider] if roles is None or item["role"] in roles) diff --git a/deploy/install/test_config_model.py b/deploy/install/test_config_model.py index 25f28433a..9b4376c22 100644 --- a/deploy/install/test_config_model.py +++ b/deploy/install/test_config_model.py @@ -19,6 +19,17 @@ def schemas(node): class ConfigModelTests(unittest.TestCase): + def test_provider_roots_follow_the_installed_layout(self): + root = Path("/installation") + for native, artifacts, state in ((False, "/opt/oac", "/state"), + (True, "/installation/native", "/installation/state")): + with self.subTest(native=native): + config = config_model.initial("all", native, **({"ports.database": 15432} if native else {})) + environment = configuration.core_environment(root, config, {"installation_id": "fixture"}) + self.assertEqual(environment["OAC_PROVIDER_ROOT"], artifacts) + self.assertEqual(environment["OAC_PROVIDER_STATE_ROOT"], state) + + def test_schema_uses_only_the_supported_keyword_subset(self): for node in schemas(config_model.SCHEMA): self.assertLessEqual(set(node), config_model.KEYWORDS) diff --git a/deploy/install/test_node_helper_transfer.py b/deploy/install/test_node_helper_transfer.py index 40e869d72..14516c94b 100644 --- a/deploy/install/test_node_helper_transfer.py +++ b/deploy/install/test_node_helper_transfer.py @@ -31,7 +31,7 @@ def test_captured_archive_survives_removal_of_private_source(self): self.assertEqual(helper.read_bytes(), captured) self.assertEqual(stat.S_IMODE(helper.stat().st_mode), 0o600) with zipfile.ZipFile(helper) as archive: - self.assertEqual(set(archive.namelist()), {"__main__.py", "node_spec.py", "distribution.py", "node_generations.py", "install_display.py", "node_output.py"}) + self.assertEqual(set(archive.namelist()), {"__main__.py", "node_spec.py", "distribution.py", "node_generations.py", "install_display.py", "node_output.py", "provider_assets.py"}) self.assertEqual(json.loads((root / "preparation.json").read_text()), {"source_url": "https://core.example"}) @unittest.skipUnless(hasattr(os, "fork") and os.geteuid() == 0, "requires a disposable Linux root test environment") diff --git a/deploy/install/test_node_install.py b/deploy/install/test_node_install.py index 87a59bfef..afbea2a90 100644 --- a/deploy/install/test_node_install.py +++ b/deploy/install/test_node_install.py @@ -57,7 +57,7 @@ def setUp(self): "image_manifest_digests": {"runtime": "sha256:" + "c" * 64}, "runtime_ref": "oac-runtime@sha256:" + "c" * 64, "microsandbox": {"runtime_sha256": "d" * 64, "firmware_sha256": "e" * 64}} - self.payloads = {name: b"fixture-payload-" + name.encode() for name in installer.COMMON + installer.MICRO} + self.payloads = {name: b"fixture-payload-" + name.encode() for name in installer.provider_assets.artifacts("microsandbox", ("node", "policy", "runtime"))} self.payloads["images/runtime.tar.gz"] = gzip.compress(b"runtime archive") self.refresh_manifest() self.containerd = False @@ -169,7 +169,7 @@ def install_current_program_with_retained_runtime(self, provider): program = copy.deepcopy(self.manifest) program["source_commit"] = new_source payloads = dict(self.payloads) - payloads[installer.COMMON[0]] = b"new protocol program from current release" + payloads[installer.provider_assets.artifacts("docker", ("node",))[0]] = b"new protocol program from current release" for name, artifact in program["artifacts"].items(): artifact["filename"] = artifact["filename"].replace(old_source, new_source) artifact["size"] = len(payloads[name]) @@ -191,12 +191,12 @@ def artifact_response(request, **_kwargs): for name, item in manifest["artifacts"].items(): if url == prefix + item["filename"]: # Only the node executable comes from the host release. - self.assertEqual(manifest["source_commit"], new_source if name == installer.COMMON[0] else old_source) + self.assertEqual(manifest["source_commit"], new_source if name == installer.provider_assets.artifacts("docker", ("node",))[0] else old_source) return Response(files[name]) raise AssertionError("Unexpected immutable artifact URL " + url) with mock.patch.object(installer, "fetch", side_effect=fetch), mock.patch.object(installer.distribution.urllib.request, "build_opener", return_value=mock.Mock(open=artifact_response)): self.install() - self.assertEqual((self.root / installer.COMMON[0]).read_bytes(), payloads[installer.COMMON[0]]) + self.assertEqual((self.root / installer.provider_assets.artifacts("docker", ("node",))[0]).read_bytes(), payloads[installer.provider_assets.artifacts("docker", ("node",))[0]]) config = json.loads((self.root / "provider.json").read_text()) self.assertEqual(config["specification"]["runtime"], node_spec.release(self.manifest)) self.assertEqual(json.loads((self.root / "registered.json").read_text())["source_commit"], old_source) @@ -220,7 +220,7 @@ def test_bundle_without_selected_runtime_refuses_before_payload_or_registration( with self.assertRaisesRegex(installer.InstallError, "does not contain Core's selected Runtime"): self.install() self.assertFalse((self.root / "installation.json").exists()) - self.assertFalse((self.root / installer.COMMON[0]).exists()) + self.assertFalse((self.root / installer.provider_assets.artifacts("docker", ("node",))[0]).exists()) self.assertFalse(any("register" in command or "load" in command or "enable" in command for command, _ in self.calls)) def test_missing_retained_runtime_never_falls_back_to_current_runtime(self): @@ -253,7 +253,7 @@ def test_original_runtime_gc_interruption_preserves_restart_identity_and_success self.args.provider = "microsandbox" self.install() successor = self.prepare_successor_runtime() - keep = (installer.COMMON[0], "generation-preparer.pyz", "provider.json", "registered.json", + keep = (installer.provider_assets.artifacts("docker", ("node",))[0], "generation-preparer.pyz", "provider.json", "registered.json", "installation.json", "preparation.json", "runtime-artifacts.json", "state/node/identity.json") before = {name: (self.root / name).read_bytes() for name in keep} original = installer.private_json(self.root / "provider.json") @@ -337,7 +337,7 @@ def inventory(command, *_args, **_kwargs): installer.node_generations.collect(self.args, installer) self.assertFalse((self.root / installer.MICRO[2]).exists()) self.assertTrue((self.root / "provider.json").exists()) - self.assertTrue((self.root / installer.COMMON[0]).exists()) + self.assertTrue((self.root / installer.provider_assets.artifacts("docker", ("node",))[0]).exists()) def test_original_runtime_gc_preserves_exact_shared_native_file_references(self): self.args.provider = "microsandbox" @@ -528,7 +528,7 @@ def test_changed_public_url_clears_unregistered_state_for_a_new_command(self): self.install() for name in ("installation.json", "provider.json", "state/node/identity.json", "registered.json"): self.assertFalse((self.root / name).exists(), name) - self.assertTrue((self.root / installer.COMMON[0]).is_file()) + self.assertTrue((self.root / installer.provider_assets.artifacts("docker", ("node",))[0]).is_file()) self.register_stderr = None self.args.core_url = "https://core-new.example" self.install() @@ -566,15 +566,15 @@ def test_corrupt_manifest_and_payload_fail_before_provider_use(self): with self.assertRaisesRegex(installer.InstallError, "manifest checksum"): self.install() self.refresh_manifest() - self.payloads[installer.COMMON[0]] += b"corrupt" + self.payloads[installer.provider_assets.artifacts("docker", ("node",))[0]] += b"corrupt" with self.assertRaisesRegex(installer.distribution.DistributionError, "published size|checksum"): self.install() self.assertFalse(self.calls) - self.assertFalse((self.root / installer.COMMON[0]).exists()) + self.assertFalse((self.root / installer.provider_assets.artifacts("docker", ("node",))[0]).exists()) def test_installed_payload_and_config_are_not_overwritten(self): self.install() - target = self.root / installer.COMMON[0] + target = self.root / installer.provider_assets.artifacts("docker", ("node",))[0] target.write_bytes(b"existing-different-payload") with self.assertRaisesRegex(installer.distribution.DistributionError, "Cached artifact differs"): self.install() diff --git a/docs/configuration.md b/docs/configuration.md index a001d67af..852f5070d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,8 +149,8 @@ Core reads only its environment. The installer renders `generated/core.env` from | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | | `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json`: the [`core.runtime_history`](#settings) object, when it is set | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | -| `OAC_E2B_STATE_DIR` | `state/e2b/`: an absolute directory owned by Core's user, with no group or other access. Back it up with the database and `credential.key`; don't mount it into Web or a Runtime | -| `OAC_E2B_PROVIDER_BIN` | The E2B helper: `/opt/oac/e2b/oac-e2b-provider` in the Core image, `native/e2b/oac-e2b-provider` for native Core. The E2B key and template live in the database | +| `OAC_PROVIDER_ROOT` | Absolute adapter artifact root: `native/` for native Core, `/opt/oac` in the Core image. Each adapter owns its helper paths beneath this root | +| `OAC_PROVIDER_STATE_ROOT` | Absolute private state root: `state/` for native Core, `/state` in the Core image. Each adapter owns its subdirectory; E2B uses `e2b/`, owned by Core's user with no group or other access. Back it up with the database and `credential.key`; don't mount it into Web or a Runtime | | `OAC_NATIVE_INSTALLER_DIR` | `native-installers/`, served to self-hosted machines; the Core image has a copy at `/opt/oac/native-installers`, used when this is unset. Core checks the catalog against its own release before serving it | Core logs the file paths it loads, never environment values or file contents. diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 52facfed7..0d08b8d6d 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -136,6 +136,12 @@ Vendor deployment validation and SDK setup stay at the construction boundary, an 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. + +The launcher supplies `sandbox.ProcessPaths` from the [derived process environment](configuration.md). Core reads these paths once and passes them to direct construction and configuration discovery. 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. + ## Managed lifecycle This is what Core does around every provider. Adapters implement none of it, but they rely on it. diff --git a/internal/providerassets/artifacts.go b/internal/providerassets/artifacts.go new file mode 100644 index 000000000..2f7dc0482 --- /dev/null +++ b/internal/providerassets/artifacts.go @@ -0,0 +1,26 @@ +// Package providerassets exposes the generated node distribution declarations. +package providerassets + +import ( + _ "embed" + "encoding/json" +) + +// Artifact identifies an immutable distribution payload and its installation role. +type Artifact struct { + Path string `json:"path"` + Suffix string `json:"suffix"` + Role string `json:"role"` +} + +//go:embed catalog.json +var catalogJSON []byte + +// Catalog returns an independent copy of the adapter-owned declarations. +func Catalog() map[string][]Artifact { + var catalog map[string][]Artifact + if err := json.Unmarshal(catalogJSON, &catalog); err != nil { + panic(err) + } + return catalog +} diff --git a/internal/providerassets/catalog.json b/internal/providerassets/catalog.json new file mode 100644 index 000000000..233ed30dd --- /dev/null +++ b/internal/providerassets/catalog.json @@ -0,0 +1,51 @@ +{ + "docker": [ + { + "path": "native/bin/oac-node", + "suffix": "sandbox-node", + "role": "node" + }, + { + "path": "images/runtime.tar.gz", + "suffix": "runtime.tar.gz", + "role": "image" + }, + { + "path": "runtime/seccomp.json", + "suffix": "seccomp.json", + "role": "policy" + } + ], + "microsandbox": [ + { + "path": "native/bin/oac-node", + "suffix": "sandbox-node", + "role": "node" + }, + { + "path": "images/runtime.tar.gz", + "suffix": "runtime.tar.gz", + "role": "image" + }, + { + "path": "runtime/seccomp.json", + "suffix": "seccomp.json", + "role": "policy" + }, + { + "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" + } + ] +} diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 4af28f2f2..31bfd32c8 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -98,8 +98,9 @@ if [[ "$(go env GOVERSION)" != "$required_go" ]]; then printf 'Distribution build requires %s\n' "$required_go" >&2 exit 1 fi +go run ./services/core/cmd/provider-artifacts for file in install.sh install.py install_output.py install_display.py node_output.py configuration.py config_model.py config.schema.json ingress.py ingress_config.py oac_cli.py \ - native_service.py native_installers.py node_install.py node_spec.py node_generations.py sandbox_setup.py distribution.py \ + native_service.py native_installers.py node_install.py provider_assets.py node_spec.py node_generations.py sandbox_setup.py distribution.py \ model_provider_sessions.py; do cp "deploy/install/$file" "$bundle/$file" done diff --git a/scripts/build-core.sh b/scripts/build-core.sh index c79565f8f..8785f80a3 100755 --- a/scripts/build-core.sh +++ b/scripts/build-core.sh @@ -26,7 +26,7 @@ tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/runtimefs internal/runtimebootstrap internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/obs/log services/core \ + internal/runtimefs internal/runtimebootstrap internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/providerassets internal/obs/log services/core \ | tar -C "$build_context" -xf - ( diff --git a/scripts/build-web.sh b/scripts/build-web.sh index d5874ce27..8ddd6dacf 100755 --- a/scripts/build-web.sh +++ b/scripts/build-web.sh @@ -22,7 +22,7 @@ trap 'rm -rf "$build_context"' EXIT mkdir -p "$build_context/tmp" export GOTMPDIR="$build_context/tmp" # Keep the independent console build separate from API and frontend sources. -tar -C "$repo_root" -cf - go.mod go.sum internal/obs/log services/web \ +tar -C "$repo_root" -cf - go.mod go.sum internal/obs/log internal/providerassets services/web \ | tar -C "$build_context" -xf - ( cd "$build_context" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 114af72c7..828b7e43c 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -19,15 +19,13 @@ RUNTIME_ARCHIVE_SHA256 = "47c223e3ef5298abf05f47ed9f87981106e400d99bb3f1d042d4d6881346b18b" DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") -ARTIFACTS = { - "images/runtime.tar.gz": "runtime.tar.gz", - "native/bin/oac-node": "sandbox-node", - "native/bin/oac-daemon": "daemon", - "native/bin/oac-microsandbox-provider": "microsandbox-provider", - "native/microsandbox/msb": "msb", - "native/microsandbox/libkrunfw.so.5.6.1": "libkrunfw.so.5.6.1", - "runtime/seccomp.json": "seccomp.json", -} +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "deploy/install")) +import provider_assets + +ARTIFACTS = {item["path"]: item["suffix"] for items in provider_assets.CATALOG.values() for item in items} +# The standalone daemon is a distribution artifact, independent of node providers. +ARTIFACTS["native/bin/oac-daemon"] = "daemon" + # The docs a distribution carries, by repository path. Links between them stay @@ -287,7 +285,7 @@ def bootstraps(bundle, epoch, revision): bundle = pathlib.Path(bundle) with tempfile.TemporaryDirectory(dir=bundle.parent) as directory: modules = (("node_install.py", "__main__.py"), ("distribution.py", "distribution.py"), - *((name, name) for name in ("node_spec.py", "node_generations.py", "install_display.py", "node_output.py"))) + *((name, name) for name in ("node_spec.py", "node_generations.py", "install_display.py", "node_output.py", "provider_assets.py"))) for original, packaged in modules: target = pathlib.Path(directory) / packaged shutil.copyfile(bundle / original, target) diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 6589e3967..aca3d2b66 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -266,7 +266,7 @@ def test_release_location_and_explicit_offline_requirements(self): self.assertEqual(json.loads((self.bundle / "manifest.json").read_text())["artifact_base_url"], "") def test_bootstraps_include_shared_downloader_and_are_reproducible(self): - for name in ("node_install.py", "node_generations.py", "install_display.py", "node_output.py", *distribution.OAC_CLI_MODULES): + for name in ("node_install.py", "node_generations.py", "install_display.py", "node_output.py", "provider_assets.py", *distribution.OAC_CLI_MODULES): (self.bundle / name).write_text("# " + name + "\n") distribution.bootstraps(self.bundle, "1700000000", "a" * 40) first = [(self.bundle / name).read_bytes() for name in ("node-install.pyz", "oac.pyz")] @@ -277,7 +277,7 @@ def test_bootstraps_include_shared_downloader_and_are_reproducible(self): self.assertEqual(set(contents.namelist()), {"__main__.py", *distribution.OAC_CLI_MODULES}) self.assertIn(("oac_cli.SOURCE_COMMIT = " + repr("a" * 40)).encode(), contents.read("__main__.py")) with zipfile.ZipFile(self.bundle / "node-install.pyz") as contents: - self.assertEqual(set(contents.namelist()), {"__main__.py", "node_spec.py", "distribution.py", "node_generations.py", "install_display.py", "node_output.py"}) + self.assertEqual(set(contents.namelist()), {"__main__.py", "node_spec.py", "distribution.py", "node_generations.py", "install_display.py", "node_output.py", "provider_assets.py"}) self.assertEqual(contents.read("__main__.py"), (self.bundle / "node_install.py").read_bytes()) diff --git a/services/core/cmd/provider-artifacts/main.go b/services/core/cmd/provider-artifacts/main.go new file mode 100644 index 000000000..2b9e9de8f --- /dev/null +++ b/services/core/cmd/provider-artifacts/main.go @@ -0,0 +1,42 @@ +// provider-artifacts generates distribution declarations from registered adapters. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" +) + +func main() { + write := flag.Bool("write", false, "write generated declarations from the repository root") + flag.Parse() + catalog, err := providers.ArtifactCatalog() + if err != nil { + panic(err) + } + raw, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + panic(err) + } + raw = append(raw, '\n') + python := []byte("# Code generated by go run ./services/core/cmd/provider-artifacts -write; DO NOT EDIT.\nimport json\n\nCATALOG = json.loads(" + strconv.Quote(string(raw)) + ")\n\ndef artifacts(provider, roles=None):\n return tuple(item[\"path\"] for item in CATALOG[provider] if roles is None or item[\"role\"] in roles)\n") + for path, content := range map[string][]byte{"internal/providerassets/catalog.json": raw, "deploy/install/provider_assets.py": python} { + if *write { + err = os.WriteFile(path, content, 0644) + } else { + var current []byte + current, err = os.ReadFile(path) + if err == nil && string(current) != string(content) { + err = fmt.Errorf("stale generated file: %s", path) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } +} diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 2f56bbedf..202fb1258 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -23,6 +23,7 @@ package main import ( "context" + "encoding/json" "errors" "net/http" "os" @@ -43,6 +44,7 @@ import ( historystoreresolver "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory/storeresolver" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" observationstoreresolver "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs/storeresolver" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/jackc/pgx/v5/pgxpool" @@ -186,7 +188,9 @@ func run() error { options := []api.Option{api.WithCoreMetrics(metrics), api.WithSubagents(executionStore), api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore), api.WithRuntimeObservations(observationService)} if managedNodes != nil { options = append(options, api.WithSandboxManager(executionStore, managedNodes.admin)) - options = append(options, api.WithSandboxConfigurationDiscovery(providers.DiscoverConfiguration)) + options = append(options, api.WithSandboxConfigurationDiscovery(func(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { + return providers.DiscoverConfiguration(ctx, kind, input, managedNodes.setup.processPaths) + })) } var keyAdmin *api.DeploymentAuthenticator if managedNodes != nil { diff --git a/services/core/cmd/server/managed_generations_test.go b/services/core/cmd/server/managed_generations_test.go index 75e18c83c..aca3167eb 100644 --- a/services/core/cmd/server/managed_generations_test.go +++ b/services/core/cmd/server/managed_generations_test.go @@ -33,8 +33,8 @@ func (s *routingSetupStore) GetSandboxAllocationSetup(_ context.Context, ref san return value, nil } func TestE2BRouterKeepsOldSpecificationWithCommittedCredential(t *testing.T) { - state := t.TempDir() - if err := os.Chmod(state, 0700); err != nil { + state := filepath.Join(t.TempDir(), "e2b") + if err := os.MkdirAll(state, 0700); err != nil { t.Fatal(err) } helper := filepath.Join(t.TempDir(), "helper") @@ -49,8 +49,7 @@ print(json.dumps({'Version':1,'Info':info})) if err := os.WriteFile(helper, []byte(script), 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_E2B_PROVIDER_BIN", helper) - t.Setenv("OAC_E2B_STATE_DIR", state) + paths := testProviderPaths(t, helper, state) id := uuid.NewString() old := store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "old-key", Template: "old:" + uuid.NewString()}} current := old @@ -59,7 +58,7 @@ print(json.dumps({'Version':1,'Info':info})) current.Configuration = &e2b.DeploymentConfiguration{APIKey: "new-key", Template: "new:" + uuid.NewString()} ref := sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} db := &routingSetupStore{setupStore: setupStore{value: current}, old: old, oldID: ref.AllocationID} - setup := &managedSetup{store: db, installationID: id} + setup := &managedSetup{processPaths: paths, store: db, installationID: id} // A facade retained by a generation-one lifecycle still reads current credentials. router := &generationRouter{setup: setup, store: db} ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -122,8 +121,8 @@ func TestE2BReplacementRequiresCommittedOwnershipAnchor(t *testing.T) { {name: "explicit same key", committedKey: "team-a", committedTemplate: "owned-a", candidateKey: "team-a", candidateTemplate: "owned-a"}, } { t.Run(tc.name, func(t *testing.T) { - state := t.TempDir() - if err := os.Chmod(state, 0700); err != nil { + state := filepath.Join(t.TempDir(), "e2b") + if err := os.MkdirAll(state, 0700); err != nil { t.Fatal(err) } helper := filepath.Join(t.TempDir(), "provider") @@ -147,14 +146,13 @@ print(json.dumps(result)) if err := os.WriteFile(helper, []byte(script), 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_E2B_PROVIDER_BIN", helper) - t.Setenv("OAC_E2B_STATE_DIR", state) + paths := testProviderPaths(t, helper, state) id, build := uuid.NewString(), ":"+uuid.NewString() current := store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: tc.committedKey, Template: tc.committedTemplate + build}} db := &setupStore{value: current} - s := &managedSetup{installationID: id, store: db} + s := &managedSetup{processPaths: paths, installationID: id, store: db} loaded, err := s.load(t.Context()) if err != nil { t.Fatal(err) diff --git a/services/core/cmd/server/managed_nodes.go b/services/core/cmd/server/managed_nodes.go index b7743bf50..5dfbe10c1 100644 --- a/services/core/cmd/server/managed_nodes.go +++ b/services/core/cmd/server/managed_nodes.go @@ -91,7 +91,7 @@ func configureManagedNodes(s *store.Store, publicURL string, owner func(context. return s.HeartbeatRuntimeNode(ctx, n.NodeID, connection, epoch, nodeHealthRecord(health)) }, }) - result.setup = &managedSetup{store: s, hub: result.hub, installationID: setupID, publicURL: publicURL} + result.setup = &managedSetup{processPaths: providerProcessPaths(), store: s, hub: result.hub, installationID: setupID, publicURL: publicURL} result.runtime = execution.NewDeferredRuntimeProvider(setupID, result.setup.load, result.setup.prepare) result.runtime.PublishUnconfigured = result.setup.publishUnconfigured success = true diff --git a/services/core/cmd/server/managed_setup.go b/services/core/cmd/server/managed_setup.go index f23661b0a..cd6f55055 100644 --- a/services/core/cmd/server/managed_setup.go +++ b/services/core/cmd/server/managed_setup.go @@ -20,7 +20,8 @@ import ( // managedSetup publishes one immutable selection to execution, bootstrap and // observation. The database owns the selection; this cache is never a writer. type managedSetup struct { - store interface { + processPaths sandbox.ProcessPaths + store interface { GetSandboxSetup(context.Context) (store.SandboxSetup, error) ResolveRuntimeGeneration(context.Context, sandbox.Reference) (string, uint64, error) } @@ -191,5 +192,5 @@ func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.SandboxProvid } return s.hub.GenerationProvider(setup.Provider, s.store.ResolveRuntimeGeneration), nil } - return providers.BuildDirect(providers.DirectConfig{InstallationID: setup.InstallationID, Selection: sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, Configuration: setup.Configuration}, Fence: &s.providerCalls}) + return providers.BuildDirect(providers.DirectConfig{ProcessPaths: s.processPaths, InstallationID: setup.InstallationID, Selection: sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, Configuration: setup.Configuration}, Fence: &s.providerCalls}) } diff --git a/services/core/cmd/server/managed_setup_preflight_test.go b/services/core/cmd/server/managed_setup_preflight_test.go index cc9e98477..ceee02d7f 100644 --- a/services/core/cmd/server/managed_setup_preflight_test.go +++ b/services/core/cmd/server/managed_setup_preflight_test.go @@ -21,14 +21,13 @@ func TestE2BRejectedSpecificationHasSafeActionableDiagnostic(t *testing.T) { if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"invalid\"}'\n"), 0700); err != nil { t.Fatal(err) } - state := t.TempDir() - if err := os.Chmod(state, 0700); err != nil { + state := filepath.Join(t.TempDir(), "e2b") + if err := os.MkdirAll(state, 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_E2B_PROVIDER_BIN", helper) - t.Setenv("OAC_E2B_STATE_DIR", state) + paths := testProviderPaths(t, helper, state) id := uuid.NewString() - s := &managedSetup{installationID: id} + s := &managedSetup{processPaths: paths, installationID: id} selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 3, MemoryMiB: 3072}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} _, err := s.prepare(t.Context(), selection) if !errors.Is(err, sandbox.ErrConfigurationSelection) || strings.Contains(err.Error(), "synthetic-private-key") || s.selected.Load() != nil { @@ -59,17 +58,16 @@ else: if err := os.WriteFile(helper, []byte(script), 0700); err != nil { t.Fatal(err) } - state := t.TempDir() - if err := os.Chmod(state, 0700); err != nil { + state := filepath.Join(t.TempDir(), "e2b") + if err := os.MkdirAll(state, 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_E2B_PROVIDER_BIN", helper) - t.Setenv("OAC_E2B_STATE_DIR", state) + paths := testProviderPaths(t, helper, state) id := uuid.NewString() selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} - s := &managedSetup{installationID: id, store: &setupStore{value: selection}} + s := &managedSetup{processPaths: paths, installationID: id, store: &setupStore{value: selection}} if _, err := s.prepare(t.Context(), selection); err == nil || s.selected.Load() != nil { t.Fatal("invalid new template selection was published", err) } @@ -94,8 +92,8 @@ else: } func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) { - state := t.TempDir() - if err := os.Chmod(state, 0700); err != nil { + state := filepath.Join(t.TempDir(), "e2b") + if err := os.MkdirAll(state, 0700); err != nil { t.Fatal(err) } helper := filepath.Join(t.TempDir(), "provider") @@ -104,10 +102,9 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) { if err := os.WriteFile(helper, []byte(script), 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_E2B_PROVIDER_BIN", helper) - t.Setenv("OAC_E2B_STATE_DIR", state) + paths := testProviderPaths(t, helper, state) id := uuid.NewString() - s := &managedSetup{installationID: id, store: &setupStore{}} + s := &managedSetup{processPaths: paths, installationID: id, store: &setupStore{}} selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} candidate, err := s.prepare(t.Context(), selection) disk := int32(24063) @@ -136,14 +133,13 @@ func TestInitialE2BPublicTemplateOutsideTeamIsRejected(t *testing.T) { if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"team_mismatch\"}'\n"), 0700); err != nil { t.Fatal(err) } - state := t.TempDir() - if err := os.Chmod(state, 0700); err != nil { + state := filepath.Join(t.TempDir(), "e2b") + if err := os.MkdirAll(state, 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_E2B_PROVIDER_BIN", helper) - t.Setenv("OAC_E2B_STATE_DIR", state) + paths := testProviderPaths(t, helper, state) id := uuid.NewString() - s := &managedSetup{installationID: id, store: &setupStore{}} + s := &managedSetup{processPaths: paths, installationID: id, store: &setupStore{}} selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-team-a", Template: "public-team-b:" + uuid.NewString()}} if _, err := s.prepare(t.Context(), selection); !errors.Is(err, sandbox.ErrCredentialOwnership) || s.selected.Load() != nil { t.Fatal("public readability accepted as team ownership", err) diff --git a/services/core/cmd/server/managed_setup_test.go b/services/core/cmd/server/managed_setup_test.go index 4157ac3ab..ea6cef8ea 100644 --- a/services/core/cmd/server/managed_setup_test.go +++ b/services/core/cmd/server/managed_setup_test.go @@ -83,9 +83,7 @@ func TestManagedSetupNeverReusesAnotherGenerationOrUnverifiedState(t *testing.T) func TestMissingE2BHelperReportsProviderUnavailable(t *testing.T) { id := uuid.NewString() - t.Setenv("OAC_E2B_PROVIDER_BIN", filepath.Join(t.TempDir(), "missing-helper")) - t.Setenv("OAC_E2B_STATE_DIR", t.TempDir()) - s := &managedSetup{installationID: id, store: &setupStore{value: store.SandboxSetup{ + s := &managedSetup{processPaths: sandbox.ProcessPaths{ArtifactRoot: t.TempDir(), StateRoot: t.TempDir()}, installationID: id, store: &setupStore{value: store.SandboxSetup{ InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}, }}} @@ -121,9 +119,7 @@ func TestManagedSetupPreparesWithoutPublishing(t *testing.T) { func TestManagedSetupRejectedCandidateRetainsSelection(t *testing.T) { id := uuid.NewString() - t.Setenv("OAC_E2B_PROVIDER_BIN", filepath.Join(t.TempDir(), "missing-helper")) - t.Setenv("OAC_E2B_STATE_DIR", t.TempDir()) - s := &managedSetup{installationID: id} + s := &managedSetup{processPaths: sandbox.ProcessPaths{ArtifactRoot: t.TempDir(), StateRoot: t.TempDir()}, installationID: id} previous := &execution.RuntimeProvider{InstallationID: id, Generation: 1, ProviderKind: "docker"} s.publish(previous) _, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", @@ -203,3 +199,23 @@ func (s *setupStore) SandboxCredentialAllocationPage(context.Context, string) ([ func (s *setupStore) ResolveRuntimeGeneration(context.Context, sandbox.Reference) (string, uint64, error) { return "", 0, errors.New("unexpected node generation lookup") } + +func testProviderPaths(t *testing.T, helper, state string) sandbox.ProcessPaths { + t.Helper() + paths := sandbox.ProcessPaths{ArtifactRoot: t.TempDir(), StateRoot: filepath.Dir(state)} + binary, resolvedState, err := e2b.InstalledPaths(paths) + if err != nil || resolvedState != state { + t.Fatalf("provider layout: %v", err) + } + if err := os.MkdirAll(filepath.Dir(binary), 0700); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(helper) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binary, raw, 0700); err != nil { + t.Fatal(err) + } + return paths +} diff --git a/services/core/cmd/server/process_configuration.go b/services/core/cmd/server/process_configuration.go index a488e4673..e7f1d6924 100644 --- a/services/core/cmd/server/process_configuration.go +++ b/services/core/cmd/server/process_configuration.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -58,3 +59,7 @@ func logConfigurationSources() { } } } + +func providerProcessPaths() sandbox.ProcessPaths { + return sandbox.ProcessPaths{ArtifactRoot: os.Getenv("OAC_PROVIDER_ROOT"), StateRoot: os.Getenv("OAC_PROVIDER_STATE_ROOT")} +} diff --git a/services/core/internal/sandbox/configuration.go b/services/core/internal/sandbox/configuration.go index 3227c8a90..e0fbc5cc6 100644 --- a/services/core/internal/sandbox/configuration.go +++ b/services/core/internal/sandbox/configuration.go @@ -67,7 +67,7 @@ type ConfigurationDiscoveryInput struct { // ConfigurationDiscoverer is separate from compute and candidate admission. // Support must also be explicitly declared in ConfigurationRequirements. type ConfigurationDiscoverer interface { - DiscoverConfiguration(context.Context, ConfigurationDiscoveryInput) (json.RawMessage, error) + DiscoverConfiguration(context.Context, ConfigurationDiscoveryInput, ProcessPaths) (json.RawMessage, error) } // DecodeConfigurationObject rejects unknown fields, null, nonobjects and trailing diff --git a/services/core/internal/sandbox/e2b/configuration_discovery.go b/services/core/internal/sandbox/e2b/configuration_discovery.go index 81d03df28..e3902e66c 100644 --- a/services/core/internal/sandbox/e2b/configuration_discovery.go +++ b/services/core/internal/sandbox/e2b/configuration_discovery.go @@ -4,13 +4,12 @@ import ( "context" "encoding/json" "errors" - "os" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (json.RawMessage, error) { var connection struct { APIURL string `json:"api_url"` Domain string `json:"domain"` @@ -24,9 +23,9 @@ func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input san if sandbox.DecodeConfigurationObject(input.Configuration, &connection, "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(input.Credential, &credential, "api_key") != nil || sandbox.DecodeConfigurationObject(input.Query, &query, "template") != nil { return nil, sandbox.ErrInvalid } - binary := os.Getenv("OAC_E2B_PROVIDER_BIN") - if binary == "" { - binary = "/opt/oac/e2b/oac-e2b-provider" + binary, _, err := InstalledPaths(paths) + if err != nil { + return nil, sandbox.ErrConfigurationUnconfirmed } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() diff --git a/services/core/internal/sandbox/e2b/installed_paths.go b/services/core/internal/sandbox/e2b/installed_paths.go new file mode 100644 index 000000000..69d25200d --- /dev/null +++ b/services/core/internal/sandbox/e2b/installed_paths.go @@ -0,0 +1,16 @@ +package e2b + +import ( + "fmt" + "path/filepath" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +// InstalledPaths resolves the adapter's fixed distribution layout. +func InstalledPaths(paths sandbox.ProcessPaths) (string, string, error) { + if !filepath.IsAbs(paths.ArtifactRoot) || !filepath.IsAbs(paths.StateRoot) { + return "", "", fmt.Errorf("%w: provider artifact and state roots must be absolute", sandbox.ErrInvalid) + } + return filepath.Join(paths.ArtifactRoot, "e2b", "oac-e2b-provider"), filepath.Join(paths.StateRoot, "e2b"), nil +} diff --git a/services/core/internal/sandbox/e2b/installed_paths_test.go b/services/core/internal/sandbox/e2b/installed_paths_test.go new file mode 100644 index 000000000..5012e0d97 --- /dev/null +++ b/services/core/internal/sandbox/e2b/installed_paths_test.go @@ -0,0 +1,45 @@ +package e2b + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func TestInstalledPathsRejectMissingAndRelativeRoots(t *testing.T) { + for _, paths := range []sandbox.ProcessPaths{{}, {ArtifactRoot: "/opt/oac"}, {StateRoot: "/state"}, {ArtifactRoot: "relative", StateRoot: "/state"}, {ArtifactRoot: "/opt/oac", StateRoot: "relative"}} { + if _, _, err := InstalledPaths(paths); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatalf("invalid paths accepted: %v", err) + } + } + binary, state, err := InstalledPaths(sandbox.ProcessPaths{ArtifactRoot: "/opt/oac", StateRoot: "/state"}) + if err != nil || binary != "/opt/oac/e2b/oac-e2b-provider" || state != "/state/e2b" { + t.Fatalf("wrong paths: %s %s %v", binary, state, err) + } +} + +func TestConfigurationDiscoveryUsesSuppliedProcessPaths(t *testing.T) { + paths := sandbox.ProcessPaths{ArtifactRoot: t.TempDir(), StateRoot: t.TempDir()} + binary, _, err := InstalledPaths(paths) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(binary), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binary, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"Templates\":[]}'\n"), 0700); err != nil { + t.Fatal(err) + } + input := sandbox.ConfigurationDiscoveryInput{Credential: json.RawMessage(`{"api_key":"synthetic-key"}`)} + result, err := (ConfigurationAdapter{}).DiscoverConfiguration(t.Context(), input, paths) + if err != nil || string(result) != `{"templates":[]}` { + t.Fatalf("configured discovery: %s %v", result, err) + } + if _, err := (ConfigurationAdapter{}).DiscoverConfiguration(t.Context(), input, sandbox.ProcessPaths{}); !errors.Is(err, sandbox.ErrConfigurationUnconfirmed) { + t.Fatalf("missing roots: %v", err) + } +} diff --git a/services/core/internal/sandbox/providers/artifacts.go b/services/core/internal/sandbox/providers/artifacts.go new file mode 100644 index 000000000..e6b70b936 --- /dev/null +++ b/services/core/internal/sandbox/providers/artifacts.go @@ -0,0 +1,68 @@ +package providers + +import ( + "fmt" + "path" + "regexp" + + "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" +) + +var nodeProgram = providerassets.Artifact{Path: "native/bin/oac-node", Suffix: "sandbox-node", Role: "node"} +var runtimeImage = providerassets.Artifact{Path: "images/runtime.tar.gz", Suffix: "runtime.tar.gz", Role: "image"} +var runtimePolicy = providerassets.Artifact{Path: "runtime/seccomp.json", Suffix: "seccomp.json", Role: "policy"} + +// ArtifactCatalog projects registered node requirements for Web and installers. +func ArtifactCatalog() (map[string][]providerassets.Artifact, error) { + result := map[string][]providerassets.Artifact{} + paths := map[string]providerassets.Artifact{} + suffixes := map[string]string{} + for kind := range adapters { + a, err := Lookup(kind) + if err != nil { + return nil, err + } + if a.Mode == "nodes" { + for _, item := range a.NodeArtifacts { + previous, exists := paths[item.Path] + if exists && previous != item || suffixes[item.Suffix] != "" && suffixes[item.Suffix] != item.Path { + return nil, fmt.Errorf("%w: conflicting node artifact declarations", providercontract.ErrContract) + } + paths[item.Path], suffixes[item.Suffix] = item, item.Path + } + + result[kind] = append([]providerassets.Artifact(nil), a.NodeArtifacts...) + } + } + return result, nil +} + +var artifactPath = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`) +var artifactSuffix = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) + +func validateNodeArtifacts(items []providerassets.Artifact) error { + invalid := fmt.Errorf("%w: invalid node artifact declaration", providercontract.ErrContract) + if len(items) == 0 { + return invalid + } + seen := map[string]bool{} + nodes := 0 + for _, item := range items { + if !artifactPath.MatchString(item.Path) || path.Clean(item.Path) != item.Path || !artifactSuffix.MatchString(item.Suffix) || seen[item.Path] { + return invalid + } + switch item.Role { + case "node": + nodes++ + case "runtime", "policy", "image": + default: + return invalid + } + seen[item.Path] = true + } + if nodes != 1 { + return invalid + } + return nil +} diff --git a/services/core/internal/sandbox/providers/artifacts_test.go b/services/core/internal/sandbox/providers/artifacts_test.go new file mode 100644 index 000000000..9e1fed379 --- /dev/null +++ b/services/core/internal/sandbox/providers/artifacts_test.go @@ -0,0 +1,89 @@ +package providers + +import ( + "encoding/json" + "errors" + "os" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" +) + +func TestArtifactProjectionsMatchRegistrations(t *testing.T) { + catalog, err := ArtifactCatalog() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(catalog, providerassets.Catalog()) { + t.Fatal("stale Web artifact projection; regenerate provider-artifacts") + } + raw, err := os.ReadFile("../../../../../deploy/install/provider_assets.py") + if err != nil { + t.Fatal(err) + } + _, body, found := strings.Cut(string(raw), "CATALOG = json.loads(") + if !found { + t.Fatal("missing Python artifact projection") + } + encoded, _, _ := strings.Cut(body, ")\n") + text, err := strconv.Unquote(encoded) + if err != nil { + t.Fatal(err) + } + var python map[string][]providerassets.Artifact + if err := json.Unmarshal([]byte(text), &python); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(catalog, python) { + t.Fatal("stale installer artifact projection; regenerate provider-artifacts") + } +} + +func TestNodeArtifactRegistrationRejectsInvalidDeclarations(t *testing.T) { + for _, mutate := range []func(*Adapter){ + func(a *Adapter) { a.NodeArtifacts = nil }, + func(a *Adapter) { a.NodeArtifacts[0].Path = "../private" }, + func(a *Adapter) { a.NodeArtifacts[0].Path = "." }, + func(a *Adapter) { a.NodeArtifacts[0].Path = ".." }, + func(a *Adapter) { a.NodeArtifacts[0].Suffix = ".." }, + func(a *Adapter) { a.NodeArtifacts[0].Suffix = "bad\x00name" }, + func(a *Adapter) { a.NodeArtifacts[0].Suffix = "../private" }, + func(a *Adapter) { a.NodeArtifacts[0].Role = "unknown" }, + func(a *Adapter) { a.NodeArtifacts = append(a.NodeArtifacts, a.NodeArtifacts[0]) }, + } { + a := adapters["docker"] + a.NodeArtifacts = append([]providerassets.Artifact(nil), a.NodeArtifacts...) + mutate(&a) + if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) { + t.Fatalf("invalid artifacts accepted: %v", err) + } + } + const kind = "another-node-provider" + adapters[kind] = adapters["docker"] + defer delete(adapters, kind) + catalog, err := ArtifactCatalog() + if err != nil || !reflect.DeepEqual(catalog[kind], adapters[kind].NodeArtifacts) { + t.Fatalf("additional registration not projected: %v", err) + } +} + +func TestArtifactCatalogRejectsConflictingSharedFiles(t *testing.T) { + const kind = "conflicting-provider" + defer delete(adapters, kind) + for _, mutate := range []func(*providerassets.Artifact){ + func(item *providerassets.Artifact) { item.Suffix = "different" }, + func(item *providerassets.Artifact) { item.Path = "native/bin/other-node" }, + } { + a := adapters["docker"] + a.NodeArtifacts = append([]providerassets.Artifact(nil), a.NodeArtifacts...) + mutate(&a.NodeArtifacts[0]) + adapters[kind] = a + if _, err := ArtifactCatalog(); !errors.Is(err, providercontract.ErrContract) { + t.Fatalf("conflicting declaration accepted: %v", err) + } + } +} diff --git a/services/core/internal/sandbox/providers/configuration.go b/services/core/internal/sandbox/providers/configuration.go index de92c14d8..e4699998e 100644 --- a/services/core/internal/sandbox/providers/configuration.go +++ b/services/core/internal/sandbox/providers/configuration.go @@ -98,7 +98,7 @@ func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, erro owner.Configuration, e = a.Configuration.WithCredential(owner.Configuration, candidate.Configuration) return owner, e } -func DiscoverConfiguration(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func DiscoverConfiguration(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (json.RawMessage, error) { a, err := Lookup(kind) if err != nil { return nil, err @@ -110,7 +110,7 @@ func DiscoverConfiguration(ctx context.Context, kind string, input sandbox.Confi if !ok { return nil, providercontract.ErrContract } - return discovery.DiscoverConfiguration(ctx, input) + return discovery.DiscoverConfiguration(ctx, input, paths) } type nodeConfiguration struct{} @@ -167,6 +167,6 @@ func (a nodeConfigurationAdapter) Equal(x, y sandbox.Configuration) (bool, error } return true, nil } -func (nodeConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (nodeConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} } diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index 047db2689..a3cedd3e8 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -73,7 +73,7 @@ func (a regionalCodec) Equal(x, y sandbox.Configuration) (bool, error) { _, err = a.Encode(y) return x == y, err } -func (regionalCodec) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (regionalCodec) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} } diff --git a/services/core/internal/sandbox/providers/configuration_test.go b/services/core/internal/sandbox/providers/configuration_test.go index 215938ded..d032c4760 100644 --- a/services/core/internal/sandbox/providers/configuration_test.go +++ b/services/core/internal/sandbox/providers/configuration_test.go @@ -28,7 +28,7 @@ func TestNodeConfigurationExplicitUnsupportedAndStrictEmptyInput(t *testing.T) { if !ok { t.Fatal("missing explicit discovery implementation") } - if _, err := discover.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}); !errors.Is(err, providercontract.ErrUnsupported) { + if _, err := discover.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}); !errors.Is(err, providercontract.ErrUnsupported) { t.Fatal("discovery did not reject", err) } if _, err := a.Configuration.WithCredential(nil, nil); !errors.Is(err, providercontract.ErrUnsupported) { diff --git a/services/core/internal/sandbox/providers/e2b.go b/services/core/internal/sandbox/providers/e2b.go index 497b43df0..041046270 100644 --- a/services/core/internal/sandbox/providers/e2b.go +++ b/services/core/internal/sandbox/providers/e2b.go @@ -2,13 +2,13 @@ package providers import ( "errors" - "os" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" ) type DirectConfig struct { + ProcessPaths sandbox.ProcessPaths InstallationID string Selection sandbox.Selection Fence *sandbox.CallFence @@ -36,9 +36,9 @@ func buildE2B(c DirectConfig) (sandbox.SandboxProvider, error) { if !ok || configuration == nil { return nil, errors.New("E2B deployment configuration is unavailable") } - binary := os.Getenv("OAC_E2B_PROVIDER_BIN") - if binary == "" { - binary = "/opt/oac/e2b/oac-e2b-provider" + binary, state, err := e2b.InstalledPaths(c.ProcessPaths) + if err != nil { + return nil, err } // Only a candidate that omitted its resources has none; its validation // reads them from the template build before the candidate is rebuilt. @@ -46,7 +46,7 @@ func buildE2B(c DirectConfig) (sandbox.SandboxProvider, error) { if c.Selection.DeploymentSpec.Resources != (sandbox.Resources{}) { resources = &c.Selection.DeploymentSpec.Resources } - provider, err := e2b.NewWithCaller(e2b.Config{Binary: binary, StateDir: os.Getenv("OAC_E2B_STATE_DIR"), + provider, err := e2b.NewWithCaller(e2b.Config{Binary: binary, StateDir: state, Resources: resources, InstallationID: c.InstallationID, APIKey: configuration.APIKey, Template: configuration.Template, APIURL: configuration.APIURL, Domain: configuration.Domain, TimeoutSeconds: 3600}, &e2b.ProcessCaller{Fence: c.Fence}) if err != nil { diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 66ea85dde..712283e55 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -16,10 +16,16 @@ func ValidateRegistration(a Adapter) error { } switch a.Mode { case "nodes": + if err := validateNodeArtifacts(a.NodeArtifacts); err != nil { + return err + } if a.BuildLocal == nil || a.BuildDirect != nil { return invalid("node constructor") } case "direct": + if len(a.NodeArtifacts) != 0 { + return invalid("direct node artifacts") + } if a.BuildDirect == nil || a.BuildLocal != nil { return invalid("direct constructor") } diff --git a/services/core/internal/sandbox/providers/registration_configuration_test.go b/services/core/internal/sandbox/providers/registration_configuration_test.go index 760b1f704..b48300e64 100644 --- a/services/core/internal/sandbox/providers/registration_configuration_test.go +++ b/services/core/internal/sandbox/providers/registration_configuration_test.go @@ -136,10 +136,10 @@ func TestUnsupportedConfigurationDiscoveryMatchesAuthoredReason(t *testing.T) { native := a.Configuration.(sandbox.ConfigurationDiscoverer) for _, read := range []func() ([]byte, error){ func() ([]byte, error) { - return native.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}) + return native.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}) }, func() ([]byte, error) { - return DiscoverConfiguration(t.Context(), kind, sandbox.ConfigurationDiscoveryInput{}) + return DiscoverConfiguration(t.Context(), kind, sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}) }, } { result, err := read() diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 764eee16a..613c942cf 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -88,7 +88,7 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { {"decode", func() error { _, err := Decode(kind, sandbox.ConfigurationRecord{}); return err }}, {"equal", func() error { _, err := Equal(kind, nil, nil); return err }}, {"discovery", func() error { - _, err := DiscoverConfiguration(t.Context(), kind, sandbox.ConfigurationDiscoveryInput{}) + _, err := DiscoverConfiguration(t.Context(), kind, sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}) return err }}, {"resolve change", func() error { _, err := ResolveChange(selection, selection); return err }}, @@ -141,7 +141,7 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { closeProvider() // Direct providers may legitimately need no remote credential or extra // selection state; registration must not require irrelevant callback stubs. - a.Mode, a.BuildLocal = "direct", nil + a.Mode, a.BuildLocal, a.NodeArtifacts = "direct", nil, nil a.BuildDirect = func(DirectConfig) (sandbox.SandboxProvider, error) { calls++ return &docker.Provider{}, nil diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index f081704d6..8dac661e1 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" + "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -18,6 +19,7 @@ import ( // Adapter describes configuration and transport independently of compute operations. // Native operation support comes from the adapter-owned complete declaration. type Adapter struct { + NodeArtifacts []providerassets.Artifact Policy sandbox.DeploymentPolicy Configuration sandbox.ConfigurationAdapter BuildLocal func(Config, *Built) (func(), error) @@ -31,11 +33,16 @@ type Adapter struct { var adapters = map[string]Adapter{ "docker": { - Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: buildDocker, + NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, + Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: buildDocker, 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, IdleSeconds: 300, RetentionSeconds: 86400, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, diff --git a/services/core/internal/sandbox/providers/specification.go b/services/core/internal/sandbox/providers/specification.go index 414737e6a..f1d887937 100644 --- a/services/core/internal/sandbox/providers/specification.go +++ b/services/core/internal/sandbox/providers/specification.go @@ -2,6 +2,9 @@ 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 @@ -12,7 +15,7 @@ func validateSpecification(c Config) error { return err } if adapter.Mode != "nodes" { - return errors.New("nodes support Docker or microsandbox; E2B is managed by Core") + 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") diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 7af67ff43..663526b8e 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -107,3 +107,10 @@ type CheckpointProvider interface { // ResumeCompute thaws only the same resident instance after an aborted pause. ResumeCompute(context.Context, Reference, Compute) (ComputeState, error) } + +// ProcessPaths locates installed adapter helpers and their private state. The +// launcher supplies roots from the distribution layout; adapters own subpaths. +type ProcessPaths struct { + ArtifactRoot string + StateRoot string +} diff --git a/services/web/distribution_test.go b/services/web/distribution_test.go index b76fc158b..efe22cc62 100644 --- a/services/web/distribution_test.go +++ b/services/web/distribution_test.go @@ -68,7 +68,7 @@ func TestConsoleReportsServableNodeProviders(t *testing.T) { write(filepath.Join(dist, "index.html"), "console") write(filepath.Join(payload, "node-install.pyz"), "bootstrap") artifacts := map[string]any{} - for logical := range map[string]bool{"native/bin/oac-node": true, "images/runtime.tar.gz": true, "native/microsandbox/msb": true} { + for logical := range map[string]bool{"native/bin/oac-node": true, "images/runtime.tar.gz": true, "native/microsandbox/msb": true, "runtime/seccomp.json": true} { name := strings.ReplaceAll(logical, "/", "-") artifacts[logical] = map[string]any{"filename": name, "size": len("runtime-bytes")} write(filepath.Join(payload, "artifacts", name), "runtime-bytes") diff --git a/services/web/node_installation.go b/services/web/node_installation.go index 8ebd74582..f9bfb2518 100644 --- a/services/web/node_installation.go +++ b/services/web/node_installation.go @@ -9,7 +9,10 @@ import ( "net/http" "os" "regexp" + "sort" "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" ) // These are distribution artifacts, never installation configuration or secrets. @@ -19,13 +22,16 @@ var nodePayloadFiles = map[string]bool{ "manifest.json": true, "SHA256SUMS": true, "runtime/seccomp.json": true, } -// An offline distribution exposes only artifacts declared for these payloads. -var optionalPayloadFiles = map[string]bool{ - "native/bin/oac-node": true, "native/bin/oac-daemon": true, - "native/bin/oac-microsandbox-provider": true, - "native/microsandbox/msb": true, "native/microsandbox/libkrunfw.so.5.6.1": true, - "images/runtime.tar.gz": true, "runtime/seccomp.json": true, -} +// The generated adapter declarations bound the distribution payload endpoint. +var optionalPayloadFiles = func() map[string]bool { + files := map[string]bool{} + for _, artifacts := range providerassets.Catalog() { + for _, artifact := range artifacts { + files[artifact.Path] = true + } + } + return files +}() var payloadRevision = regexp.MustCompile(`^[0-9a-f]{40}$`) @@ -144,14 +150,6 @@ func (h *console) serveNodePayload(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, info.Name(), info.ModTime(), f) } -// providerArtifacts lists the artifacts a node of each provider downloads from -// this console, besides the fixed payload files. -var providerArtifacts = map[string][]string{ - "docker": {"native/bin/oac-node", "images/runtime.tar.gz"}, - "microsandbox": {"native/bin/oac-node", "images/runtime.tar.gz", "native/bin/oac-microsandbox-provider", - "native/microsandbox/msb", "native/microsandbox/libkrunfw.so.5.6.1"}, -} - // nodeArtifacts reports the providers whose node artifacts this console can serve: // each is present locally or has a pinned release download. Nodes verify every // checksum themselves. It is read per @@ -169,9 +167,10 @@ func (h *console) nodeArtifacts() []string { if err != nil { return available } - for _, provider := range []string{"docker", "microsandbox"} { + for provider, artifacts := range providerassets.Catalog() { complete := true - for _, logical := range providerArtifacts[provider] { + for _, artifact := range artifacts { + logical := artifact.Path entry, ok := manifest.Artifacts[logical] info, err := h.nodePayload.Stat(prefix + "artifacts/" + entry.Filename) local := err == nil && info.Mode().IsRegular() && info.Size() == entry.Size @@ -185,6 +184,7 @@ func (h *console) nodeArtifacts() []string { available = append(available, provider) } } + sort.Strings(available) return available }