From bfbf2750c614028f4d399f3aa7d2737d6d853734 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 15:31:58 +0000 Subject: [PATCH] Fix E2B hosted startup on fresh installations A real E2B run on a fresh Compose installation found two adapter faults that together kept every E2B Session from starting: - Nothing created the adapter's private /state/e2b since the Compose installer replaced the Python one, so selecting E2B failed with 503 execution_unavailable. The adapter now creates the directory (0700) when Core loads the Provider. - The E2B API omits the domain of a sandbox on the configured one. SDK Sandbox objects fill it in, SandboxInfo leaves None, so every check of GetInfo (create qualification, inspect, renew, resume) rejected an owned sandbox as foreign. An omitted domain now counts as the configured one; a reported foreign domain is still refused. --- .../sandbox/e2b/installed_paths_test.go | 24 +++++++++++++++++++ .../core/internal/sandbox/e2b/provider.go | 4 ++++ services/core/tools/e2b-provider/README.md | 4 ++-- services/core/tools/e2b-provider/provider.py | 4 +++- .../core/tools/e2b-provider/provider_test.py | 7 ++++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/services/core/internal/sandbox/e2b/installed_paths_test.go b/services/core/internal/sandbox/e2b/installed_paths_test.go index 9d4d4af86..181fc65fc 100644 --- a/services/core/internal/sandbox/e2b/installed_paths_test.go +++ b/services/core/internal/sandbox/e2b/installed_paths_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" ) func TestInstalledPathsRejectMissingAndRelativeRoots(t *testing.T) { @@ -43,3 +44,26 @@ func TestConfigurationDiscoveryUsesSuppliedProcessPaths(t *testing.T) { t.Fatalf("missing roots: %v", err) } } + +func TestBuildDirectCreatesItsPrivateStateDirectory(t *testing.T) { + paths := sandbox.ProcessPaths{ArtifactRoot: t.TempDir(), StateRoot: t.TempDir()} + binary, state, 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\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + selection := sandbox.Selection{Provider: "e2b", Configuration: &DeploymentConfiguration{APIKey: "synthetic-key", Template: "test:" + uuid.NewString()}} + for range 2 { + if _, err := BuildDirect(sandbox.DirectConfig{ProcessPaths: paths, InstallationID: uuid.NewString(), Selection: selection}); err != nil { + t.Fatal(err) + } + } + if info, err := os.Stat(state); err != nil || info.Mode().Perm() != 0700 { + t.Fatalf("state directory: %v %v", info, err) + } +} diff --git a/services/core/internal/sandbox/e2b/provider.go b/services/core/internal/sandbox/e2b/provider.go index ef4e2dc50..e9431f444 100644 --- a/services/core/internal/sandbox/e2b/provider.go +++ b/services/core/internal/sandbox/e2b/provider.go @@ -167,6 +167,10 @@ func newDirect(c sandbox.DirectConfig) (*Provider, error) { if err != nil { return nil, err } + // The adapter owns its private directory under the state root. + if err := os.Mkdir(state, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return nil, errors.New("E2B provider cannot create its private state directory") + } // Only a candidate that omitted its resources has none; its validation // reads them from the template build before the candidate is rebuilt. var resources *sandbox.Resources diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md index 131cc60d7..053ae426e 100644 --- a/services/core/tools/e2b-provider/README.md +++ b/services/core/tools/e2b-provider/README.md @@ -13,7 +13,7 @@ The account key is stored encrypted in Core's database and is write-only. It rea | Helper operation | Core use | Behavior | | --- | --- | --- | | `create` | `Create` | Creates the sandbox once and runs `managed_init.py`; see [Create](#create) | -| `inspect` | `GetInfo` | Reads the sandbox by recorded ID, or by ownership metadata when no ID is recorded, and checks ownership, domain, template and resources | +| `inspect` | `GetInfo` | Reads the sandbox by recorded ID, or by ownership metadata when no ID is recorded, and checks ownership, domain (an omitted domain is the configured one), template and resources | | `renew` | `Renew` | Extends the lease of the running sandbox to the configured timeout, then rereads it | | `kill` | `Kill` | Destroys every matching sandbox and confirms that none remains | | `command` | `RunCommand` | Runs one bounded command as the Runtime user on a running sandbox whose bootstrap completed; output is limited to 1 MiB per stream | @@ -40,7 +40,7 @@ The request and credentials arrive on standard input; standard output carries on ## Receipts and state directory -The state directory is the fixed `/state/e2b` ([private Provider state](../../../../docs/configuration.md#compose-installations)). It must be owned by the helper's user and grant no group or other access, and it lives on durable private storage across Core restarts. Its receipts hold SDK connection material, ownership identities and one-shot creation claims; they are not Core execution state. Losing the directory cannot authorize recreation or successful cleanup; never delete receipts after an uncertain call. +The state directory is the fixed `/state/e2b` ([private Provider state](../../../../docs/configuration.md#compose-installations)), which Core creates when it loads the Provider. It must be owned by the helper's user and grant no group or other access, and it lives on durable private storage across Core restarts. Its receipts hold SDK connection material, ownership identities and one-shot creation claims; they are not Core execution state. Losing the directory cannot authorize recreation or successful cleanup; never delete receipts after an uncertain call. A helper holds its allocation's lock until the SDK operation returns, even after Core's caller times out. Core tracks the helper's actual exit, and a credential change waits for running helpers without killing them. Helper exit never proves that a remote Create settled. Core serializes lifecycle requests per allocation and never replays Create. diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index cb0464334..050debbbe 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -145,7 +145,9 @@ def owns(self, cloud): def check_domain(self, cloud): domain = self.options()['domain'] - sandbox_domain = cloud.sandbox_domain + # The API may omit the domain of a sandbox on the configured one: SDK + # Sandbox objects then fill in that domain, SandboxInfo leaves None. + sandbox_domain = domain if cloud.sandbox_domain is None else cloud.sandbox_domain if not isinstance(sandbox_domain, str) or not ( sandbox_domain == domain or sandbox_domain.endswith('.' + domain)): raise Failure('ownership') diff --git a/services/core/tools/e2b-provider/provider_test.py b/services/core/tools/e2b-provider/provider_test.py index 41db53950..de49699d7 100644 --- a/services/core/tools/e2b-provider/provider_test.py +++ b/services/core/tools/e2b-provider/provider_test.py @@ -143,6 +143,13 @@ def test_custom_endpoint_reaches_unknown_create_discovery(self): self.assertEqual(self.api.list.call_args.kwargs['api_url'], self.config['APIURL']) self.assertEqual(self.api.list.call_args.kwargs['domain'], self.config['Domain']) + def test_info_without_a_domain_is_on_the_configured_one(self): + self.api.get_info.return_value = SimpleNamespace( + sandbox_id='owned-id', sandbox_domain=None, state='running', metadata=self.cloud.metadata, + template_id=self.cloud.template_id, cpu_count=self.cloud.cpu_count, memory_mb=self.cloud.memory_mb) + self.assertEqual(self.call('create')['ErrorCode'], '') + self.assertEqual(self.call('renew')['ErrorCode'], '') + def test_create_refuses_foreign_data_plane_before_envd(self): self.cloud.sandbox_domain = 'foreign.example' self.assertEqual(self.call('create')['ErrorCode'], 'ownership')