From eef0a4f9af3cdd1db9db5973f2ee3391fcdf705c Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 01:02:56 +0800 Subject: [PATCH 1/5] refactor(sandbox): centralize provider configuration and construction --- CONTRIBUTING.md | 2 + apps/docs/content/docs/sandbox-provider.mdx | 57 ++++--- apps/docs/content/guide-sources.json | 4 +- .../src/features/sandbox/node-enrollment.ts | 2 +- docs/sandbox-provider.md | 57 ++++--- services/agents-api/IMPLEMENTATION.md | 5 +- .../cmd/sandbox-node/generations.go | 2 +- .../cmd/sandbox-node/generations_test.go | 4 +- services/agents-api/cmd/sandbox-node/main.go | 2 +- .../cmd/server/managed_generations.go | 73 ++++++--- .../cmd/server/managed_generations_test.go | 10 +- .../agents-api/cmd/server/managed_setup.go | 79 +++------- .../server/managed_setup_preflight_test.go | 14 +- .../cmd/server/managed_setup_test.go | 8 +- .../cmd/specification-contract/main.go | 4 +- .../internal/api/sandbox_deployment_setup.go | 2 +- .../db/queries/sandbox_deployment_setup.sql | 2 +- .../db/sqlc/sandbox_deployment_setup.sql.go | 2 +- .../archive_cancellation_cleanup_test.go | 8 +- .../execution/sandbox_deployment_setup.go | 27 ++-- .../sandbox_deployment_setup_test.go | 4 +- .../execution/sandbox_deployment_switch.go | 6 +- .../execution/sandbox_generations_test.go | 3 +- .../internal/execution/sandbox_reset_test.go | 3 +- .../execution/sandbox_snapshot_budget_test.go | 3 +- .../sandbox/{e2b/fence.go => call_fence.go} | 4 +- .../agents-api/internal/sandbox/deployment.go | 27 +--- .../internal/sandbox/deployment_contract.go | 17 +-- .../sandbox/deployment_validation_test.go | 57 ------- .../internal/sandbox/docker/provider.go | 2 +- .../internal/sandbox/docker/selection.go | 20 +++ .../internal/sandbox/e2b/deployment.go | 144 ++++++++++++++++++ .../internal/sandbox/e2b/process.go | 6 +- .../internal/sandbox/e2b/process_test.go | 4 +- .../internal/sandbox/e2b/provider.go | 2 +- .../e2b/selection_test.go} | 11 +- .../sandbox/microsandbox/deployment.go | 20 +++ .../internal/sandbox/node/identity.go | 3 +- .../agents-api/internal/sandbox/node/proxy.go | 5 +- .../sandbox/{config => providers}/capacity.go | 3 +- .../{config => providers}/capacity_linux.go | 5 +- .../{config => providers}/capacity_other.go | 3 +- .../sandbox/{config => providers}/config.go | 60 ++------ .../{config => providers}/config_test.go | 5 +- .../deployment_contract_test.go | 13 +- .../internal/sandbox/providers/docker.go | 46 ++++++ .../internal/sandbox/providers/e2b.go | 48 ++++++ .../{config => providers}/microsandbox.go | 10 +- .../sandbox/{config => providers}/probe.go | 2 +- .../{config => providers}/probe_test.go | 2 +- .../internal/sandbox/providers/registry.go | 144 ++++++++++++++++++ .../sandbox/providers/registry_test.go | 79 ++++++++++ .../{config => providers}/specification.go | 10 +- .../sandbox/providers/validation_test.go | 61 ++++++++ .../internal/sandbox/sandbox_provider.go | 2 +- .../agents-api/internal/sandbox/selection.go | 46 ++++++ .../admin_session_archive_worker_http_test.go | 2 +- .../store/archive_cancellation_test.go | 3 +- .../internal/store/runtime_node_deployment.go | 3 +- .../store/runtime_node_generations.go | 3 +- .../store/sandbox_deployment_mutations.go | 94 +++--------- .../store/sandbox_deployment_setup.go | 28 ++-- .../store/sandbox_deployment_switch_test.go | 3 +- .../sandbox_deployment_switch_worker_test.go | 2 +- .../store/sandbox_deployment_view_test.go | 2 +- .../internal/store/sandbox_generations.go | 35 ++--- .../internal/store/sandbox_specification.go | 24 ++- .../store/sandbox_specification_store_test.go | 26 ++++ .../000089_provider_registration.sql | 73 +++++++++ 69 files changed, 1056 insertions(+), 486 deletions(-) rename services/agents-api/internal/sandbox/{e2b/fence.go => call_fence.go} (95%) delete mode 100644 services/agents-api/internal/sandbox/deployment_validation_test.go create mode 100644 services/agents-api/internal/sandbox/docker/selection.go create mode 100644 services/agents-api/internal/sandbox/e2b/deployment.go rename services/agents-api/internal/{execution/sandbox_template_build_test.go => sandbox/e2b/selection_test.go} (55%) create mode 100644 services/agents-api/internal/sandbox/microsandbox/deployment.go rename services/agents-api/internal/sandbox/{config => providers}/capacity.go (95%) rename services/agents-api/internal/sandbox/{config => providers}/capacity_linux.go (95%) rename services/agents-api/internal/sandbox/{config => providers}/capacity_other.go (91%) rename services/agents-api/internal/sandbox/{config => providers}/config.go (50%) rename services/agents-api/internal/sandbox/{config => providers}/config_test.go (99%) rename services/agents-api/internal/sandbox/{ => providers}/deployment_contract_test.go (79%) create mode 100644 services/agents-api/internal/sandbox/providers/docker.go create mode 100644 services/agents-api/internal/sandbox/providers/e2b.go rename services/agents-api/internal/sandbox/{config => providers}/microsandbox.go (89%) rename services/agents-api/internal/sandbox/{config => providers}/probe.go (99%) rename services/agents-api/internal/sandbox/{config => providers}/probe_test.go (99%) create mode 100644 services/agents-api/internal/sandbox/providers/registry.go create mode 100644 services/agents-api/internal/sandbox/providers/registry_test.go rename services/agents-api/internal/sandbox/{config => providers}/specification.go (88%) create mode 100644 services/agents-api/internal/sandbox/providers/validation_test.go create mode 100644 services/agents-api/internal/sandbox/selection.go create mode 100644 services/agents-api/migrations/000089_provider_registration.sql diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15b0dc872..221b4a1c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -191,6 +191,8 @@ The Runtime is not a sandbox; see - Keep component boundaries explicit through shared interfaces and versioned protocols. Register implementations behind those interfaces. Adding an implementation must not require a new orchestration path selected by its name. + Sandbox registration, configuration adaptation and persistence boundaries follow + the [Sandbox Provider guide](docs/sandbox-provider.md#register-the-provider-kind). - Core owns durable Session/Turn state and scheduling. Runtime owns local execution resources. Harness adapters translate the common execution contract into native operations; model and sandbox provider details stay behind their diff --git a/apps/docs/content/docs/sandbox-provider.mdx b/apps/docs/content/docs/sandbox-provider.mdx index 8211efff1..057da9066 100644 --- a/apps/docs/content/docs/sandbox-provider.mdx +++ b/apps/docs/content/docs/sandbox-provider.mdx @@ -41,7 +41,7 @@ connectivity; see [Runtime and outer isolation](/concepts#runtime-and-outer-isol (native SDK calls, ownership checks, identity translation, private config). Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time. Out-of-process helpers live in `services/agents-api/tools/-provider`. -3. **Register the kind** in every file listed in +3. **Register the kind** once using [Register the provider kind](#register-the-provider-kind). Registration is explicit construction, not an init-time plugin registry. 4. **Label owned resources** with `io.oac.*` labels, or `oac_*` metadata keys @@ -171,25 +171,48 @@ A provider must not implement a competing preparation path. ## Register the provider kind -Provider kinds are enumerated explicitly. Add a new kind to each surface -below; none accepts an arbitrary provider string. - -| Surface | File | What it enumerates | -| --- | --- | --- | -| Node-local construction | [`sandbox/config/config.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/config/config.go), [`specification.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/config/specification.go) | Typed construction, readiness probe and node specification | -| Direct construction | [`cmd/server/managed_setup.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/cmd/server/managed_setup.go) (`managedSetup.provider`) | Direct adapters (E2B) and node-proxied kinds (Docker, microsandbox) | -| Resource validation | [`sandbox/deployment.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/deployment.go) (`Resources.Validate`) | Per-kind resource limits | -| Node proxy and identity | [`sandbox/node/proxy.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/node/proxy.go), [`node/identity.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/node/identity.go) | Node-capable kinds and their checkpoint capability | -| Deployment persistence | [`sandbox_deployment_mutations.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/sandbox_deployment_mutations.go), [`sandbox_deployment_setup.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/sandbox_deployment_setup.go), [`sandbox_specification.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/sandbox_specification.go), [`runtime_node_deployment.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/runtime_node_deployment.go) | Deployment admission and node-mode kinds | -| Database constraint | A new migration in [`migrations/`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/migrations) | `provider_kind` `CHECK` constraints, last set in `000081_sandbox_generations.sql`; never edit a landed migration | -| Node installation | [`node_installation.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core-console/node_installation.go) | Node artifacts per kind | -| Installer | [`deploy/install/sandbox_setup.py`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/sandbox_setup.py) (`CHOICES`, used by `install.py --sandbox`) | Installer backend choices | -| Distribution | [`scripts/core-distribution-manifest.py`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/scripts/core-distribution-manifest.py) | Bundled provider helper binaries | -| Clients and Web | [`sandbox-client.ts`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/agents-client/src/sandbox-client.ts) (`SandboxProvider`), [`features/sandbox`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/src/features/sandbox) (`SandboxSetupWizard.tsx`, `console-config.ts`, `node-enrollment.ts`), [`sandbox-labels.ts`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/src/lib/sandbox-labels.ts) | Backend choices and labels shown to operators | +`sandbox/providers/registry.go` is the sole registration table. Each entry binds +an adapter's specification/resource validators, selection normalization, deployment +mode, defaults, optional checkpoint capability, and local or direct constructor. +`providers.Build` constructs node-local adapters; `providers.BuildDirect` constructs +direct adapters. Neither allocates compute. There is no init-time registration or +runtime plugin loading. + +For a new implementation: + +1. Implement `SandboxProvider` in its adapter package and add native contract tests. +2. Add its configuration validators and optional read-only `SelectionDiscoverer` + for native resource discovery. Normalization must copy input before changing it. + `RestoreSelection` must retain access to owned resources without requiring new + template validation. Put native credential verification behind + `CredentialVerifier` when needed. +3. Register its constructor, policies and defaults in `providers/registry.go`. + Node proxy identity and checkpoint advertisement consume this same entry. +4. If new configuration fields are necessary, extend the typed `sandbox.Selection` + envelope and its dedicated encrypted persistence fields, API DTO and operator + client. Do not replace typed configuration with unrestricted JSON. Field codecs + may map columns; Store must not parse native endpoints, templates or defaults. +5. Supply required installer/distribution artifacts and operator labels. A new + provider must not add a Session/Turn scheduling path or a Store vendor switch. + +Preview and persistence use `providers.Normalize` and `providers.Describe`. +`SelectionDiscoverer` resolves omitted native resource values before commit; the +complete specification is validated again at persistence. Store owns transactions, +credential encryption, generation fencing, resource ownership and typed column +mapping. Database constraints validate structure, not the registration list. +`providers.ResolveChange` owns configuration inheritance and comparison uses +normalized selectors, so preview, retry and commit share the same defaults. + +A direct adapter with credentials verifies all retained generations and allocation +references before replacing a key. The common `sandbox.CallFence` excludes native +calls and waits for helper completion, including calls whose callers timed out. +Execution invokes prepared verification/fencing callbacks without branching on a +vendor. A transport wrapper advertises only capabilities that its adapter supports; +new optional capabilities need forwarding and qualification before registration. Keep vendor-specific deployment validation and SDK setup at the construction boundary. Construction must not create an Environment. For node-local adapters -the `Built` result returns the provider, probe, installation identity, backend +the `providers.Built` result returns the provider, probe, installation identity, backend fingerprint and specification digest; the factory also returns its close function. Preserve the `execution.RuntimeProvider` deployment binding: `ProviderKind`, diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 599bc1930..b38fbe41f 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -28,7 +28,7 @@ "contracts/agents-api/harness-onboarding.md": "18d3ccfe989e2caa57f220430157c6b73d87f5fc8e31aba8cada0b7ab9653a8c", "docs/runtime-bootstrap.md": "0d49aed73b298039e04453e6f465b0e925fb2227fa35d4206820bb3acbd8df39", "docs/runtime-protocol.md": "0e968e509236369dd4e840b94d9083372830c45b695ae3a71a56eba5bae01347", - "docs/sandbox-provider.md": "3def50dfeb69e5160124cb9c4577e107e6ca666b40e6da32843a1c33b2b3515b", + "docs/sandbox-provider.md": "912172c0c517ef00b521c2140983a2d40d4723ffec1e1239844f429fb2d29373", "apps/docs/scripts/guides.json": "3c3768fb94fd3d42464d4ce8c55724b9ba8360133c7cc19d513d8ec83a8e034f" }, "outputs": { @@ -59,6 +59,6 @@ "content/docs/harness-onboarding.mdx": "e7a172793d81e0dee89398ea34351617c66435a13f511f7fb29df3b6a8967fbf", "content/docs/runtime-bootstrap.mdx": "58982955811c9ad46a9fc04d8d2aef5a762fc9d25d5833f88aa75ee3fc46523f", "content/docs/runtime-protocol.mdx": "75e7c85a2085cccedf453e8ee751316ba2ee22ed0559e4018ee82df1af805f48", - "content/docs/sandbox-provider.mdx": "5472ccc9dc965c48716b6d73ff74263b4373bab6b41a8ae30040b6181d2de5c0" + "content/docs/sandbox-provider.mdx": "9c905ee047c6eeea321923f6b41baf6b55246d4ea0e3e5f0a43b7103dceffc9f" } } diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 6247d4176..c8e4eef41 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -26,7 +26,7 @@ export interface HostPrerequisite { * - Docker: `provider_group` needs rootful Docker Engine running, its socket * group-accessible (0660) and, through `device_group`, owned by the docker * group, which the service user joins; and CPU and memory limits enforced (the - * node is ready only then: services/agents-api/internal/sandbox/config/probe.go). + * node is ready only then: services/agents-api/internal/sandbox/providers/probe.go). * It installs nothing; * - microsandbox: `provider_group` needs /dev/kvm, readable and writable by all or * group-accessible in the kvm group, and `prepare_runtime` the libraries its diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 52b1cfc40..635a17d63 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -38,7 +38,7 @@ connectivity; see [Runtime and outer isolation](design-principles.md#runtime-and (native SDK calls, ownership checks, identity translation, private config). Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time. Out-of-process helpers live in `services/agents-api/tools/-provider`. -3. **Register the kind** in every file listed in +3. **Register the kind** once using [Register the provider kind](#register-the-provider-kind). Registration is explicit construction, not an init-time plugin registry. 4. **Label owned resources** with `io.oac.*` labels, or `oac_*` metadata keys @@ -168,25 +168,48 @@ A provider must not implement a competing preparation path. ## Register the provider kind -Provider kinds are enumerated explicitly. Add a new kind to each surface -below; none accepts an arbitrary provider string. - -| Surface | File | What it enumerates | -| --- | --- | --- | -| Node-local construction | [`sandbox/config/config.go`](../services/agents-api/internal/sandbox/config/config.go), [`specification.go`](../services/agents-api/internal/sandbox/config/specification.go) | Typed construction, readiness probe and node specification | -| Direct construction | [`cmd/server/managed_setup.go`](../services/agents-api/cmd/server/managed_setup.go) (`managedSetup.provider`) | Direct adapters (E2B) and node-proxied kinds (Docker, microsandbox) | -| Resource validation | [`sandbox/deployment.go`](../services/agents-api/internal/sandbox/deployment.go) (`Resources.Validate`) | Per-kind resource limits | -| Node proxy and identity | [`sandbox/node/proxy.go`](../services/agents-api/internal/sandbox/node/proxy.go), [`node/identity.go`](../services/agents-api/internal/sandbox/node/identity.go) | Node-capable kinds and their checkpoint capability | -| Deployment persistence | [`sandbox_deployment_mutations.go`](../services/agents-api/internal/store/sandbox_deployment_mutations.go), [`sandbox_deployment_setup.go`](../services/agents-api/internal/store/sandbox_deployment_setup.go), [`sandbox_specification.go`](../services/agents-api/internal/store/sandbox_specification.go), [`runtime_node_deployment.go`](../services/agents-api/internal/store/runtime_node_deployment.go) | Deployment admission and node-mode kinds | -| Database constraint | A new migration in [`migrations/`](../services/agents-api/migrations) | `provider_kind` `CHECK` constraints, last set in `000081_sandbox_generations.sql`; never edit a landed migration | -| Node installation | [`node_installation.go`](../services/core-console/node_installation.go) | Node artifacts per kind | -| Installer | [`deploy/install/sandbox_setup.py`](../deploy/install/sandbox_setup.py) (`CHOICES`, used by `install.py --sandbox`) | Installer backend choices | -| Distribution | [`scripts/core-distribution-manifest.py`](../scripts/core-distribution-manifest.py) | Bundled provider helper binaries | -| Clients and Web | [`sandbox-client.ts`](../packages/agents-client/src/sandbox-client.ts) (`SandboxProvider`), [`features/sandbox`](../apps/web/src/features/sandbox) (`SandboxSetupWizard.tsx`, `console-config.ts`, `node-enrollment.ts`), [`sandbox-labels.ts`](../apps/web/src/lib/sandbox-labels.ts) | Backend choices and labels shown to operators | +`sandbox/providers/registry.go` is the sole registration table. Each entry binds +an adapter's specification/resource validators, selection normalization, deployment +mode, defaults, optional checkpoint capability, and local or direct constructor. +`providers.Build` constructs node-local adapters; `providers.BuildDirect` constructs +direct adapters. Neither allocates compute. There is no init-time registration or +runtime plugin loading. + +For a new implementation: + +1. Implement `SandboxProvider` in its adapter package and add native contract tests. +2. Add its configuration validators and optional read-only `SelectionDiscoverer` + for native resource discovery. Normalization must copy input before changing it. + `RestoreSelection` must retain access to owned resources without requiring new + template validation. Put native credential verification behind + `CredentialVerifier` when needed. +3. Register its constructor, policies and defaults in `providers/registry.go`. + Node proxy identity and checkpoint advertisement consume this same entry. +4. If new configuration fields are necessary, extend the typed `sandbox.Selection` + envelope and its dedicated encrypted persistence fields, API DTO and operator + client. Do not replace typed configuration with unrestricted JSON. Field codecs + may map columns; Store must not parse native endpoints, templates or defaults. +5. Supply required installer/distribution artifacts and operator labels. A new + provider must not add a Session/Turn scheduling path or a Store vendor switch. + +Preview and persistence use `providers.Normalize` and `providers.Describe`. +`SelectionDiscoverer` resolves omitted native resource values before commit; the +complete specification is validated again at persistence. Store owns transactions, +credential encryption, generation fencing, resource ownership and typed column +mapping. Database constraints validate structure, not the registration list. +`providers.ResolveChange` owns configuration inheritance and comparison uses +normalized selectors, so preview, retry and commit share the same defaults. + +A direct adapter with credentials verifies all retained generations and allocation +references before replacing a key. The common `sandbox.CallFence` excludes native +calls and waits for helper completion, including calls whose callers timed out. +Execution invokes prepared verification/fencing callbacks without branching on a +vendor. A transport wrapper advertises only capabilities that its adapter supports; +new optional capabilities need forwarding and qualification before registration. Keep vendor-specific deployment validation and SDK setup at the construction boundary. Construction must not create an Environment. For node-local adapters -the `Built` result returns the provider, probe, installation identity, backend +the `providers.Built` result returns the provider, probe, installation identity, backend fingerprint and specification digest; the factory also returns its close function. Preserve the `execution.RuntimeProvider` deployment binding: `ProviderKind`, diff --git a/services/agents-api/IMPLEMENTATION.md b/services/agents-api/IMPLEMENTATION.md index d64dbe105..bd4cdc802 100644 --- a/services/agents-api/IMPLEMENTATION.md +++ b/services/agents-api/IMPLEMENTATION.md @@ -1443,9 +1443,8 @@ change guard. This boundary does not add cross-node Session migration, Core multi-active, autoscaling, Kubernetes or harness residency. The common -`services/agents-api/internal/sandbox` contract owns the four base operations -(Create, GetInfo, Renew, Kill) and the optional CheckpointProvider -capability. Core orchestration must not import an adapter or SDK. Exact compute +`services/agents-api/internal/sandbox` contract owns the five required operations and optional capabilities documented +in [the Provider guide](../../docs/sandbox-provider.md). Core orchestration must not import an adapter or SDK. Exact compute identity, generation construction, inspection, full snapshot capture, restore, thaw and owned artifact cleanup use that common capability. Initialization and execution use the authenticated Runtime peer. diff --git a/services/agents-api/cmd/sandbox-node/generations.go b/services/agents-api/cmd/sandbox-node/generations.go index 14197eda4..f6d09313d 100644 --- a/services/agents-api/cmd/sandbox-node/generations.go +++ b/services/agents-api/cmd/sandbox-node/generations.go @@ -15,8 +15,8 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/config" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" + providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" ) func runGenerations(ctx context.Context, configFile, stateDir string) error { diff --git a/services/agents-api/cmd/sandbox-node/generations_test.go b/services/agents-api/cmd/sandbox-node/generations_test.go index b0705496e..e1795ecb2 100644 --- a/services/agents-api/cmd/sandbox-node/generations_test.go +++ b/services/agents-api/cmd/sandbox-node/generations_test.go @@ -4,14 +4,14 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "os" "os/exec" "path/filepath" "syscall" "testing" - providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/config" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" ) func TestGenerationJournalRestartIdentity(t *testing.T) { diff --git a/services/agents-api/cmd/sandbox-node/main.go b/services/agents-api/cmd/sandbox-node/main.go index a1065e99e..d5836a3f8 100644 --- a/services/agents-api/cmd/sandbox-node/main.go +++ b/services/agents-api/cmd/sandbox-node/main.go @@ -17,8 +17,8 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/config" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" + providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" ) func main() { diff --git a/services/agents-api/cmd/server/managed_generations.go b/services/agents-api/cmd/server/managed_generations.go index 030584841..cb3ba82c2 100644 --- a/services/agents-api/cmd/server/managed_generations.go +++ b/services/agents-api/cmd/server/managed_generations.go @@ -8,7 +8,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -22,13 +22,13 @@ type generationStore interface { // Every facade, including already running lifecycles, resolves the allocation's // immutable specification and current credential. There is no mutable provider // map to unload and no current-generation fallback for a missing historical row. -type e2bGenerationRouter struct { +type generationRouter struct { setup *managedSetup store generationStore } -func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.SandboxProvider, func(), error) { - release, err := p.setup.e2bCalls.Enter(ctx) +func (p *generationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.SandboxProvider, func(), error) { + release, err := p.setup.providerCalls.Enter(ctx) if err != nil { return nil, nil, err } @@ -44,7 +44,7 @@ func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (s } return provider, release, nil } -func (p *e2bGenerationRouter) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { +func (p *generationRouter) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { v, done, err := p.route(ctx, b.Reference) if err != nil { return sandbox.Info{}, err @@ -52,7 +52,7 @@ func (p *e2bGenerationRouter) Create(ctx context.Context, b sandbox.Bootstrap) ( defer done() return v.Create(ctx, b) } -func (p *e2bGenerationRouter) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { +func (p *generationRouter) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { v, done, err := p.route(ctx, r) if err != nil { return sandbox.Info{}, err @@ -60,7 +60,7 @@ func (p *e2bGenerationRouter) GetInfo(ctx context.Context, r sandbox.Reference) defer done() return v.GetInfo(ctx, r) } -func (p *e2bGenerationRouter) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { +func (p *generationRouter) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { v, done, err := p.route(ctx, r) if err != nil { return sandbox.Info{}, err @@ -68,7 +68,7 @@ func (p *e2bGenerationRouter) Renew(ctx context.Context, r sandbox.Reference) (s defer done() return v.Renew(ctx, r) } -func (p *e2bGenerationRouter) Kill(ctx context.Context, r sandbox.Reference) error { +func (p *generationRouter) Kill(ctx context.Context, r sandbox.Reference) error { v, done, err := p.route(ctx, r) if err != nil { return err @@ -76,7 +76,7 @@ func (p *e2bGenerationRouter) Kill(ctx context.Context, r sandbox.Reference) err defer done() return v.Kill(ctx, r) } -func (p *e2bGenerationRouter) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { +func (p *generationRouter) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { v, done, err := p.route(ctx, r) if err != nil { return sandbox.CommandResult{}, err @@ -84,28 +84,47 @@ func (p *e2bGenerationRouter) RunCommand(ctx context.Context, r sandbox.Referenc defer done() return v.RunCommand(ctx, r, c) } -func (p *e2bGenerationRouter) Observe(ctx context.Context, t runtimeobs.Target) (runtimeobs.Sample, error) { + +type observedGenerationRouter struct{ *generationRouter } + +func (p *observedGenerationRouter) Observe(ctx context.Context, t runtimeobs.Target) (runtimeobs.Sample, error) { v, done, err := p.route(ctx, sandbox.Reference{TenantID: t.TenantID, EnvironmentID: t.EnvironmentID, AllocationID: t.Instance.AllocationID}) if err != nil { return runtimeobs.Sample{}, err } defer done() - return v.(*e2b.Provider).Observe(ctx, t) + source, ok := v.(runtimeobs.Source) + if !ok { + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + } + return source.Observe(ctx, t) } func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeployment, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) { - if setup.Provider != "e2b" { + adapter, err := providers.Lookup(setup.Provider) + if err != nil { + return execution.PreparedRuntimeDeployment{}, err + } + if adapter.Mode == "nodes" { return candidate, nil } db, ok := s.store.(generationStore) if !ok { return execution.PreparedRuntimeDeployment{}, errors.New("sandbox generation store is unavailable") } - candidate.Config.Provider = &e2bGenerationRouter{setup: s, store: db} + router := &generationRouter{setup: s, store: db} + if _, ok := candidate.Config.Provider.(runtimeobs.Source); ok { + candidate.Config.Provider = &observedGenerationRouter{router} + } else { + candidate.Config.Provider = router + } + if !adapter.Credential { + return candidate, nil + } candidate.FenceCredential = func(ctx context.Context) (func(), error) { - release, err := s.e2bCalls.Fence(ctx) + release, err := s.providerCalls.Fence(ctx) if err != nil { - return nil, e2b.ErrRequestUnconfirmed + return nil, adapter.CredentialUnconfirmed } return release, nil } @@ -120,25 +139,33 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo if err != nil { return err } - return provider.(*e2b.Provider).VerifyCredential(ctx, refs) + verifier, ok := provider.(sandbox.CredentialVerifier) + if !ok { + return sandbox.ErrInvalid + } + return verifier.VerifyCredential(ctx, refs) } current, err := db.GetSandboxSetup(ctx) if err != nil { return err } - if current.Provider != "e2b" || current.E2B == nil { - return &store.SandboxResetRequiredError{CurrentProvider: current.Provider, RequestedProvider: "e2b"} + if current.Provider != setup.Provider { + return &store.SandboxResetRequiredError{CurrentProvider: current.Provider, RequestedProvider: setup.Provider} } if err := verify(current, nil); err != nil { - if errors.Is(err, e2b.ErrCredentialInvalid) || errors.Is(err, e2b.ErrTeamMismatch) { + if adapter.CredentialRequiresReset != nil && adapter.CredentialRequiresReset(err) { // A revoked legacy key or a public template outside its team cannot // anchor ownership. This says nothing about the candidate key's validity. - return &store.SandboxResetRequiredError{CurrentProvider: "e2b", RequestedProvider: "e2b"} + return &store.SandboxResetRequiredError{CurrentProvider: setup.Provider, RequestedProvider: setup.Provider} } return err } withCandidateKey := func(value store.SandboxSetup, refs []sandbox.Reference) error { - value.E2B = &store.SandboxE2BConfiguration{APIKey: setup.E2B.APIKey, Template: value.E2B.Template, APIURL: value.E2B.APIURL, Domain: value.E2B.Domain} + selection, err := providers.WithCredential(sandbox.Selection{Provider: value.Provider, DeploymentSpec: value.Specification, E2B: value.E2B}, sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, E2B: setup.E2B}) + if err != nil { + return err + } + value.E2B = selection.E2B return verify(value, refs) } if err := withCandidateKey(current, nil); err != nil { @@ -177,7 +204,7 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo for generation, refs := range refsByGeneration { owner, ok := generations[generation] if !ok { - return e2b.ErrRequestUnconfirmed + return adapter.CredentialUnconfirmed } if err := withCandidateKey(owner, refs); err != nil { return err @@ -194,6 +221,6 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo // A batch can contain allocations from different endpoint generations. Let the // observation worker route each allocation through its immutable generation. -func (p *e2bGenerationRouter) ObserveBatch(_ context.Context, _ []runtimeobs.Target) ([]runtimeobs.BatchResult, bool) { +func (p *observedGenerationRouter) ObserveBatch(_ context.Context, _ []runtimeobs.Target) ([]runtimeobs.BatchResult, bool) { return nil, false } diff --git a/services/agents-api/cmd/server/managed_generations_test.go b/services/agents-api/cmd/server/managed_generations_test.go index 19cd0f48c..4d9190d0b 100644 --- a/services/agents-api/cmd/server/managed_generations_test.go +++ b/services/agents-api/cmd/server/managed_generations_test.go @@ -52,16 +52,16 @@ print(json.dumps({'Version':1,'Info':info})) t.Setenv("OAC_E2B_PROVIDER_BIN", helper) t.Setenv("OAC_E2B_STATE_DIR", 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}}, E2B: &store.SandboxE2BConfiguration{APIKey: "old-key", Template: "old:" + uuid.NewString()}} + old := store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, E2B: &sandbox.E2BConfiguration{APIKey: "old-key", Template: "old:" + uuid.NewString()}} current := old current.Generation = 2 current.Specification.Resources.CPUs = 4 - current.E2B = &store.SandboxE2BConfiguration{APIKey: "new-key", Template: "new:" + uuid.NewString()} + current.E2B = &sandbox.E2BConfiguration{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} // A facade retained by a generation-one lifecycle still reads current credentials. - router := &e2bGenerationRouter{setup: setup, store: db} + router := &generationRouter{setup: setup, store: db} ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() if _, err := router.GetInfo(ctx, ref); err != nil { @@ -152,7 +152,7 @@ print(json.dumps(result)) 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}}, - E2B: &store.SandboxE2BConfiguration{APIKey: tc.committedKey, Template: tc.committedTemplate + build}} + E2B: &sandbox.E2BConfiguration{APIKey: tc.committedKey, Template: tc.committedTemplate + build}} db := &setupStore{value: current} s := &managedSetup{installationID: id, store: db} loaded, err := s.load(t.Context()) @@ -160,7 +160,7 @@ print(json.dumps(result)) t.Fatal(err) } next := current - next.E2B = &store.SandboxE2BConfiguration{APIKey: tc.candidateKey, Template: tc.candidateTemplate + build} + next.E2B = &sandbox.E2BConfiguration{APIKey: tc.candidateKey, Template: tc.candidateTemplate + build} candidate, err := s.prepare(t.Context(), next) if err != nil { t.Fatal(err) diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/agents-api/cmd/server/managed_setup.go index 940150bcf..2ab0c49f6 100644 --- a/services/agents-api/cmd/server/managed_setup.go +++ b/services/agents-api/cmd/server/managed_setup.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "math" - "os" "sync/atomic" "time" @@ -13,8 +11,8 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -28,9 +26,9 @@ type managedSetup struct { hub *node.Hub installationID string // publicURL is OAC_PUBLIC_URL; every sandbox reaches Core through it. - publicURL string - selected atomic.Pointer[managedSelection] - e2bCalls e2b.CallFence + publicURL string + selected atomic.Pointer[managedSelection] + providerCalls sandbox.CallFence } // Empty selections retain their generation so a delayed provider load cannot @@ -84,40 +82,30 @@ func (s *managedSetup) load(ctx context.Context) (*execution.RuntimeProvider, er func (s *managedSetup) prepare(ctx context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) { // E2B guests reach Core from E2B's cloud, over the internet. - if setup.Provider == "e2b" && store.LoopbackOrigin(s.publicURL) { + adapter, err := providers.Lookup(setup.Provider) + if err != nil { + return execution.PreparedRuntimeDeployment{}, err + } + if adapter.PublicOrigin && store.LoopbackOrigin(s.publicURL) { return execution.PreparedRuntimeDeployment{}, store.ErrSandboxPublicURLUnreachable } candidate, err := s.configuration(setup) if err != nil { return execution.PreparedRuntimeDeployment{}, err } - if provider, ok := candidate.Config.Provider.(*e2b.Provider); ok { - build, err := provider.ValidateDeployment(ctx) + selection := sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, E2B: setup.E2B} + if discoverer, ok := candidate.Config.Provider.(sandbox.SelectionDiscoverer); ok { + selection, err = discoverer.DiscoverSelection(ctx, selection) if err != nil { - if errors.Is(err, e2b.ErrCredentialInvalid) || errors.Is(err, e2b.ErrTeamMismatch) { - return execution.PreparedRuntimeDeployment{}, err - } - if errors.Is(err, sandbox.ErrInvalid) { - return execution.PreparedRuntimeDeployment{}, e2b.ErrTemplateInvalid - } - return execution.PreparedRuntimeDeployment{}, e2b.ErrRequestUnconfirmed - } - if setup.Specification.Resources == (sandbox.Resources{}) { - // Omitted E2B resources take the validated build's CPU and memory. - setup.Specification.Resources = sandbox.Resources{CPUs: build.CPUs, MemoryMiB: build.MemoryMiB} - if err := setup.Specification.Validate("e2b"); err != nil { - return execution.PreparedRuntimeDeployment{}, &store.SandboxConfigurationError{Message: "E2B template build resources are outside the supported sandbox limits; select another build"} - } - if candidate, err = s.configuration(setup); err != nil { - return execution.PreparedRuntimeDeployment{}, err - } + return execution.PreparedRuntimeDeployment{}, err } - candidate.E2BTemplateBuild = &store.SandboxE2BTemplateBuild{Status: build.Status, CPUs: int32(build.CPUs), MemoryMiB: int32(build.MemoryMiB)} - if build.RootDiskMiB != nil && *build.RootDiskMiB <= math.MaxInt32 { - disk := int32(*build.RootDiskMiB) - candidate.E2BTemplateBuild.RootDiskMiB = &disk + setup.Specification, setup.E2B = selection.DeploymentSpec, selection.E2B + candidate, err = s.configuration(setup) + if err != nil { + return execution.PreparedRuntimeDeployment{}, err } } + candidate.Selection = &selection return s.routeGenerations(candidate, setup) } @@ -177,34 +165,15 @@ func (s *managedSetup) Observe(ctx context.Context, target runtimeobs.Target) (r } func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.SandboxProvider, error) { - switch setup.Provider { - case "docker", "microsandbox": + adapter, err := providers.Lookup(setup.Provider) + if err != nil { + return nil, err + } + if adapter.Mode == "nodes" { if s.hub == nil { return nil, errors.New("sandbox node transport is unavailable") } return s.hub.GenerationProvider(setup.Provider, s.store.ResolveRuntimeGeneration), nil - case "e2b": - if setup.E2B == 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" - } - // 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 - if setup.Specification.Resources != (sandbox.Resources{}) { - resources = &setup.Specification.Resources - } - provider, err := e2b.NewWithCaller(e2b.Config{Binary: binary, StateDir: os.Getenv("OAC_E2B_STATE_DIR"), - Resources: resources, InstallationID: setup.InstallationID, APIKey: setup.E2B.APIKey, Template: setup.E2B.Template, - APIURL: setup.E2B.APIURL, Domain: setup.E2B.Domain, TimeoutSeconds: 3600}, &e2b.ProcessCaller{Fence: &s.e2bCalls}) - if err != nil { - return nil, errors.New("E2B provider cannot load; check the installed helper and private state directory") - } - return provider, nil - default: - return nil, errors.New("sandbox provider is unavailable") } + return providers.BuildDirect(providers.DirectConfig{InstallationID: setup.InstallationID, Selection: sandbox.Selection{Provider: setup.Provider, DeploymentSpec: setup.Specification, E2B: setup.E2B}, Fence: &s.providerCalls}) } diff --git a/services/agents-api/cmd/server/managed_setup_preflight_test.go b/services/agents-api/cmd/server/managed_setup_preflight_test.go index e95d33fd6..a818e65a4 100644 --- a/services/agents-api/cmd/server/managed_setup_preflight_test.go +++ b/services/agents-api/cmd/server/managed_setup_preflight_test.go @@ -28,7 +28,7 @@ func TestE2BRejectedSpecificationHasSafeActionableDiagnostic(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", state) id := uuid.NewString() s := &managedSetup{installationID: id} - selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 3, MemoryMiB: 3072}}, E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 3, MemoryMiB: 3072}}, E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} _, err := s.prepare(t.Context(), selection) if !errors.Is(err, e2b.ErrTemplateInvalid) || strings.Contains(err.Error(), "synthetic-private-key") || s.selected.Load() != nil { t.Fatal("rejected candidate lost its safe diagnostic or was published", err) @@ -67,7 +67,7 @@ else: id := uuid.NewString() selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", Generation: 1, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, - E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} + E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} s := &managedSetup{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) @@ -107,12 +107,12 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", state) id := uuid.NewString() s := &managedSetup{installationID: id, store: &setupStore{}} - selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} candidate, err := s.prepare(t.Context(), selection) disk := int32(24063) - if err != nil || candidate.E2BTemplateBuild == nil || candidate.E2BTemplateBuild.CPUs != 4 || candidate.E2BTemplateBuild.MemoryMiB != 4096 || - *candidate.E2BTemplateBuild.RootDiskMiB != disk || candidate.E2BTemplateBuild.Status != "ready" { - t.Fatalf("validated build was not recorded: %+v %v", candidate.E2BTemplateBuild, err) + if err != nil || candidate.Selection.E2B.TemplateBuild == nil || candidate.Selection.E2B.TemplateBuild.CPUs != 4 || candidate.Selection.E2B.TemplateBuild.MemoryMiB != 4096 || + *candidate.Selection.E2B.TemplateBuild.RootDiskMiB != disk || candidate.Selection.E2B.TemplateBuild.Status != "ready" { + t.Fatalf("validated build was not recorded: %+v %v", candidate.Selection.E2B.TemplateBuild, err) } // The published candidate enforces the adopted resources. selection.Specification.Resources = sandbox.Resources{CPUs: 4, MemoryMiB: 4096} @@ -143,7 +143,7 @@ func TestInitialE2BPublicTemplateOutsideTeamIsRejected(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", state) id := uuid.NewString() s := &managedSetup{installationID: id, store: &setupStore{}} - selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-team-a", Template: "public-team-b:" + uuid.NewString()}} + selection := store.SandboxSetup{InstallationID: id, Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-team-a", Template: "public-team-b:" + uuid.NewString()}} if _, err := s.prepare(t.Context(), selection); !errors.Is(err, e2b.ErrTeamMismatch) || s.selected.Load() != nil { t.Fatal("public readability accepted as team ownership", err) } diff --git a/services/agents-api/cmd/server/managed_setup_test.go b/services/agents-api/cmd/server/managed_setup_test.go index 1bb0cd09c..0c42c3c7b 100644 --- a/services/agents-api/cmd/server/managed_setup_test.go +++ b/services/agents-api/cmd/server/managed_setup_test.go @@ -4,12 +4,12 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "os" "path/filepath" "strings" "testing" - "errors" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" @@ -86,7 +86,7 @@ func TestMissingE2BHelperReportsProviderUnavailable(t *testing.T) { t.Setenv("OAC_E2B_STATE_DIR", t.TempDir()) s := &managedSetup{installationID: id, store: &setupStore{value: store.SandboxSetup{ InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, - E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}, + E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}, }}} if _, err := s.load(t.Context()); !errors.Is(err, execution.ErrExecutionUnavailable) { t.Fatal("missing local helper must leave administrative recovery available", err) @@ -126,7 +126,7 @@ func TestManagedSetupRejectedCandidateRetainsSelection(t *testing.T) { 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", - E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) + E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) if !errors.Is(err, execution.ErrExecutionUnavailable) || s.selected.Load().Config != previous { t.Fatal("rejected candidate lost the previous selection", err) } @@ -136,7 +136,7 @@ func TestE2BRequiresAPublicURLOutsideTheHost(t *testing.T) { id := uuid.NewString() s := &managedSetup{installationID: id, publicURL: "http://127.0.0.1:8091"} _, err := s.prepare(t.Context(), store.SandboxSetup{InstallationID: id, Provider: "e2b", Mode: "direct", - E2B: &store.SandboxE2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) + E2B: &sandbox.E2BConfiguration{APIKey: "synthetic-key", Template: "runtime:" + uuid.NewString()}}) if !errors.Is(err, store.ErrSandboxPublicURLUnreachable) { t.Fatal("E2B accepted a loopback public URL", err) } diff --git a/services/agents-api/cmd/specification-contract/main.go b/services/agents-api/cmd/specification-contract/main.go index ba47c03f0..67f445eac 100644 --- a/services/agents-api/cmd/specification-contract/main.go +++ b/services/agents-api/cmd/specification-contract/main.go @@ -4,7 +4,7 @@ package main import ( "flag" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "os" "strings" ) @@ -12,7 +12,7 @@ import ( func main() { write := flag.Bool("write", false, "update deploy/install/node_spec.py from the repository root") flag.Parse() - projection := sandbox.PythonDeploymentContract() + projection := providers.PythonDeploymentContract() if !*write { fmt.Println(projection) return diff --git a/services/agents-api/internal/api/sandbox_deployment_setup.go b/services/agents-api/internal/api/sandbox_deployment_setup.go index ffa0a91e2..5c06a104b 100644 --- a/services/agents-api/internal/api/sandbox_deployment_setup.go +++ b/services/agents-api/internal/api/sandbox_deployment_setup.go @@ -37,7 +37,7 @@ type SandboxDeploymentChangeInput struct { func (v SandboxDeploymentInput) request() store.SandboxDeploymentSetupRequest { input := store.SandboxDeploymentSetupRequest{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}} if v.E2B != nil { - input.E2B = &store.SandboxE2BConfiguration{Template: v.E2B.Template, APIURL: v.E2B.APIURL, Domain: v.E2B.Domain} + input.E2B = &sandbox.E2BConfiguration{Template: v.E2B.Template, APIURL: v.E2B.APIURL, Domain: v.E2B.Domain} if v.E2B.APIKey != nil { input.E2B.APIKey = *v.E2B.APIKey input.E2B.ReplaceCredential = true diff --git a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql index aa1de4453..e26c78ca0 100644 --- a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql +++ b/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql @@ -13,7 +13,7 @@ updated_at=clock_timestamp() WHERE singleton=true; -- name: RecordSandboxTemplateBuild :exec UPDATE runtime_deployment SET e2b_template_build_status=sqlc.narg(e2b_template_build_status), e2b_template_cpus=sqlc.narg(e2b_template_cpus), e2b_template_memory_mib=sqlc.narg(e2b_template_memory_mib), e2b_template_root_disk_mib=sqlc.narg(e2b_template_root_disk_mib), -updated_at=clock_timestamp() WHERE singleton=true AND provider_kind='e2b'; +updated_at=clock_timestamp() WHERE singleton=true AND e2b_template<>''; -- name: RetireSandboxNodes :exec UPDATE runtime_nodes SET removed_at=clock_timestamp(),connection_id=NULL,provider_ready=false,ready_generation=NULL WHERE removed_at IS NULL; diff --git a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go index cd4b350b9..088bd1311 100644 --- a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go +++ b/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go @@ -81,7 +81,7 @@ func (q *Queries) InitializeSandboxDeployment(ctx context.Context, arg Initializ const recordSandboxTemplateBuild = `-- name: RecordSandboxTemplateBuild :exec UPDATE runtime_deployment SET e2b_template_build_status=$1, e2b_template_cpus=$2, e2b_template_memory_mib=$3, e2b_template_root_disk_mib=$4, -updated_at=clock_timestamp() WHERE singleton=true AND provider_kind='e2b' +updated_at=clock_timestamp() WHERE singleton=true AND e2b_template<>'' ` type RecordSandboxTemplateBuildParams struct { diff --git a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go b/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go index 637e70ef6..9470d7f89 100644 --- a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go @@ -3,15 +3,12 @@ package execution import ( "context" "encoding/json" - runtimegateway "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/gorilla/websocket" "net/http" "net/http/httptest" "net/url" "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -20,6 +17,9 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" + "github.com/gorilla/websocket" + runtimegateway "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" ) // Provider callbacks inspect the real database at the instant destructive @@ -60,7 +60,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { if err := writer.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { t.Fatal(err) } - selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} selection.Resources.CPUs = 2 selection.Resources.MemoryMiB = 2048 if _, err := writer.InitializeSandboxDeployment(t.Context(), installation, selection); err != nil { diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup.go b/services/agents-api/internal/execution/sandbox_deployment_setup.go index c2c494c6e..d929bbece 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup.go @@ -10,29 +10,15 @@ import ( // PreparedRuntimeDeployment has completed provider validation without publishing // a selection. Publish must only update in-memory state and must not fail. -// E2BTemplateBuild is the fixed build's specification read by that validation. +// Selection is the resolved typed configuration returned by preparation. type PreparedRuntimeDeployment struct { Config *RuntimeProvider Publish func(*RuntimeProvider) - E2BTemplateBuild *store.SandboxE2BTemplateBuild + Selection *sandbox.Selection VerifyCredential func(context.Context) error FenceCredential func(context.Context) (func(), error) } -// withTemplateBuild saves the validated build with the selection and fills -// omitted E2B resources from it, without changing the caller's request. -func withTemplateBuild(input store.SandboxDeploymentSetupRequest, candidate PreparedRuntimeDeployment) store.SandboxDeploymentSetupRequest { - if input.E2B != nil && candidate.E2BTemplateBuild != nil { - e2b, build := *input.E2B, *candidate.E2BTemplateBuild - if store.E2BResourcesPending(input) { - input.Resources = sandbox.Resources{CPUs: uint32(build.CPUs), MemoryMiB: uint32(build.MemoryMiB)} - } - e2b.TemplateBuild = &build - input.E2B = &e2b - } - return input -} - type RuntimeDeploymentPreparer func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) // NewDeferredRuntimeProvider enables Web setup for one fixed installation. The @@ -70,7 +56,7 @@ func (w *Worker) InitializeSandboxDeployment(ctx context.Context, input store.Sa return store.RuntimeDeploymentView{}, err } } - result, err := m.store.InitializeSandboxDeployment(ctx, m.setupInstallationID, withTemplateBuild(input, candidate)) + result, err := m.store.InitializeSandboxDeployment(ctx, m.setupInstallationID, *candidate.Selection) if err != nil { return store.RuntimeDeploymentView{}, err } @@ -161,6 +147,13 @@ func (m *runtimeManager) prepareCandidate(ctx context.Context, input store.Sandb if closed { return PreparedRuntimeDeployment{}, ErrExecutionUnavailable } + if candidate.Selection == nil { + candidate.Selection = &input + } + if candidate.Selection.Provider != input.Provider { + return PreparedRuntimeDeployment{}, sandbox.ErrInvalid + } + candidate.Selection.ExpectedGeneration = input.ExpectedGeneration candidate.Config = &copied return candidate, nil } diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go index 41d52dc4f..39d0d7a52 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go @@ -140,7 +140,7 @@ func TestRejectedSandboxCandidatePreservesActiveGeneration(t *testing.T) { if err != nil { t.Fatal(err) } - input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} + input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} if _, err := m.prepareCandidate(t.Context(), input); !errors.Is(err, rejected) { t.Fatal("candidate rejection was lost", err) } @@ -170,7 +170,7 @@ func TestSandboxCandidateValidationDoesNotHoldManagerLock(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) + _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) }() <-entered stopped := make(chan struct{}) diff --git a/services/agents-api/internal/execution/sandbox_deployment_switch.go b/services/agents-api/internal/execution/sandbox_deployment_switch.go index d86727eaa..53d6cca7c 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_switch.go +++ b/services/agents-api/internal/execution/sandbox_deployment_switch.go @@ -155,14 +155,14 @@ func (w *Worker) UpdateSandboxDeployment(ctx context.Context, input store.Sandbo if err != nil { return store.RuntimeDeploymentView{}, err } - if input.Provider == "e2b" { + if input.HasCredential() || candidate.VerifyCredential != nil { if candidate.VerifyCredential == nil || candidate.FenceCredential == nil { return store.RuntimeDeploymentView{}, ErrExecutionUnavailable } if err := candidate.VerifyCredential(ctx); err != nil { return store.RuntimeDeploymentView{}, err } - if input.E2B.ReplaceCredential { + if input.ReplacesCredential() { fenceCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() release, err := candidate.FenceCredential(fenceCtx) @@ -176,7 +176,7 @@ func (w *Worker) UpdateSandboxDeployment(ctx context.Context, input store.Sandbo } } } - input.SandboxDeploymentSetupRequest = withTemplateBuild(input.SandboxDeploymentSetupRequest, candidate) + input.SandboxDeploymentSetupRequest = *candidate.Selection result, err := m.store.UpdateSandboxDeployment(ctx, m.setupInstallationID, input) if err != nil { return store.RuntimeDeploymentView{}, err diff --git a/services/agents-api/internal/execution/sandbox_generations_test.go b/services/agents-api/internal/execution/sandbox_generations_test.go index 4b816b214..ecc457d1d 100644 --- a/services/agents-api/internal/execution/sandbox_generations_test.go +++ b/services/agents-api/internal/execution/sandbox_generations_test.go @@ -7,6 +7,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -20,7 +21,7 @@ func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) if err := writer.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "old-key", Template: "runtime:" + uuid.NewString()}} + input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "old-key", Template: "runtime:" + uuid.NewString()}} input.Resources.CPUs = 2 input.Resources.MemoryMiB = 2048 if _, err := writer.InitializeSandboxDeployment(t.Context(), id, input); err != nil { diff --git a/services/agents-api/internal/execution/sandbox_reset_test.go b/services/agents-api/internal/execution/sandbox_reset_test.go index 7517d3007..09249ba5b 100644 --- a/services/agents-api/internal/execution/sandbox_reset_test.go +++ b/services/agents-api/internal/execution/sandbox_reset_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" @@ -89,7 +90,7 @@ func TestSandboxResetPageTimeoutRecoversCommittedOwner(t *testing.T) { if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} selection.Resources.CPUs = 2 selection.Resources.MemoryMiB = 2048 if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { diff --git a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go b/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go index 6f4a95325..8608e536a 100644 --- a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go +++ b/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" @@ -67,7 +68,7 @@ func TestSandboxResetSnapshotFitsPageBudget(t *testing.T) { if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { t.Fatal(err) } - selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} selection.Resources.CPUs = 2 selection.Resources.MemoryMiB = 2048 if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { diff --git a/services/agents-api/internal/sandbox/e2b/fence.go b/services/agents-api/internal/sandbox/call_fence.go similarity index 95% rename from services/agents-api/internal/sandbox/e2b/fence.go rename to services/agents-api/internal/sandbox/call_fence.go index 89fa4a48a..657180e3c 100644 --- a/services/agents-api/internal/sandbox/e2b/fence.go +++ b/services/agents-api/internal/sandbox/call_fence.go @@ -1,4 +1,4 @@ -package e2b +package sandbox import ( "context" @@ -27,7 +27,7 @@ func (f *CallFence) Enter(ctx context.Context) (func(), error) { } return func() { f.gate.Release(1) }, nil } -func (f *CallFence) childStarted() func() { +func (f *CallFence) ChildStarted() func() { f.mu.Lock() if f.active == 0 { f.idle = make(chan struct{}) diff --git a/services/agents-api/internal/sandbox/deployment.go b/services/agents-api/internal/sandbox/deployment.go index 9c03ed7eb..00ee27019 100644 --- a/services/agents-api/internal/sandbox/deployment.go +++ b/services/agents-api/internal/sandbox/deployment.go @@ -30,22 +30,18 @@ type Resources struct { EnvironmentDiskMiB uint32 `json:"environment_disk_mib,omitempty"` } -func (r Resources) Validate(provider string) error { - rules, ok := deploymentProviders[provider] +func (r Resources) ValidatePolicy(provider string, rules DeploymentPolicy) error { values := reflect.ValueOf(r) for i, rule := range resourceContract { min, max := rule.Min, rule.Max message := rule.Message var maximum *uint32 = validationBound(max) if rule.OmitZero { - if !ok { - break - } min, max = 0, 0 message = provider + " does not support independent disk capacity limits" if rules.Disk { min, max = minimumDiskMiB, ^uint32(0) - message = "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB" + message = provider + " requires root_disk_mib and environment_disk_mib of at least 1024 MiB" maximum = nil } else { maximum = validationBound(max) @@ -56,9 +52,6 @@ func (r Resources) Validate(provider string) error { return &ValidationError{Param: "resources." + rule.Name, Min: validationBound(min), Max: maximum, Message: fmt.Sprintf("%s: %s", ErrInvalid, message)} } } - if !ok { - return fmt.Errorf("%w: unsupported sandbox provider", ErrInvalid) - } return nil } @@ -94,22 +87,6 @@ type DeploymentSpec struct { Runtime *RuntimeRelease `json:"runtime,omitempty"` } -func (s DeploymentSpec) Validate(provider string) error { - if err := s.Resources.Validate(provider); err != nil { - return err - } - if !deploymentProviders[provider].Runtime { - if s.Runtime != nil { - return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: E2B Runtime is selected by its immutable template build", ErrInvalid)} - } - return nil - } - if s.Runtime == nil { - return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", ErrInvalid)} - } - return s.Runtime.Validate() -} - func (s DeploymentSpec) Digest(provider string) string { raw, _ := json.Marshal(struct { Provider string `json:"provider"` diff --git a/services/agents-api/internal/sandbox/deployment_contract.go b/services/agents-api/internal/sandbox/deployment_contract.go index a0688211f..c21d79cb9 100644 --- a/services/agents-api/internal/sandbox/deployment_contract.go +++ b/services/agents-api/internal/sandbox/deployment_contract.go @@ -22,7 +22,7 @@ type runtimeRule struct { Name string `json:"name"` Pattern string `json:"pattern"` } -type providerRule struct { +type DeploymentPolicy struct { Disk bool `json:"disk"` Runtime bool `json:"runtime"` } @@ -41,13 +41,10 @@ var runtimeContract = []runtimeRule{ {"runtime_sha256", "[0-9a-f]{64}"}, {"firmware_sha256", "[0-9a-f]{64}"}, } -var deploymentProviders = map[string]providerRule{ - "docker": {Runtime: true}, "microsandbox": {Disk: true, Runtime: true}, "e2b": {}, -} // PythonDeploymentContract generates the installer projection. Struct order is // checked before generation because it also defines Go's canonical JSON bytes. -func PythonDeploymentContract() string { +func PythonDeploymentContract(policies map[string]DeploymentPolicy) string { for _, item := range []struct { value any names []string @@ -65,11 +62,11 @@ func PythonDeploymentContract() string { } } raw, _ := json.Marshal(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - Providers map[string]providerRule `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, deploymentProviders, minimumDiskMiB}) + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + Providers map[string]DeploymentPolicy `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, policies, minimumDiskMiB}) return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT" } func resourceNames() []string { diff --git a/services/agents-api/internal/sandbox/deployment_validation_test.go b/services/agents-api/internal/sandbox/deployment_validation_test.go deleted file mode 100644 index 07085b96d..000000000 --- a/services/agents-api/internal/sandbox/deployment_validation_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package sandbox - -import ( - "errors" - "testing" -) - -func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) { - for _, tc := range []struct { - provider string - resources Resources - param, message string - min, max *uint32 - }{ - {"docker", Resources{}, "resources.cpus", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(1), validationBound(255)}, - {"docker", Resources{CPUs: 1, MemoryMiB: 511}, "resources.memory_mib", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(512), validationBound(1048576)}, - {"microsandbox", Resources{CPUs: 1, MemoryMiB: 512}, "resources.root_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil}, - {"microsandbox", Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1024}, "resources.environment_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil}, - {"docker", Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1}, "resources.root_disk_mib", "docker does not support independent disk capacity limits", validationBound(0), validationBound(0)}, - {"e2b", Resources{CPUs: 1, MemoryMiB: 512, EnvironmentDiskMiB: 1}, "resources.environment_disk_mib", "e2b does not support independent disk capacity limits", validationBound(0), validationBound(0)}, - } { - err := tc.resources.Validate(tc.provider) - var field *ValidationError - if !errors.As(err, &field) || !errors.Is(err, ErrInvalid) || err.Error() != ErrInvalid.Error()+": "+tc.message || field.Param != tc.param { - t.Fatalf("wrong error: %#v", err) - } - for i, bound := range []*uint32{field.Min, field.Max} { - want := []*uint32{tc.min, tc.max}[i] - if (bound == nil) != (want == nil) || (bound != nil && *bound != *want) { - t.Fatal("wrong fixed bounds", field) - } - } - } - for _, tc := range []struct { - provider string - runtime *RuntimeRelease - message string - }{ - {"docker", nil, "managed nodes require a pinned Runtime release"}, - {"docker", &RuntimeRelease{}, "Runtime must reference one immutable distribution"}, - {"e2b", &RuntimeRelease{}, "E2B Runtime is selected by its immutable template build"}, - } { - err := (DeploymentSpec{Resources: Resources{CPUs: 1, MemoryMiB: 512}, Runtime: tc.runtime}).Validate(tc.provider) - var field *ValidationError - if !errors.As(err, &field) || field.Param != "runtime" || err.Error() != ErrInvalid.Error()+": "+tc.message || !errors.Is(err, ErrInvalid) { - t.Fatal(err) - } - } - if err := (Resources{CPUs: 255, MemoryMiB: 1048576}).Validate("docker"); err != nil { - t.Fatal(err) - } - var field *ValidationError - err := (Resources{CPUs: 1, MemoryMiB: 512}).Validate("private-provider") - if errors.As(err, &field) || err.Error() != ErrInvalid.Error()+": unsupported sandbox provider" { - t.Fatal("unknown provider reclassified", err) - } -} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go index e74b905dc..8ad977b63 100644 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -41,7 +41,7 @@ func New(c *client.Client, config Config) (*Provider, error) { return nil, sandbox.ErrInvalid } if config.Resources != nil { - if config.Resources.Validate("docker") != nil { + if ValidateResources(*config.Resources) != nil { return nil, sandbox.ErrInvalid } resources := *config.Resources diff --git a/services/agents-api/internal/sandbox/docker/selection.go b/services/agents-api/internal/sandbox/docker/selection.go new file mode 100644 index 000000000..f732ed25c --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/selection.go @@ -0,0 +1,20 @@ +package docker + +import ( + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Runtime: true} } + +func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("docker", Policy()) } +func ValidateSpecification(s sandbox.DeploymentSpec) error { + if err := ValidateResources(s.Resources); err != nil { + return err + } + if s.Runtime == nil { + return &sandbox.ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", sandbox.ErrInvalid)} + } + return s.Runtime.Validate() +} diff --git a/services/agents-api/internal/sandbox/e2b/deployment.go b/services/agents-api/internal/sandbox/e2b/deployment.go new file mode 100644 index 000000000..6c6326e42 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/deployment.go @@ -0,0 +1,144 @@ +package e2b + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "unicode" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" +) + +func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{} } + +func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } +func ValidateSpecification(s sandbox.DeploymentSpec) error { + if err := ValidateResources(s.Resources); err != nil { + return err + } + if s.Runtime != nil { + return &sandbox.ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: E2B Runtime is selected by its immutable template build", sandbox.ErrInvalid)} + } + return nil +} + +func ValidateConfiguration(c *sandbox.E2BConfiguration) error { + if c == nil || c.APIKey == "" || len(c.APIKey) > 4096 || strings.IndexFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { + return sandbox.ErrInvalid + } + if _, _, err := NormalizeEndpoint(c.APIURL, c.Domain); err != nil { + return sandbox.ErrInvalid + } + template, build, ok := strings.Cut(c.Template, ":") + id, err := uuid.Parse(build) + if !ok || template == "" || len(template) > 128 || err != nil || id == uuid.Nil || id.String() != build { + return sandbox.ErrInvalid + } + for _, c := range template { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { + return sandbox.ErrInvalid + } + } + return nil +} + +// NormalizeSelection accepts omitted candidate resources only until live build +// discovery. Persistence requires ValidateSpecification after preparation. +func NormalizeSelection(s sandbox.Selection) (sandbox.Selection, error) { + if s.Resources == (sandbox.Resources{}) { + if s.Runtime != nil { + return s, &sandbox.ValidationError{Param: "runtime", Message: "invalid sandbox configuration: E2B Runtime is selected by its immutable template build"} + } + } else if err := ValidateSpecification(s.DeploymentSpec); err != nil { + return s, err + } + if err := ValidateConfiguration(s.E2B); err != nil { + return s, err + } + c := *s.E2B + c.APIURL, c.Domain, _ = NormalizeEndpoint(c.APIURL, c.Domain) + s.E2B = &c + return s, nil +} + +func WithTemplateBuild(input sandbox.Selection, build *sandbox.TemplateBuild) sandbox.Selection { + if input.E2B != nil && build != nil { + c, b := *input.E2B, *build + if input.Resources == (sandbox.Resources{}) { + input.Resources = sandbox.Resources{CPUs: uint32(b.CPUs), MemoryMiB: uint32(b.MemoryMiB)} + } + c.TemplateBuild = &b + input.E2B = &c + } + return input +} + +// DiscoverSelection checks the immutable native build without allocating compute. +func (p *Provider) DiscoverSelection(ctx context.Context, s sandbox.Selection) (sandbox.Selection, error) { + build, err := p.ValidateDeployment(ctx) + if err != nil { + if errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) { + return s, err + } + if errors.Is(err, sandbox.ErrInvalid) { + return s, ErrTemplateInvalid + } + return s, ErrRequestUnconfirmed + } + recorded := &sandbox.TemplateBuild{Status: build.Status, CPUs: int32(build.CPUs), MemoryMiB: int32(build.MemoryMiB)} + if build.RootDiskMiB != nil && *build.RootDiskMiB <= math.MaxInt32 { + disk := int32(*build.RootDiskMiB) + recorded.RootDiskMiB = &disk + } + s = WithTemplateBuild(s, recorded) + if err := ValidateSpecification(s.DeploymentSpec); err != nil { + return s, &sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"} + } + return s, nil +} + +// RestoreSelection normalizes stored connection fields without admitting a new +// template or requiring remote availability for retained-resource cleanup. +func RestoreSelection(s sandbox.Selection) (sandbox.Selection, error) { + if s.E2B == nil { + return s, sandbox.ErrInvalid + } + c := *s.E2B + var err error + c.APIURL, c.Domain, err = NormalizeEndpoint(c.APIURL, c.Domain) + s.E2B = &c + return s, err +} +func ResolveChange(next, previous sandbox.Selection) sandbox.Selection { + if next.E2B == nil || previous.E2B == nil { + return next + } + c := *next.E2B + c.ReplaceCredential = c.ReplaceCredential || c.APIKey != "" + if c.APIURL == "" && c.Domain == "" { + c.APIURL, c.Domain = previous.E2B.APIURL, previous.E2B.Domain + } + if c.APIKey == "" && !c.ReplaceCredential { + c.APIKey = previous.E2B.APIKey + } + if c.Template == previous.E2B.Template && next.Resources == (sandbox.Resources{}) { + next.Resources = previous.Resources + } + next.E2B = &c + return next +} + +func ReplaceCredential(owner, candidate sandbox.Selection) sandbox.Selection { + if owner.E2B != nil && candidate.E2B != nil { + c := *owner.E2B + c.APIKey = candidate.E2B.APIKey + owner.E2B = &c + } + return owner +} +func CredentialRequiresReset(err error) bool { + return errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) +} diff --git a/services/agents-api/internal/sandbox/e2b/process.go b/services/agents-api/internal/sandbox/e2b/process.go index eae28f4b3..3d6eeab6e 100644 --- a/services/agents-api/internal/sandbox/e2b/process.go +++ b/services/agents-api/internal/sandbox/e2b/process.go @@ -9,13 +9,15 @@ import ( "os" "os/exec" "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) // ProcessCaller retains and drains an outstanding helper after caller timeout. // The helper keeps its allocation lock until its bounded SDK operation settles. var errHelperNotStarted = errors.New("helper did not start") -type ProcessCaller struct{ Fence *CallFence } +type ProcessCaller struct{ Fence *sandbox.CallFence } func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { data, err := json.Marshal(q) @@ -41,7 +43,7 @@ func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { } finished := func() {} if p.Fence != nil { - finished = p.Fence.childStarted() + finished = p.Fence.ChildStarted() } done := make(chan error, 1) go func() { err := cmd.Wait(); finished(); done <- err }() diff --git a/services/agents-api/internal/sandbox/e2b/process_test.go b/services/agents-api/internal/sandbox/e2b/process_test.go index 2cbedc943..918b70a67 100644 --- a/services/agents-api/internal/sandbox/e2b/process_test.go +++ b/services/agents-api/internal/sandbox/e2b/process_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) func TestProcessWireRejectsUnknownAndTrailingOutput(t *testing.T) { @@ -69,7 +71,7 @@ func TestCredentialFenceWaitsForActualHelperExitAfterCancellation(t *testing.T) if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } - fence := &CallFence{} + fence := &sandbox.CallFence{} ctx, cancel := context.WithCancel(t.Context()) entered, err := fence.Enter(ctx) if err != nil { diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go index bd9cd3ee3..beff3c929 100644 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ b/services/agents-api/internal/sandbox/e2b/provider.go @@ -157,7 +157,7 @@ func validReference(r sandbox.Reference) bool { return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) } func (c Config) Validate() error { - if c.Resources != nil && c.Resources.Validate("e2b") != nil { + if c.Resources != nil && ValidateResources(*c.Resources) != nil { return sandbox.ErrInvalid } template, build, ok := strings.Cut(c.Template, ":") diff --git a/services/agents-api/internal/execution/sandbox_template_build_test.go b/services/agents-api/internal/sandbox/e2b/selection_test.go similarity index 55% rename from services/agents-api/internal/execution/sandbox_template_build_test.go rename to services/agents-api/internal/sandbox/e2b/selection_test.go index b09550bf5..b225eb7e5 100644 --- a/services/agents-api/internal/execution/sandbox_template_build_test.go +++ b/services/agents-api/internal/sandbox/e2b/selection_test.go @@ -1,16 +1,15 @@ -package execution +package e2b import ( "testing" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) func TestOmittedE2BResourcesComeFromValidatedTemplateBuild(t *testing.T) { - request := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "key", Template: "runtime:build"}} - build := &store.SandboxE2BTemplateBuild{Status: "ready", CPUs: 4, MemoryMiB: 4096} - saved := withTemplateBuild(request, PreparedRuntimeDeployment{E2BTemplateBuild: build}) + request := sandbox.Selection{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "key", Template: "runtime:build"}} + build := &sandbox.TemplateBuild{Status: "ready", CPUs: 4, MemoryMiB: 4096} + saved := WithTemplateBuild(request, build) if saved.Resources != (sandbox.Resources{CPUs: 4, MemoryMiB: 4096}) || saved.E2B.TemplateBuild == nil || *saved.E2B.TemplateBuild != *build { t.Fatalf("validated build was not saved with the selection: %+v", saved) } @@ -18,7 +17,7 @@ func TestOmittedE2BResourcesComeFromValidatedTemplateBuild(t *testing.T) { t.Fatal("caller's request was changed") } request.Resources = sandbox.Resources{CPUs: 2, MemoryMiB: 2048} - if saved = withTemplateBuild(request, PreparedRuntimeDeployment{E2BTemplateBuild: build}); saved.Resources != request.Resources { + if saved = WithTemplateBuild(request, build); saved.Resources != request.Resources { t.Fatalf("explicit resources were replaced: %+v", saved.Resources) } } diff --git a/services/agents-api/internal/sandbox/microsandbox/deployment.go b/services/agents-api/internal/sandbox/microsandbox/deployment.go new file mode 100644 index 000000000..68495e8b9 --- /dev/null +++ b/services/agents-api/internal/sandbox/microsandbox/deployment.go @@ -0,0 +1,20 @@ +package microsandbox + +import ( + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Disk: true, Runtime: true} } + +func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } +func ValidateSpecification(s sandbox.DeploymentSpec) error { + if err := ValidateResources(s.Resources); err != nil { + return err + } + if s.Runtime == nil { + return &sandbox.ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", sandbox.ErrInvalid)} + } + return s.Runtime.Validate() +} diff --git a/services/agents-api/internal/sandbox/node/identity.go b/services/agents-api/internal/sandbox/node/identity.go index e1601e54f..9b00c852d 100644 --- a/services/agents-api/internal/sandbox/node/identity.go +++ b/services/agents-api/internal/sandbox/node/identity.go @@ -12,6 +12,7 @@ import ( "path/filepath" "syscall" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/google/uuid" ) @@ -128,7 +129,7 @@ func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error if identity.NodeID == "" { identity.NodeID = uuid.NewString() } - if !validID(identity.NodeID) || !validID(identity.InstallationID) || (identity.Provider != "docker" && identity.Provider != "microsandbox") || len(identity.BackendFingerprint) != 64 { + if !validID(identity.NodeID) || !validID(identity.InstallationID) || !providers.IsNode(identity.Provider) || len(identity.BackendFingerprint) != 64 { return StoredIdentity{}, errors.New("invalid node configuration") } secret := make([]byte, 32) diff --git a/services/agents-api/internal/sandbox/node/proxy.go b/services/agents-api/internal/sandbox/node/proxy.go index 9635464e2..1b921c190 100644 --- a/services/agents-api/internal/sandbox/node/proxy.go +++ b/services/agents-api/internal/sandbox/node/proxy.go @@ -2,7 +2,9 @@ package node import ( "context" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" ) type provider struct { @@ -18,6 +20,7 @@ var _ sandbox.CheckpointProvider = (*checkpointProvider)(nil) // Proxy binds a fixed node and deployment generation explicitly. func (h *Hub) Proxy(id, kind string, generation uint64) sandbox.SandboxProvider { return h.GenerationProvider(kind, func(context.Context, sandbox.Reference) (string, uint64, error) { return id, generation, nil }) + } func (p *provider) call(ctx context.Context, q request) (response, error) { id, generation, err := p.resolveGeneration(ctx, q.Reference) @@ -139,7 +142,7 @@ func (p *checkpointProvider) ResumeCompute(ctx context.Context, r sandbox.Refere // distinct from the request's compute generation. func (h *Hub) GenerationProvider(kind string, resolve func(context.Context, sandbox.Reference) (string, uint64, error)) sandbox.SandboxProvider { p := &provider{hub: h, kind: kind, resolveGeneration: resolve} - if kind == "microsandbox" { + if providers.SupportsCheckpoint(kind) { return &checkpointProvider{p} } return p diff --git a/services/agents-api/internal/sandbox/config/capacity.go b/services/agents-api/internal/sandbox/providers/capacity.go similarity index 95% rename from services/agents-api/internal/sandbox/config/capacity.go rename to services/agents-api/internal/sandbox/providers/capacity.go index 1ca62326c..9420953d5 100644 --- a/services/agents-api/internal/sandbox/config/capacity.go +++ b/services/agents-api/internal/sandbox/providers/capacity.go @@ -1,7 +1,8 @@ -package config +package providers import ( "fmt" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) diff --git a/services/agents-api/internal/sandbox/config/capacity_linux.go b/services/agents-api/internal/sandbox/providers/capacity_linux.go similarity index 95% rename from services/agents-api/internal/sandbox/config/capacity_linux.go rename to services/agents-api/internal/sandbox/providers/capacity_linux.go index e9b20bf79..017dfdc90 100644 --- a/services/agents-api/internal/sandbox/config/capacity_linux.go +++ b/services/agents-api/internal/sandbox/providers/capacity_linux.go @@ -1,12 +1,13 @@ //go:build linux -package config +package providers import ( "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "runtime" "syscall" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) func hostCapacity(r sandbox.Resources) error { diff --git a/services/agents-api/internal/sandbox/config/capacity_other.go b/services/agents-api/internal/sandbox/providers/capacity_other.go similarity index 91% rename from services/agents-api/internal/sandbox/config/capacity_other.go rename to services/agents-api/internal/sandbox/providers/capacity_other.go index 497922b90..33190f1c6 100644 --- a/services/agents-api/internal/sandbox/config/capacity_other.go +++ b/services/agents-api/internal/sandbox/providers/capacity_other.go @@ -1,9 +1,10 @@ //go:build !linux -package config +package providers import ( "errors" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) diff --git a/services/agents-api/internal/sandbox/config/config.go b/services/agents-api/internal/sandbox/providers/config.go similarity index 50% rename from services/agents-api/internal/sandbox/config/config.go rename to services/agents-api/internal/sandbox/providers/config.go index 8f9d68e34..61839d052 100644 --- a/services/agents-api/internal/sandbox/config/config.go +++ b/services/agents-api/internal/sandbox/providers/config.go @@ -1,23 +1,18 @@ -// Package config constructs the selected node-local sandbox adapter. -package config +// Node-local adapter configuration and construction. +package providers import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" - "fmt" "io" - "net/url" "os" - "path/filepath" - "context" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - sandboxdocker "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" "github.com/google/uuid" - "github.com/moby/moby/client" ) type Config struct { @@ -79,51 +74,14 @@ func Build(config Config) (*Built, func(), error) { if err != nil || id == uuid.Nil || id.String() != config.InstallationID { return nil, closeProvider, errors.New("sandbox requires a canonical installation_id UUID") } - if config.Provider != "docker" && config.Provider != "microsandbox" { - return nil, closeProvider, errors.New("sandbox provider must be docker or microsandbox") + adapter, err := Lookup(config.Provider) + if err != nil || adapter.BuildLocal == nil { + return nil, closeProvider, errors.New("sandbox provider is not node-local") } - - hasDocker, hasMicrosandbox := config.Docker != nil, config.Microsandbox != nil result := &Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} - switch config.Provider { - case "docker": - if config.Docker == nil || !hasDocker || hasMicrosandbox { - return nil, 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 nil, closeProvider, errors.New("managed Docker host must be an explicit canonical unix socket") - } - seccomp, err := os.ReadFile(entry.SeccompFile) - if err != nil { - return nil, closeProvider, fmt.Errorf("cannot read managed Docker seccomp JSON: %w", err) - } - if !json.Valid(seccomp) { - return nil, closeProvider, errors.New("invalid managed Docker seccomp JSON") - } - c, err := client.New(client.WithHost(entry.Host)) - if err != nil { - return nil, 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 nil, 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) - case "microsandbox": - if config.Microsandbox == nil || !hasMicrosandbox || hasDocker { - return nil, closeProvider, errors.New("managed microsandbox requires only the microsandbox configuration object") - } - if err := configureMicrosandbox(*config.Microsandbox, config.Specification.Resources, result); err != nil { - return nil, closeProvider, err - } - default: - return nil, closeProvider, errors.New("managed provider must be docker or microsandbox") + closeProvider, err = adapter.BuildLocal(config, result) + if err != nil { + return nil, closeProvider, err } return result, closeProvider, nil } diff --git a/services/agents-api/internal/sandbox/config/config_test.go b/services/agents-api/internal/sandbox/providers/config_test.go similarity index 99% rename from services/agents-api/internal/sandbox/config/config_test.go rename to services/agents-api/internal/sandbox/providers/config_test.go index 8c175e739..ebc248620 100644 --- a/services/agents-api/internal/sandbox/config/config_test.go +++ b/services/agents-api/internal/sandbox/providers/config_test.go @@ -1,11 +1,12 @@ -package config +package providers import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "os" "path/filepath" "strings" "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) func TestNodeRejectsCoreConfigurationAndUnknownProvider(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/deployment_contract_test.go b/services/agents-api/internal/sandbox/providers/deployment_contract_test.go similarity index 79% rename from services/agents-api/internal/sandbox/deployment_contract_test.go rename to services/agents-api/internal/sandbox/providers/deployment_contract_test.go index 26f96a890..c016a356a 100644 --- a/services/agents-api/internal/sandbox/deployment_contract_test.go +++ b/services/agents-api/internal/sandbox/providers/deployment_contract_test.go @@ -1,15 +1,16 @@ -package sandbox +package providers import ( "bytes" "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "os" "strings" "testing" ) func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { - raw, err := os.ReadFile("../../../../deploy/install/node_spec.py") + raw, err := os.ReadFile("../../../../../deploy/install/node_spec.py") if err != nil { t.Fatal(err) } @@ -19,7 +20,7 @@ func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { } } func TestDeploymentContractFixtures(t *testing.T) { - raw, err := os.ReadFile("testdata/deployment-contract.json") + raw, err := os.ReadFile("../testdata/deployment-contract.json") if err != nil { t.Fatal(err) } @@ -33,12 +34,12 @@ func TestDeploymentContractFixtures(t *testing.T) { } for _, fixture := range fixtures { t.Run(fixture.Name, func(t *testing.T) { - var spec DeploymentSpec + var spec sandbox.DeploymentSpec decoder := json.NewDecoder(bytes.NewReader(fixture.Specification)) decoder.DisallowUnknownFields() err := decoder.Decode(&spec) if err == nil { - err = spec.Validate(fixture.Provider) + err = ValidateSpecification(fixture.Provider, spec) } if (err == nil) != fixture.Valid { t.Fatalf("validation differs: %v", err) @@ -48,7 +49,7 @@ func TestDeploymentContractFixtures(t *testing.T) { } canonical, _ := json.Marshal(struct { Provider string `json:"provider"` - DeploymentSpec + sandbox.DeploymentSpec }{fixture.Provider, spec}) if string(canonical) != fixture.Canonical || spec.Digest(fixture.Provider) != fixture.Digest { t.Fatalf("canonical contract differs: %s", canonical) diff --git a/services/agents-api/internal/sandbox/providers/docker.go b/services/agents-api/internal/sandbox/providers/docker.go new file mode 100644 index 000000000..b6657b6f3 --- /dev/null +++ b/services/agents-api/internal/sandbox/providers/docker.go @@ -0,0 +1,46 @@ +package providers + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + sandboxdocker "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" + "github.com/moby/moby/client" +) + +func buildDocker(config Config, 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/agents-api/internal/sandbox/providers/e2b.go b/services/agents-api/internal/sandbox/providers/e2b.go new file mode 100644 index 000000000..783f1a47f --- /dev/null +++ b/services/agents-api/internal/sandbox/providers/e2b.go @@ -0,0 +1,48 @@ +package providers + +import ( + "errors" + "os" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" +) + +type DirectConfig struct { + InstallationID string + Selection sandbox.Selection + Fence *sandbox.CallFence +} + +func BuildDirect(c DirectConfig) (sandbox.SandboxProvider, error) { + a, e := Lookup(c.Selection.Provider) + if e != nil { + return nil, e + } + if a.BuildDirect == nil { + return nil, sandbox.ErrInvalid + } + return a.BuildDirect(c) +} +func buildE2B(c DirectConfig) (sandbox.SandboxProvider, error) { + if c.Selection.E2B == 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" + } + // 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 + 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"), + Resources: resources, InstallationID: c.InstallationID, APIKey: c.Selection.E2B.APIKey, Template: c.Selection.E2B.Template, + APIURL: c.Selection.E2B.APIURL, Domain: c.Selection.E2B.Domain, TimeoutSeconds: 3600}, &e2b.ProcessCaller{Fence: c.Fence}) + if err != nil { + return nil, errors.New("E2B provider cannot load; check the installed helper and private state directory") + } + return provider, nil +} diff --git a/services/agents-api/internal/sandbox/config/microsandbox.go b/services/agents-api/internal/sandbox/providers/microsandbox.go similarity index 89% rename from services/agents-api/internal/sandbox/config/microsandbox.go rename to services/agents-api/internal/sandbox/providers/microsandbox.go index 16ed01ff4..ddf587944 100644 --- a/services/agents-api/internal/sandbox/config/microsandbox.go +++ b/services/agents-api/internal/sandbox/providers/microsandbox.go @@ -1,4 +1,4 @@ -package config +package providers import ( "errors" @@ -62,3 +62,11 @@ func configureMicrosandbox(entry Microsandbox, resources sandbox.Resources, resu result.BackendFingerprint = BackendFingerprint("microsandbox", entry.RuntimeHome) return nil } + +func buildMicrosandbox(c Config, 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") + } + return closeProvider, configureMicrosandbox(*c.Microsandbox, c.Specification.Resources, result) +} diff --git a/services/agents-api/internal/sandbox/config/probe.go b/services/agents-api/internal/sandbox/providers/probe.go similarity index 99% rename from services/agents-api/internal/sandbox/config/probe.go rename to services/agents-api/internal/sandbox/providers/probe.go index d3dd70398..c9ec52934 100644 --- a/services/agents-api/internal/sandbox/config/probe.go +++ b/services/agents-api/internal/sandbox/providers/probe.go @@ -1,4 +1,4 @@ -package config +package providers import ( "context" diff --git a/services/agents-api/internal/sandbox/config/probe_test.go b/services/agents-api/internal/sandbox/providers/probe_test.go similarity index 99% rename from services/agents-api/internal/sandbox/config/probe_test.go rename to services/agents-api/internal/sandbox/providers/probe_test.go index 52a7d492c..e226b5b79 100644 --- a/services/agents-api/internal/sandbox/config/probe_test.go +++ b/services/agents-api/internal/sandbox/providers/probe_test.go @@ -1,4 +1,4 @@ -package config +package providers import ( "crypto/sha256" diff --git a/services/agents-api/internal/sandbox/providers/registry.go b/services/agents-api/internal/sandbox/providers/registry.go new file mode 100644 index 000000000..f8e2d02ba --- /dev/null +++ b/services/agents-api/internal/sandbox/providers/registry.go @@ -0,0 +1,144 @@ +// Package providers is the explicit registration boundary for sandbox adapters. +// It performs read-only configuration work; it never allocates compute. +package providers + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" +) + +// Adapter describes configuration and transport independently of compute operations. +// Optional native operations remain interface assertions on SandboxProvider. +type Adapter struct { + Policy sandbox.DeploymentPolicy + ReplaceCredential func(sandbox.Selection, sandbox.Selection) sandbox.Selection + CredentialRequiresReset func(error) bool + CredentialUnconfirmed error + Restore func(sandbox.Selection) (sandbox.Selection, error) + ResolveChange func(sandbox.Selection, sandbox.Selection) sandbox.Selection + BuildLocal func(Config, *Built) (func(), error) + BuildDirect func(DirectConfig) (sandbox.SandboxProvider, error) + Credential bool + PublicOrigin bool + Mode string + Checkpoint bool + IdleSeconds, RetentionSeconds int64 + ValidateSpecification func(sandbox.DeploymentSpec) error + ValidateResources func(sandbox.Resources) error + Normalize func(sandbox.Selection) (sandbox.Selection, error) +} + +var adapters = map[string]Adapter{ + "docker": {Policy: docker.Policy(), BuildLocal: buildDocker, Mode: "nodes", ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, Normalize: nodeSelection(docker.ValidateSpecification)}, + "microsandbox": {Policy: microsandbox.Policy(), BuildLocal: buildMicrosandbox, Mode: "nodes", Checkpoint: true, IdleSeconds: 300, RetentionSeconds: 86400, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, Normalize: nodeSelection(microsandbox.ValidateSpecification)}, + "e2b": {Policy: e2b.Policy(), ReplaceCredential: e2b.ReplaceCredential, CredentialRequiresReset: e2b.CredentialRequiresReset, CredentialUnconfirmed: e2b.ErrRequestUnconfirmed, Restore: e2b.RestoreSelection, ResolveChange: e2b.ResolveChange, Credential: true, PublicOrigin: true, BuildDirect: buildE2B, Mode: "direct", ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, Normalize: e2b.NormalizeSelection}, +} + +func Lookup(kind string) (Adapter, error) { + a, ok := adapters[kind] + if !ok { + return Adapter{}, fmt.Errorf("%w: unsupported sandbox provider", sandbox.ErrInvalid) + } + return a, nil +} +func IsNode(kind string) bool { a, e := Lookup(kind); return e == nil && a.Mode == "nodes" } +func SupportsCheckpoint(kind string) bool { a, e := Lookup(kind); return e == nil && a.Checkpoint } +func ValidateSpecification(kind string, s sandbox.DeploymentSpec) error { + a, e := Lookup(kind) + if e != nil { + return e + } + return a.ValidateSpecification(s) +} +func ValidateResources(kind string, s sandbox.Resources) error { + a, e := Lookup(kind) + if e != nil { + return e + } + return a.ValidateResources(s) +} +func Normalize(s sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(s.Provider) + if e != nil { + return s, e + } + return a.Normalize(s) +} +func nodeSelection(validate func(sandbox.DeploymentSpec) error) func(sandbox.Selection) (sandbox.Selection, error) { + return func(s sandbox.Selection) (sandbox.Selection, error) { + if s.E2B != nil { + return s, sandbox.ErrInvalid + } + return s, validate(s.DeploymentSpec) + } +} + +// Description is derived once for both preview and persistence. Its fingerprint +// identifies a namespace, never mutable capacity or a credential. +type Description struct { + Mode, BackendFingerprint string + IdleSeconds, RetentionSeconds int64 +} + +func Describe(kind, installation string) (Description, error) { + a, e := Lookup(kind) + if e != nil { + return Description{}, e + } + namespace := a.Mode + if a.Mode == "direct" { + namespace = kind + } + digest := sha256.Sum256([]byte(kind + "\x00" + namespace + ":" + installation)) + return Description{a.Mode, hex.EncodeToString(digest[:]), a.IdleSeconds, a.RetentionSeconds}, nil +} + +func UsesCredential(kind string) bool { a, e := Lookup(kind); return e == nil && a.Credential } +func Restore(s sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(s.Provider) + if e != nil { + return s, e + } + if a.Restore != nil { + return a.Restore(s) + } + s.E2B = nil + return s, nil +} +func ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(next.Provider) + if e != nil { + return next, e + } + if a.ResolveChange != nil { + next = a.ResolveChange(next, previous) + } + return Normalize(next) +} + +func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(owner.Provider) + if e != nil { + return owner, e + } + if a.ReplaceCredential == nil { + return owner, sandbox.ErrInvalid + } + return a.ReplaceCredential(owner, candidate), nil +} + +// PythonDeploymentContract projects the same registered adapter policies into +// the node installer; no second provider list exists in another language. +func PythonDeploymentContract() string { + policies := make(map[string]sandbox.DeploymentPolicy, len(adapters)) + for kind, a := range adapters { + policies[kind] = a.Policy + } + return sandbox.PythonDeploymentContract(policies) +} diff --git a/services/agents-api/internal/sandbox/providers/registry_test.go b/services/agents-api/internal/sandbox/providers/registry_test.go new file mode 100644 index 000000000..4bef4efda --- /dev/null +++ b/services/agents-api/internal/sandbox/providers/registry_test.go @@ -0,0 +1,79 @@ +package providers + +import ( + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" +) + +func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { + installation := uuid.NewString() + for _, tc := range []struct { + kind, mode, namespace string + idle, retention int64 + checkpoint bool + }{ + {"docker", "nodes", "nodes", 0, 0, false}, + {"microsandbox", "nodes", "nodes", 300, 86400, true}, + {"e2b", "direct", "e2b", 0, 0, false}, + } { + t.Run(tc.kind, func(t *testing.T) { + d, err := Describe(tc.kind, installation) + if err != nil || d.Mode != tc.mode || d.IdleSeconds != tc.idle || d.RetentionSeconds != tc.retention || d.BackendFingerprint != BackendFingerprint(tc.kind, tc.namespace+":"+installation) { + t.Fatalf("wrong namespace or defaults: %+v %v", d, err) + } + a, err := Lookup(tc.kind) + if err != nil || SupportsCheckpoint(tc.kind) != tc.checkpoint || IsNode(tc.kind) != (tc.mode == "nodes") || (a.BuildLocal != nil) != (tc.mode == "nodes") || (a.BuildDirect != nil) != (tc.mode == "direct") { + t.Fatal("inconsistent construction/capability registration", err) + } + }) + } + if _, err := Describe("unregistered", installation); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("unknown kind accepted", err) + } +} + +func TestSelectionNormalizationAndCredentialInheritance(t *testing.T) { + input := sandbox.Selection{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "original-key", Template: "runtime:" + uuid.NewString()}} + normalized, err := Normalize(input) + if err != nil || normalized.E2B.APIURL != "https://api.e2b.app" || normalized.E2B.Domain != "e2b.app" { + t.Fatal("defaults not normalized", err) + } + if input.E2B.APIURL != "" || input.E2B.Domain != "" { + t.Fatal("normalization changed request") + } + normalized.Resources = sandbox.Resources{CPUs: 4, MemoryMiB: 4096} + request := input + request.E2B = &sandbox.E2BConfiguration{Template: input.E2B.Template} + next, err := ResolveChange(request, normalized) + if err != nil || next.Resources != normalized.Resources || next.E2B.APIKey != "original-key" || next.E2B.APIURL != normalized.E2B.APIURL || next.ReplacesCredential() { + t.Fatal("omitted values lost committed selection", err) + } + request.E2B.ReplaceCredential = true + if _, err := ResolveChange(request, normalized); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("explicit empty credential silently inherited", err) + } + if request.E2B.APIKey != "" || request.Resources != (sandbox.Resources{}) { + t.Fatal("resolution changed request") + } +} + +func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { + const kind = "contract-test-provider" + // Registration is test-local: production registrations are fixed, never plugins. + adapters[kind] = Adapter{Mode: "nodes", ValidateSpecification: func(sandbox.DeploymentSpec) error { return nil }, Normalize: nodeSelection(func(sandbox.DeploymentSpec) error { return nil })} + defer delete(adapters, kind) + s, err := Normalize(sandbox.Selection{Provider: kind}) + if err != nil || s.Provider != kind || !IsNode(kind) || SupportsCheckpoint(kind) { + t.Fatal("new entry did not follow shared boundary", err) + } + d, err := Describe(kind, uuid.NewString()) + if err != nil || d.Mode != "nodes" || d.IdleSeconds != 0 { + t.Fatal(d, err) + } + if _, err := Normalize(sandbox.Selection{Provider: kind, E2B: &sandbox.E2BConfiguration{APIKey: "wrong-provider"}}); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("mixed configuration admitted", err) + } +} diff --git a/services/agents-api/internal/sandbox/config/specification.go b/services/agents-api/internal/sandbox/providers/specification.go similarity index 88% rename from services/agents-api/internal/sandbox/config/specification.go rename to services/agents-api/internal/sandbox/providers/specification.go index b93050b0a..e80c0535c 100644 --- a/services/agents-api/internal/sandbox/config/specification.go +++ b/services/agents-api/internal/sandbox/providers/specification.go @@ -1,17 +1,19 @@ -package config +package providers -import "errors" +import ( + "errors" +) // Local paths belong to the node. Core owns reservation capacity, execution // resources and the immutable deployment release it enrolled with. func validateSpecification(c Config) error { - if c.Provider != "docker" && c.Provider != "microsandbox" { + if !IsNode(c.Provider) { return errors.New("nodes support Docker or microsandbox; E2B is managed by Core") } if c.Generation == 0 { return errors.New("node requires a deployment generation; obtain configuration from Core") } - if err := c.Specification.Validate(c.Provider); err != nil { + if err := ValidateSpecification(c.Provider, c.Specification); err != nil { return err } r := c.Specification.Runtime diff --git a/services/agents-api/internal/sandbox/providers/validation_test.go b/services/agents-api/internal/sandbox/providers/validation_test.go new file mode 100644 index 000000000..6810d4d84 --- /dev/null +++ b/services/agents-api/internal/sandbox/providers/validation_test.go @@ -0,0 +1,61 @@ +package providers + +import ( + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" +) + +func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) { + for _, tc := range []struct { + provider string + resources sandbox.Resources + param, message string + min, max *uint32 + }{ + {"docker", sandbox.Resources{}, "resources.cpus", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(1), validationBound(255)}, + {"docker", sandbox.Resources{CPUs: 1, MemoryMiB: 511}, "resources.memory_mib", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(512), validationBound(1048576)}, + {"microsandbox", sandbox.Resources{CPUs: 1, MemoryMiB: 512}, "resources.root_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil}, + {"microsandbox", sandbox.Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1024}, "resources.environment_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil}, + {"docker", sandbox.Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1}, "resources.root_disk_mib", "docker does not support independent disk capacity limits", validationBound(0), validationBound(0)}, + {"e2b", sandbox.Resources{CPUs: 1, MemoryMiB: 512, EnvironmentDiskMiB: 1}, "resources.environment_disk_mib", "e2b does not support independent disk capacity limits", validationBound(0), validationBound(0)}, + } { + err := ValidateResources(tc.provider, tc.resources) + var field *sandbox.ValidationError + if !errors.As(err, &field) || !errors.Is(err, sandbox.ErrInvalid) || err.Error() != sandbox.ErrInvalid.Error()+": "+tc.message || field.Param != tc.param { + t.Fatalf("wrong error: %#v", err) + } + for i, bound := range []*uint32{field.Min, field.Max} { + want := []*uint32{tc.min, tc.max}[i] + if (bound == nil) != (want == nil) || (bound != nil && *bound != *want) { + t.Fatal("wrong fixed bounds", field) + } + } + } + for _, tc := range []struct { + provider string + runtime *sandbox.RuntimeRelease + message string + }{ + {"docker", nil, "managed nodes require a pinned Runtime release"}, + {"docker", &sandbox.RuntimeRelease{}, "Runtime must reference one immutable distribution"}, + {"e2b", &sandbox.RuntimeRelease{}, "E2B Runtime is selected by its immutable template build"}, + } { + err := ValidateSpecification(tc.provider, sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 1, MemoryMiB: 512}, Runtime: tc.runtime}) + var field *sandbox.ValidationError + if !errors.As(err, &field) || field.Param != "runtime" || err.Error() != sandbox.ErrInvalid.Error()+": "+tc.message || !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal(err) + } + } + if err := ValidateResources("docker", sandbox.Resources{CPUs: 255, MemoryMiB: 1048576}); err != nil { + t.Fatal(err) + } + var field *sandbox.ValidationError + err := ValidateResources("private-provider", sandbox.Resources{CPUs: 1, MemoryMiB: 512}) + if errors.As(err, &field) || err.Error() != sandbox.ErrInvalid.Error()+": unsupported sandbox provider" { + t.Fatal("unknown provider reclassified", err) + } +} + +func validationBound(v uint32) *uint32 { return &v } diff --git a/services/agents-api/internal/sandbox/sandbox_provider.go b/services/agents-api/internal/sandbox/sandbox_provider.go index 019d3722a..531fd3d71 100644 --- a/services/agents-api/internal/sandbox/sandbox_provider.go +++ b/services/agents-api/internal/sandbox/sandbox_provider.go @@ -10,7 +10,7 @@ // of qualification: run the common contract tests and adapter-specific acceptance. // // Registration is explicit construction, not a global init-time registry. Node-local -// adapters register in sandbox/config.Build; Core's cmd/server managedSetup.provider +// adapters register in sandbox/providers; Core's managed setup // constructs direct adapters or node proxies. execution.RuntimeProvider binds the // selected adapter to installation, backend, deployment generation and node identity. // Keep vendor configuration at those construction boundaries; common lifecycle code diff --git a/services/agents-api/internal/sandbox/selection.go b/services/agents-api/internal/sandbox/selection.go new file mode 100644 index 000000000..5b42f87e4 --- /dev/null +++ b/services/agents-api/internal/sandbox/selection.go @@ -0,0 +1,46 @@ +package sandbox + +import "context" + +// APIKey is internal configuration. HTTP requests use a write-only DTO. +// TemplateBuild is set only by Core after it validates the candidate. +type E2BConfiguration struct { + ReplaceCredential bool `json:"-"` + APIKey string `json:"-"` + APIURL string `json:"api_url,omitempty"` + Domain string `json:"domain,omitempty"` + Template string `json:"template"` + TemplateBuild *TemplateBuild `json:"-"` +} + +// TemplateBuild is the fixed build as read by the validation that +// admitted a selection. RootDiskMiB is nil when E2B does not report it. +type TemplateBuild struct { + Status string + CPUs, MemoryMiB int32 + RootDiskMiB *int32 +} + +// Selection is the typed deployment configuration shared by preview and commit. +type Selection struct { + DeploymentSpec + ExpectedGeneration uint64 `json:"expected_generation"` + Provider string `json:"provider"` + E2B *E2BConfiguration `json:"e2b,omitempty"` +} + +func (s Selection) HasCredential() bool { return s.E2B != nil } + +func (s Selection) ReplacesCredential() bool { return s.E2B != nil && s.E2B.ReplaceCredential } + +// SelectionDiscoverer is an optional read-only native configuration capability. +// It resolves candidate configuration; loading retained ownership never calls it. +type SelectionDiscoverer interface { + DiscoverSelection(context.Context, Selection) (Selection, error) +} + +// CredentialVerifier verifies access to already owned resources without mutation. +// Replacing credentials requires this capability and a shared CallFence. +type CredentialVerifier interface { + VerifyCredential(context.Context, []Reference) error +} diff --git a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go index ac25698d7..6b6c102c8 100644 --- a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go +++ b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go @@ -58,7 +58,7 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { }) } t.Cleanup(stop) - selection := store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} + selection := store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} if _, err := worker.InitializeSandboxDeployment(t.Context(), selection); err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/archive_cancellation_test.go b/services/agents-api/internal/store/archive_cancellation_test.go index ad4044573..1f68324bd 100644 --- a/services/agents-api/internal/store/archive_cancellation_test.go +++ b/services/agents-api/internal/store/archive_cancellation_test.go @@ -17,6 +17,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -44,7 +45,7 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { if err := writer.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { t.Fatal(err) } - if _, err := writer.InitializeSandboxDeployment(t.Context(), installation, store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture", Template: "runtime:" + uuid.NewString()}}); err != nil { + if _, err := writer.InitializeSandboxDeployment(t.Context(), installation, store.SandboxDeploymentSetupRequest{DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture", Template: "runtime:" + uuid.NewString()}}); err != nil { t.Fatal(err) } projectID := uuid.NewString() diff --git a/services/agents-api/internal/store/runtime_node_deployment.go b/services/agents-api/internal/store/runtime_node_deployment.go index 6eb8b275b..1da2187b4 100644 --- a/services/agents-api/internal/store/runtime_node_deployment.go +++ b/services/agents-api/internal/store/runtime_node_deployment.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -14,7 +15,7 @@ func configureRuntimeManager(ctx context.Context, q *sqlc.Queries, previous sqlc if selected.ProviderKind == "" { return nil } - if selected.ProviderKind != "docker" && selected.ProviderKind != "microsandbox" { + if !providers.IsNode(selected.ProviderKind) { return ErrInvalidInput } installation, err := parseConnectionGeneration(selected.InstallationID) diff --git a/services/agents-api/internal/store/runtime_node_generations.go b/services/agents-api/internal/store/runtime_node_generations.go index 7f4d0061c..057d2262d 100644 --- a/services/agents-api/internal/store/runtime_node_generations.go +++ b/services/agents-api/internal/store/runtime_node_generations.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -21,7 +22,7 @@ func nodeGenerationSpec(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDepl if err != nil { return spec, err } - if row.ProviderKind != d.ProviderKind || json.Unmarshal(row.Specification, &spec) != nil || spec.Validate(d.ProviderKind) != nil { + if row.ProviderKind != d.ProviderKind || json.Unmarshal(row.Specification, &spec) != nil || providers.ValidateSpecification(d.ProviderKind, spec) != nil { return spec, ErrRuntimeSpecificationMismatch } return spec, nil diff --git a/services/agents-api/internal/store/sandbox_deployment_mutations.go b/services/agents-api/internal/store/sandbox_deployment_mutations.go index 2d60b8729..01b4e46a2 100644 --- a/services/agents-api/internal/store/sandbox_deployment_mutations.go +++ b/services/agents-api/internal/store/sandbox_deployment_mutations.go @@ -2,86 +2,29 @@ package store import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "math" - "strings" - "unicode" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/google/uuid" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) var ErrSandboxCredentialUnavailable = errors.New("sandbox credential encryption is unavailable") -// APIKey is internal configuration. HTTP requests use a write-only DTO. -// TemplateBuild is set only by Core after it validates the candidate. -type SandboxE2BConfiguration struct { - ReplaceCredential bool `json:"-"` - APIKey string `json:"-"` - APIURL string `json:"api_url,omitempty"` - Domain string `json:"domain,omitempty"` - Template string `json:"template"` - TemplateBuild *SandboxE2BTemplateBuild `json:"-"` -} - -// SandboxE2BTemplateBuild is the fixed build as read by the validation that -// admitted a selection. RootDiskMiB is nil when E2B does not report it. -type SandboxE2BTemplateBuild struct { - Status string - CPUs, MemoryMiB int32 - RootDiskMiB *int32 -} type SandboxDeploymentUpdateRequest struct { SandboxDeploymentSetupRequest ExpectedGeneration uint64 `json:"expected_generation"` } -// E2BResourcesPending reports an E2B selection that omitted resources. Core -// fills them from the validated template build before any write. -func E2BResourcesPending(input SandboxDeploymentSetupRequest) bool { - return input.Provider == "e2b" && input.Resources == (sandbox.Resources{}) -} - func validateSandboxSelection(input SandboxDeploymentSetupRequest) error { - if E2BResourcesPending(input) { - if input.Runtime != nil { - return sandboxConfigurationError(&sandbox.ValidationError{Param: "runtime", Message: "invalid sandbox configuration: E2B Runtime is selected by its immutable template build"}) - } - } else if err := input.DeploymentSpec.Validate(input.Provider); err != nil { + _, err := providers.Normalize(input) + if err != nil { return sandboxConfigurationError(err) } - switch input.Provider { - case "docker", "microsandbox": - if input.E2B != nil { - return ErrInvalidInput - } - case "e2b": - if input.E2B == nil || input.E2B.APIKey == "" || len(input.E2B.APIKey) > 4096 || strings.IndexFunc(input.E2B.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { - return ErrInvalidInput - } - if _, _, err := e2b.NormalizeEndpoint(input.E2B.APIURL, input.E2B.Domain); err != nil { - return ErrInvalidInput - } - template, build, ok := strings.Cut(input.E2B.Template, ":") - id, err := uuid.Parse(build) - if !ok || template == "" || len(template) > 128 || err != nil || id == uuid.Nil || id.String() != build { - return ErrInvalidInput - } - for _, c := range template { - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { - return ErrInvalidInput - } - } - default: - return ErrInvalidInput - } return nil } @@ -102,9 +45,11 @@ func (s *Store) sandboxSelectionEqual(d sqlc.RuntimeDeployment, input SandboxDep if d.E2bTemplate != input.E2B.Template { return false, nil } - savedAPI, savedDomain, savedErr := e2b.NormalizeEndpoint(d.E2bApiUrl, d.E2bDomain) - inputAPI, inputDomain, inputErr := e2b.NormalizeEndpoint(input.E2B.APIURL, input.E2B.Domain) - if savedErr != nil || inputErr != nil || savedAPI != inputAPI || savedDomain != inputDomain { + saved := input + saved.E2B = &sandbox.E2BConfiguration{APIKey: input.E2B.APIKey, Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain} + saved, savedErr := providers.Normalize(saved) + normalized, inputErr := providers.Normalize(input) + if savedErr != nil || inputErr != nil || saved.E2B.APIURL != normalized.E2B.APIURL || saved.E2B.Domain != normalized.E2B.Domain { return false, nil } key, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, runtimeUUID(d.InstallationID), uint64(d.Generation)) @@ -116,25 +61,24 @@ func (s *Store) sandboxSelectionEqual(d sqlc.RuntimeDeployment, input SandboxDep func (s *Store) saveSandboxSelection(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment, input SandboxDeploymentSetupRequest) error { // Only a complete specification is stored, including derived E2B resources. - if err := input.DeploymentSpec.Validate(input.Provider); err != nil { + if err := providers.ValidateSpecification(input.Provider, input.DeploymentSpec); err != nil { return sandboxConfigurationError(err) } if d.Generation == math.MaxInt64 { return ErrSandboxDeploymentConflict } generation := d.Generation + 1 - mode := "nodes" - namespace := "nodes:" + runtimeUUID(d.InstallationID) - if input.Provider == "e2b" { - mode = "direct" - namespace = "e2b:" + runtimeUUID(d.InstallationID) + description, err := providers.Describe(input.Provider, runtimeUUID(d.InstallationID)) + if err != nil { + return sandboxConfigurationError(err) } - digest := sha256.Sum256([]byte(input.Provider + "\x00" + namespace)) - params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: input.Provider, BackendFingerprint: hex.EncodeToString(digest[:]), Generation: generation, Mode: mode} - params.Specification, _ = json.Marshal(input.DeploymentSpec) - if input.Provider == "microsandbox" { - params.IdleSeconds, params.RetentionSeconds = 300, 86400 + normalized, err := providers.Normalize(input) + if err != nil { + return sandboxConfigurationError(err) } + input = normalized + params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: input.Provider, BackendFingerprint: description.BackendFingerprint, Generation: generation, Mode: description.Mode, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds} + params.Specification, _ = json.Marshal(input.DeploymentSpec) if input.E2B != nil { encrypted, err := s.credentialCipher.SealSandboxDeployment([]byte(input.E2B.APIKey), runtimeUUID(d.InstallationID), uint64(generation)) if err != nil { @@ -149,7 +93,7 @@ func (s *Store) saveSandboxSelection(ctx context.Context, q *sqlc.Queries, d sql return q.InitializeSandboxDeployment(ctx, params) } -func templateBuildColumns(build *SandboxE2BTemplateBuild) sqlc.RecordSandboxTemplateBuildParams { +func templateBuildColumns(build *sandbox.TemplateBuild) sqlc.RecordSandboxTemplateBuildParams { var params sqlc.RecordSandboxTemplateBuildParams if build != nil { params.E2bTemplateBuildStatus = pgtype.Text{String: build.Status, Valid: true} diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go index 74aa89ce1..c6a457441 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup.go @@ -4,26 +4,21 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "net" "net/url" "strconv" "strings" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) var ErrSandboxDeploymentConflict = errors.New("sandbox deployment is already configured differently") -type SandboxDeploymentSetupRequest struct { - sandbox.DeploymentSpec - ExpectedGeneration uint64 `json:"expected_generation"` - Provider string `json:"provider"` - E2B *SandboxE2BConfiguration `json:"e2b,omitempty"` -} +type SandboxDeploymentSetupRequest = sandbox.Selection // SandboxSetup is the immutable configuration selected by the deployment admin. // An empty Provider means that Web setup has not yet selected an adapter. @@ -33,7 +28,7 @@ type SandboxSetup struct { Generation uint64 Mode string AdmissionPaused bool - E2B *SandboxE2BConfiguration + E2B *sandbox.E2BConfiguration IdleSeconds, RetentionSeconds int64 } @@ -56,20 +51,20 @@ func (s *Store) sandboxSetup(d sqlc.RuntimeDeployment) (SandboxSetup, error) { return SandboxSetup{}, err } if d.ProviderKind != "" && !unspecifiedNodeDeployment(d) { - if err := result.Specification.Validate(d.ProviderKind); err != nil { + if err := providers.ValidateSpecification(d.ProviderKind, result.Specification); err != nil { return SandboxSetup{}, err } } - if d.ProviderKind == "e2b" { + if providers.UsesCredential(d.ProviderKind) { credential, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, result.InstallationID, result.Generation) if err != nil { return SandboxSetup{}, ErrSandboxCredentialUnavailable } - apiURL, domain, err := e2b.NormalizeEndpoint(d.E2bApiUrl, d.E2bDomain) + selection, err := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, DeploymentSpec: result.Specification, E2B: &sandbox.E2BConfiguration{APIKey: string(credential), Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) if err != nil { return SandboxSetup{}, ErrSandboxDeploymentConflict } - result.E2B = &SandboxE2BConfiguration{APIKey: string(credential), Template: d.E2bTemplate, APIURL: apiURL, Domain: domain} + result.E2B = selection.E2B } return result, nil } @@ -169,8 +164,9 @@ func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDe result.SpecificationDigest = spec.Digest(d.ProviderKind) } } - if d.ProviderKind == "e2b" { - apiURL, domain, _ := e2b.NormalizeEndpoint(d.E2bApiUrl, d.E2bDomain) + if providers.UsesCredential(d.ProviderKind) { + selection, _ := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, E2B: &sandbox.E2BConfiguration{Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) + apiURL, domain := selection.E2B.APIURL, selection.E2B.Domain result.E2B = &SandboxE2BView{Template: d.E2bTemplate, APIURL: apiURL, Domain: domain, CredentialConfigured: len(d.E2bCredential) > 0, TemplateBuild: SandboxE2BTemplateBuildView{Resources: SandboxTemplateResources{ CPUs: optionalInt32(d.E2bTemplateCpus), MemoryMiB: optionalInt32(d.E2bTemplateMemoryMib), RootDiskMiB: optionalInt32(d.E2bTemplateRootDiskMib)}}} @@ -179,7 +175,7 @@ func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDe result.E2B.TemplateBuild.Status = &status } } - if d.ProviderKind == "microsandbox" { + if providers.SupportsCheckpoint(d.ProviderKind) { result.Suspension = &SandboxSuspensionView{IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds} } return result diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_test.go index e17bb2e29..1d0784ab4 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go @@ -10,11 +10,12 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" ) func e2bSelection() SandboxDeploymentSetupRequest { - return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &SandboxE2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} + return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} } func TestSandboxE2BEndpointPersistenceAndOnlineSwitch(t *testing.T) { diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go index 6cc559427..94b580d43 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go @@ -141,7 +141,7 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { return store.RuntimeDeploymentView{} } empty = reset(2) - cloud := store.SandboxDeploymentSetupRequest{ExpectedGeneration: empty.Generation, DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &store.SandboxE2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} + cloud := store.SandboxDeploymentSetupRequest{ExpectedGeneration: empty.Generation, DeploymentSpec: store.SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-api-key", Template: "runtime:" + uuid.NewString()}} if _, err := w.InitializeSandboxDeployment(t.Context(), cloud); err != nil { t.Fatal("setup after unconfigured publication", err) } diff --git a/services/agents-api/internal/store/sandbox_deployment_view_test.go b/services/agents-api/internal/store/sandbox_deployment_view_test.go index 2e81f0fa4..b350bda1b 100644 --- a/services/agents-api/internal/store/sandbox_deployment_view_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_view_test.go @@ -31,7 +31,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { } disk := int32(24063) input.Resources = sandbox.Resources{CPUs: 2, MemoryMiB: 2048} - input.E2B.TemplateBuild = &SandboxE2BTemplateBuild{Status: "ready", CPUs: 2, MemoryMiB: 2048, RootDiskMiB: &disk} + input.E2B.TemplateBuild = &sandbox.TemplateBuild{Status: "ready", CPUs: 2, MemoryMiB: 2048, RootDiskMiB: &disk} view, err := w.InitializeSandboxDeployment(t.Context(), id, input) if err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/store/sandbox_generations.go b/services/agents-api/internal/store/sandbox_generations.go index 3851b2690..f66db7ff1 100644 --- a/services/agents-api/internal/store/sandbox_generations.go +++ b/services/agents-api/internal/store/sandbox_generations.go @@ -7,6 +7,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -19,29 +20,15 @@ func (s *Store) ClassifySandboxDeploymentChange(ctx context.Context, installatio if err := checkSandboxSwitch(ctx, q, d, installation, input); err != nil { return err } - if input.E2B != nil { - copy := *input.E2B - copy.ReplaceCredential = copy.ReplaceCredential || copy.APIKey != "" - input.E2B = © - // Omitted endpoint selectors retain the committed E2B connection. - if copy.APIURL == "" && copy.Domain == "" { - input.E2B.APIURL, input.E2B.Domain = d.E2bApiUrl, d.E2bDomain - } - if copy.APIKey == "" && !copy.ReplaceCredential { - key, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, installation, uint64(d.Generation)) - if err != nil { - return ErrSandboxCredentialUnavailable - } - input.E2B.APIKey = string(key) - } - if input.E2B.Template == d.E2bTemplate && E2BResourcesPending(input.SandboxDeploymentSetupRequest) { - var saved sandbox.DeploymentSpec - if err := json.Unmarshal(d.Specification, &saved); err != nil { - return err - } - input.Resources = saved.Resources - } + previous, err := s.sandboxSetup(d) + if err != nil { + return err + } + resolved, err := providers.ResolveChange(input.SandboxDeploymentSetupRequest, sandbox.Selection{Provider: previous.Provider, DeploymentSpec: previous.Specification, E2B: previous.E2B}) + if err != nil { + return sandboxConfigurationError(err) } + input.SandboxDeploymentSetupRequest = resolved if err := validateSandboxSelection(input.SandboxDeploymentSetupRequest); err != nil { return err } @@ -119,8 +106,8 @@ func (s *Store) SandboxGenerationPage(ctx context.Context, after int64) ([]Sandb if err := json.Unmarshal(r.Specification, &v.Specification); err != nil { return nil, err } - if r.ProviderKind == "e2b" { - v.E2B = &SandboxE2BConfiguration{Template: r.E2bTemplate, APIURL: r.E2bApiUrl, Domain: r.E2bDomain} + if providers.UsesCredential(r.ProviderKind) { + v.E2B = &sandbox.E2BConfiguration{Template: r.E2bTemplate, APIURL: r.E2bApiUrl, Domain: r.E2bDomain} } result = append(result, v) } diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/agents-api/internal/store/sandbox_specification.go index cb39081a9..a18d7a98f 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/agents-api/internal/store/sandbox_specification.go @@ -1,13 +1,12 @@ package store import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" ) // SandboxConfigurationError contains only validated, non-secret configuration diagnostics. @@ -35,18 +34,15 @@ func SandboxSetupForSelection(installationID string, input SandboxDeploymentSetu if err := validateSandboxSelection(input); err != nil { return SandboxSetup{}, err } - result := SandboxSetup{InstallationID: installationID, Provider: input.Provider, - Mode: "nodes", Specification: input.DeploymentSpec, E2B: input.E2B} - namespace := "nodes:" + installationID - if input.Provider == "e2b" { - result.Mode = "direct" - namespace = "e2b:" + installationID + normalized, err := providers.Normalize(input) + if err != nil { + return SandboxSetup{}, sandboxConfigurationError(err) } - if input.Provider == "microsandbox" { - result.IdleSeconds, result.RetentionSeconds = 300, 86400 + description, err := providers.Describe(input.Provider, installationID) + if err != nil { + return SandboxSetup{}, sandboxConfigurationError(err) } - digest := sha256.Sum256([]byte(input.Provider + "\x00" + namespace)) - result.BackendFingerprint = hex.EncodeToString(digest[:]) + result := SandboxSetup{InstallationID: installationID, Provider: input.Provider, Mode: description.Mode, Specification: normalized.DeploymentSpec, E2B: normalized.E2B, BackendFingerprint: description.BackendFingerprint, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds} return result, nil } @@ -55,12 +51,12 @@ func SandboxSetupForSelection(installationID string, input SandboxDeploymentSetu // retained nodes may reconnect to drain resources, but it cannot create // sandboxes or enroll nodes until an administrator replaces the selection. func unspecifiedNodeDeployment(d sqlc.RuntimeDeployment) bool { - return d.WebManaged && d.Mode == "nodes" && (d.ProviderKind == "docker" || d.ProviderKind == "microsandbox") && string(d.Specification) == "{}" + return d.WebManaged && d.Mode == "nodes" && providers.IsNode(d.ProviderKind) && string(d.Specification) == "{}" } func deploymentSpecification(d sqlc.RuntimeDeployment) (sandbox.DeploymentSpec, error) { var spec sandbox.DeploymentSpec - if json.Unmarshal(d.Specification, &spec) != nil || spec.Validate(d.ProviderKind) != nil { + if json.Unmarshal(d.Specification, &spec) != nil || providers.ValidateSpecification(d.ProviderKind, spec) != nil { return spec, ErrRuntimeSpecificationMismatch } return spec, nil diff --git a/services/agents-api/internal/store/sandbox_specification_store_test.go b/services/agents-api/internal/store/sandbox_specification_store_test.go index c6c370246..c2141e64a 100644 --- a/services/agents-api/internal/store/sandbox_specification_store_test.go +++ b/services/agents-api/internal/store/sandbox_specification_store_test.go @@ -60,6 +60,10 @@ func TestSandboxSpecificationRoundTripAndFileConfigurationCannotOverride(t *test if err != nil || !reflect.DeepEqual(setup.Specification, input.DeploymentSpec) || view.Specification == nil || !reflect.DeepEqual(*view.Specification, input.DeploymentSpec) || view.SpecificationDigest != input.DeploymentSpec.Digest(provider) { t.Fatal("saved deployment lost its resources or Runtime provenance", err) } + preview, err := SandboxSetupForSelection(view.InstallationID, input) + if err != nil || preview.Mode != setup.Mode || preview.BackendFingerprint != setup.BackendFingerprint || preview.IdleSeconds != setup.IdleSeconds || preview.RetentionSeconds != setup.RetentionSeconds || !reflect.DeepEqual(preview.E2B, setup.E2B) { + t.Fatal("preview and persisted normalized deployment disagree", err) + } input.ExpectedGeneration = view.Generation retry, err := w.InitializeSandboxDeployment(t.Context(), view.InstallationID, input) if err != nil || !reflect.DeepEqual(retry, view) { @@ -432,3 +436,25 @@ func TestE2BAdmitsNothingWhileThePublicURLIsLoopback(t *testing.T) { t.Fatal(err) } } + +func TestDatabaseDoesNotEnumerateProviderRegistrations(t *testing.T) { + s, w, view, input := webSpecificationFixture(t, "docker") + tx, err := s.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(t.Context()) + if _, err = tx.Exec(t.Context(), "UPDATE runtime_deployment SET provider_kind='new-adapter' WHERE singleton=true"); err != nil { + t.Fatal("database enumerated provider implementations", err) + } + // Roll back before calling the serialized Store, which still rejects unknown + // registrations even though persistence can represent a new adapter. + if err = tx.Rollback(t.Context()); err != nil { + t.Fatal(err) + } + input.Provider = "new-adapter" + input.ExpectedGeneration = view.Generation + if _, err = w.InitializeSandboxDeployment(t.Context(), view.InstallationID, input); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unknown adapter reached persistence", err) + } +} diff --git a/services/agents-api/migrations/000089_provider_registration.sql b/services/agents-api/migrations/000089_provider_registration.sql new file mode 100644 index 000000000..2f8207e0d --- /dev/null +++ b/services/agents-api/migrations/000089_provider_registration.sql @@ -0,0 +1,73 @@ +-- +goose Up +-- Provider registration owns supported kinds, modes and native configuration. +-- Persistence enforces shape and ownership without enumerating implementations. +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_provider_kind_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_provider_kind_check + CHECK (provider_kind = '' OR provider_kind ~ '^[a-z][a-z0-9_-]{0,63}$'); +ALTER TABLE runtime_deployment_generations DROP CONSTRAINT runtime_deployment_generations_provider_kind_check; +ALTER TABLE runtime_deployment_generations ADD CONSTRAINT runtime_deployment_generations_provider_kind_check + CHECK (provider_kind ~ '^[a-z][a-z0-9_-]{0,63}$'); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_setup_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + NOT web_managed OR (local_node_id IS NULL AND ( + (provider_kind = '' AND mode = '' AND generation >= 0 AND idle_seconds = 0 AND retention_seconds = 0) OR + (provider_kind <> '' AND mode IN ('nodes','direct') AND generation > 0 AND + ((idle_seconds = 0 AND retention_seconds = 0) OR (idle_seconds > 0 AND retention_seconds > 0))) + )) +); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_check CHECK ( + (provider_kind <> '' AND e2b_template <> '' AND e2b_credential IS NOT NULL) OR + (e2b_template = '' AND e2b_credential IS NULL) +); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_template_build_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_template_build_check CHECK ( + e2b_template <> '' OR (e2b_template_build_status IS NULL AND e2b_template_cpus IS NULL AND + e2b_template_memory_mib IS NULL AND e2b_template_root_disk_mib IS NULL) +); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_endpoint_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_endpoint_check CHECK ( + (e2b_api_url = '' AND e2b_domain = '') OR + (e2b_template <> '' AND e2b_api_url <> '' AND e2b_domain <> '') +); +ALTER TABLE runtime_deployment_generations DROP CONSTRAINT runtime_deployment_generation_e2b_endpoint_check; +ALTER TABLE runtime_deployment_generations ADD CONSTRAINT runtime_deployment_generation_e2b_endpoint_check CHECK ( + (e2b_api_url = '' AND e2b_domain = '') OR + (e2b_template <> '' AND e2b_api_url <> '' AND e2b_domain <> '') +); + +-- +goose Down +-- Narrowing registration cannot silently invalidate retained resource ownership. +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_provider_kind_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_provider_kind_check CHECK (provider_kind IN ('','docker','microsandbox','e2b')); +ALTER TABLE runtime_deployment_generations DROP CONSTRAINT runtime_deployment_generations_provider_kind_check; +ALTER TABLE runtime_deployment_generations ADD CONSTRAINT runtime_deployment_generations_provider_kind_check CHECK (provider_kind IN ('docker','microsandbox','e2b')); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_setup_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + NOT web_managed OR (local_node_id IS NULL AND ( + (provider_kind='' AND mode='' AND generation>=0 AND idle_seconds=0 AND retention_seconds=0) OR + (provider_kind='docker' AND mode='nodes' AND generation>0 AND idle_seconds=0 AND retention_seconds=0) OR + (provider_kind='microsandbox' AND mode='nodes' AND generation>0 AND idle_seconds>0 AND retention_seconds>0) OR + (provider_kind='e2b' AND mode='direct' AND generation>0 AND idle_seconds=0 AND retention_seconds=0) + )) +); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_check CHECK ( + (provider_kind='e2b' AND e2b_template<>'' AND e2b_credential IS NOT NULL) OR + (provider_kind<>'e2b' AND e2b_template='' AND e2b_credential IS NULL) +); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_template_build_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_template_build_check CHECK ( + provider_kind = 'e2b' OR (e2b_template_build_status IS NULL AND e2b_template_cpus IS NULL AND + e2b_template_memory_mib IS NULL AND e2b_template_root_disk_mib IS NULL) +); +ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_endpoint_check; +ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_endpoint_check CHECK ( + (provider_kind = 'e2b' AND ((e2b_api_url = '' AND e2b_domain = '') OR (e2b_api_url <> '' AND e2b_domain <> ''))) OR + (provider_kind <> 'e2b' AND e2b_api_url = '' AND e2b_domain = '') +); +ALTER TABLE runtime_deployment_generations DROP CONSTRAINT runtime_deployment_generation_e2b_endpoint_check; +ALTER TABLE runtime_deployment_generations ADD CONSTRAINT runtime_deployment_generation_e2b_endpoint_check CHECK ( + (provider_kind = 'e2b' AND ((e2b_api_url = '' AND e2b_domain = '') OR (e2b_api_url <> '' AND e2b_domain <> ''))) OR + (provider_kind <> 'e2b' AND e2b_api_url = '' AND e2b_domain = '') +); From f957d414e8c42e5d317d0390d963fb2e5d7930e8 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 01:10:32 +0800 Subject: [PATCH 2/5] fix(sandbox): keep generated policies and normalized reset state consistent --- apps/docs/content/docs/sandbox-provider.mdx | 3 +++ apps/docs/content/guide-sources.json | 4 ++-- docs/sandbox-provider.md | 3 +++ .../internal/db/queries/sandbox_reset.sql | 2 +- .../internal/db/sqlc/sandbox_reset.sql.go | 2 +- .../agents-api/internal/sandbox/deployment.go | 17 ++++++++++++++ .../internal/sandbox/deployment_contract.go | 7 ++++-- .../internal/sandbox/docker/selection.go | 10 +-------- .../internal/sandbox/e2b/deployment.go | 19 +++++----------- .../internal/sandbox/e2b/provider.go | 12 +++------- .../sandbox/microsandbox/deployment.go | 10 +-------- .../internal/sandbox/providers/registry.go | 22 ++++++++++++++++--- .../store/sandbox_generations_test.go | 6 ++--- .../tools/microsandbox-provider/go.mod | 5 ++++- .../tools/microsandbox-provider/go.sum | 2 ++ 15 files changed, 71 insertions(+), 53 deletions(-) diff --git a/apps/docs/content/docs/sandbox-provider.mdx b/apps/docs/content/docs/sandbox-provider.mdx index 057da9066..8e221c313 100644 --- a/apps/docs/content/docs/sandbox-provider.mdx +++ b/apps/docs/content/docs/sandbox-provider.mdx @@ -188,6 +188,9 @@ For a new implementation: `CredentialVerifier` when needed. 3. Register its constructor, policies and defaults in `providers/registry.go`. Node proxy identity and checkpoint advertisement consume this same entry. + The installer projection uses those registered policies and the common field + bounds in `sandbox/deployment_contract.go`; regenerate it with + `go run ./services/agents-api/cmd/specification-contract -write`. 4. If new configuration fields are necessary, extend the typed `sandbox.Selection` envelope and its dedicated encrypted persistence fields, API DTO and operator client. Do not replace typed configuration with unrestricted JSON. Field codecs diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index b38fbe41f..c6ead72fd 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -28,7 +28,7 @@ "contracts/agents-api/harness-onboarding.md": "18d3ccfe989e2caa57f220430157c6b73d87f5fc8e31aba8cada0b7ab9653a8c", "docs/runtime-bootstrap.md": "0d49aed73b298039e04453e6f465b0e925fb2227fa35d4206820bb3acbd8df39", "docs/runtime-protocol.md": "0e968e509236369dd4e840b94d9083372830c45b695ae3a71a56eba5bae01347", - "docs/sandbox-provider.md": "912172c0c517ef00b521c2140983a2d40d4723ffec1e1239844f429fb2d29373", + "docs/sandbox-provider.md": "4d47b234a457f874f3ff7e61a7f5da6fc8b75b0c6df0ce0ce9ead1c07afdfb3e", "apps/docs/scripts/guides.json": "3c3768fb94fd3d42464d4ce8c55724b9ba8360133c7cc19d513d8ec83a8e034f" }, "outputs": { @@ -59,6 +59,6 @@ "content/docs/harness-onboarding.mdx": "e7a172793d81e0dee89398ea34351617c66435a13f511f7fb29df3b6a8967fbf", "content/docs/runtime-bootstrap.mdx": "58982955811c9ad46a9fc04d8d2aef5a762fc9d25d5833f88aa75ee3fc46523f", "content/docs/runtime-protocol.mdx": "75e7c85a2085cccedf453e8ee751316ba2ee22ed0559e4018ee82df1af805f48", - "content/docs/sandbox-provider.mdx": "9c905ee047c6eeea321923f6b41baf6b55246d4ea0e3e5f0a43b7103dceffc9f" + "content/docs/sandbox-provider.mdx": "9db833fe9ff3f30a65451f80d7251348695a08e7830f9e95871739a710529818" } } diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 635a17d63..60a6af2d7 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -185,6 +185,9 @@ For a new implementation: `CredentialVerifier` when needed. 3. Register its constructor, policies and defaults in `providers/registry.go`. Node proxy identity and checkpoint advertisement consume this same entry. + The installer projection uses those registered policies and the common field + bounds in `sandbox/deployment_contract.go`; regenerate it with + `go run ./services/agents-api/cmd/specification-contract -write`. 4. If new configuration fields are necessary, extend the typed `sandbox.Selection` envelope and its dedicated encrypted persistence fields, API DTO and operator client. Do not replace typed configuration with unrestricted JSON. Field codecs diff --git a/services/agents-api/internal/db/queries/sandbox_reset.sql b/services/agents-api/internal/db/queries/sandbox_reset.sql index 01afae0f9..2ca1e9af6 100644 --- a/services/agents-api/internal/db/queries/sandbox_reset.sql +++ b/services/agents-api/internal/db/queries/sandbox_reset.sql @@ -20,7 +20,7 @@ WHERE singleton = true; -- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', specification = '{}', idle_seconds = 0, retention_seconds = 0, - e2b_template = '', e2b_credential = NULL, e2b_template_build_status = NULL, + e2b_template = '', e2b_credential = NULL, e2b_api_url = '', e2b_domain = '', e2b_template_build_status = NULL, e2b_template_cpus = NULL, e2b_template_memory_mib = NULL, e2b_template_root_disk_mib = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, admission_paused = false, reset_clear = NULL, reset_requested_at = NULL, diff --git a/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go b/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go index fabff44d7..2f63a4933 100644 --- a/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go +++ b/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go @@ -26,7 +26,7 @@ func (q *Queries) CancelSandboxReset(ctx context.Context) error { const completeSandboxReset = `-- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', specification = '{}', idle_seconds = 0, retention_seconds = 0, - e2b_template = '', e2b_credential = NULL, e2b_template_build_status = NULL, + e2b_template = '', e2b_credential = NULL, e2b_api_url = '', e2b_domain = '', e2b_template_build_status = NULL, e2b_template_cpus = NULL, e2b_template_memory_mib = NULL, e2b_template_root_disk_mib = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, admission_paused = false, reset_clear = NULL, reset_requested_at = NULL, diff --git a/services/agents-api/internal/sandbox/deployment.go b/services/agents-api/internal/sandbox/deployment.go index 00ee27019..544820913 100644 --- a/services/agents-api/internal/sandbox/deployment.go +++ b/services/agents-api/internal/sandbox/deployment.go @@ -87,6 +87,23 @@ type DeploymentSpec struct { Runtime *RuntimeRelease `json:"runtime,omitempty"` } +// ValidatePolicy applies a registered adapter's rules without knowing its kind. +func (s DeploymentSpec) ValidatePolicy(provider string, policy DeploymentPolicy) error { + if err := s.Resources.ValidatePolicy(provider, policy); err != nil { + return err + } + if !policy.Runtime { + if s.Runtime != nil { + return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: %s", ErrInvalid, policy.RuntimeError)} + } + return nil + } + if s.Runtime == nil { + return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", ErrInvalid)} + } + return s.Runtime.Validate() +} + func (s DeploymentSpec) Digest(provider string) string { raw, _ := json.Marshal(struct { Provider string `json:"provider"` diff --git a/services/agents-api/internal/sandbox/deployment_contract.go b/services/agents-api/internal/sandbox/deployment_contract.go index c21d79cb9..4350100d4 100644 --- a/services/agents-api/internal/sandbox/deployment_contract.go +++ b/services/agents-api/internal/sandbox/deployment_contract.go @@ -22,9 +22,12 @@ type runtimeRule struct { Name string `json:"name"` Pattern string `json:"pattern"` } + +// DeploymentPolicy is declared by an adapter and projected to the installer. type DeploymentPolicy struct { - Disk bool `json:"disk"` - Runtime bool `json:"runtime"` + RuntimeError string `json:"-"` + Disk bool `json:"disk"` + Runtime bool `json:"runtime"` } var resourceContract = []resourceRule{ diff --git a/services/agents-api/internal/sandbox/docker/selection.go b/services/agents-api/internal/sandbox/docker/selection.go index f732ed25c..53529093d 100644 --- a/services/agents-api/internal/sandbox/docker/selection.go +++ b/services/agents-api/internal/sandbox/docker/selection.go @@ -1,8 +1,6 @@ package docker import ( - "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) @@ -10,11 +8,5 @@ func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Runtime func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("docker", Policy()) } func ValidateSpecification(s sandbox.DeploymentSpec) error { - if err := ValidateResources(s.Resources); err != nil { - return err - } - if s.Runtime == nil { - return &sandbox.ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", sandbox.ErrInvalid)} - } - return s.Runtime.Validate() + return s.ValidatePolicy("docker", Policy()) } diff --git a/services/agents-api/internal/sandbox/e2b/deployment.go b/services/agents-api/internal/sandbox/e2b/deployment.go index 6c6326e42..97eb36898 100644 --- a/services/agents-api/internal/sandbox/e2b/deployment.go +++ b/services/agents-api/internal/sandbox/e2b/deployment.go @@ -3,7 +3,6 @@ package e2b import ( "context" "errors" - "fmt" "math" "strings" "unicode" @@ -12,19 +11,13 @@ import ( "github.com/google/uuid" ) -func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{} } - -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { - if err := ValidateResources(s.Resources); err != nil { - return err - } - if s.Runtime != nil { - return &sandbox.ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: E2B Runtime is selected by its immutable template build", sandbox.ErrInvalid)} - } - return nil +func Policy() sandbox.DeploymentPolicy { + return sandbox.DeploymentPolicy{RuntimeError: "E2B Runtime is selected by its immutable template build"} } +func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } +func ValidateSpecification(s sandbox.DeploymentSpec) error { return s.ValidatePolicy("e2b", Policy()) } + func ValidateConfiguration(c *sandbox.E2BConfiguration) error { if c == nil || c.APIKey == "" || len(c.APIKey) > 4096 || strings.IndexFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { return sandbox.ErrInvalid @@ -50,7 +43,7 @@ func ValidateConfiguration(c *sandbox.E2BConfiguration) error { func NormalizeSelection(s sandbox.Selection) (sandbox.Selection, error) { if s.Resources == (sandbox.Resources{}) { if s.Runtime != nil { - return s, &sandbox.ValidationError{Param: "runtime", Message: "invalid sandbox configuration: E2B Runtime is selected by its immutable template build"} + return s, &sandbox.ValidationError{Param: "runtime", Message: sandbox.ErrInvalid.Error() + ": " + Policy().RuntimeError} } } else if err := ValidateSpecification(s.DeploymentSpec); err != nil { return s, err diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go index beff3c929..8d50db340 100644 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ b/services/agents-api/internal/sandbox/e2b/provider.go @@ -160,17 +160,11 @@ func (c Config) Validate() error { if c.Resources != nil && ValidateResources(*c.Resources) != nil { return sandbox.ErrInvalid } - template, build, ok := strings.Cut(c.Template, ":") - if !ok || template == "" || !validID(build) || !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 || c.APIKey == "" || len(c.APIKey) > 4096 || strings.ContainsFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) { + if !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 { return sandbox.ErrInvalid } - if _, _, err := NormalizeEndpoint(c.APIURL, c.Domain); err != nil { - return sandbox.ErrInvalid - } - for _, r := range template { - if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { - return sandbox.ErrInvalid - } + if err := ValidateConfiguration(&sandbox.E2BConfiguration{APIKey: c.APIKey, Template: c.Template, APIURL: c.APIURL, Domain: c.Domain}); err != nil { + return err } for _, path := range []string{c.Binary, c.StateDir} { if !filepath.IsAbs(path) || filepath.Clean(path) != path { diff --git a/services/agents-api/internal/sandbox/microsandbox/deployment.go b/services/agents-api/internal/sandbox/microsandbox/deployment.go index 68495e8b9..6b9da5311 100644 --- a/services/agents-api/internal/sandbox/microsandbox/deployment.go +++ b/services/agents-api/internal/sandbox/microsandbox/deployment.go @@ -1,8 +1,6 @@ package microsandbox import ( - "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" ) @@ -10,11 +8,5 @@ func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Disk: t func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } func ValidateSpecification(s sandbox.DeploymentSpec) error { - if err := ValidateResources(s.Resources); err != nil { - return err - } - if s.Runtime == nil { - return &sandbox.ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", sandbox.ErrInvalid)} - } - return s.Runtime.Validate() + return s.ValidatePolicy("microsandbox", Policy()) } diff --git a/services/agents-api/internal/sandbox/providers/registry.go b/services/agents-api/internal/sandbox/providers/registry.go index f8e2d02ba..0b3edbb6a 100644 --- a/services/agents-api/internal/sandbox/providers/registry.go +++ b/services/agents-api/internal/sandbox/providers/registry.go @@ -35,9 +35,25 @@ type Adapter struct { } var adapters = map[string]Adapter{ - "docker": {Policy: docker.Policy(), BuildLocal: buildDocker, Mode: "nodes", ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, Normalize: nodeSelection(docker.ValidateSpecification)}, - "microsandbox": {Policy: microsandbox.Policy(), BuildLocal: buildMicrosandbox, Mode: "nodes", Checkpoint: true, IdleSeconds: 300, RetentionSeconds: 86400, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, Normalize: nodeSelection(microsandbox.ValidateSpecification)}, - "e2b": {Policy: e2b.Policy(), ReplaceCredential: e2b.ReplaceCredential, CredentialRequiresReset: e2b.CredentialRequiresReset, CredentialUnconfirmed: e2b.ErrRequestUnconfirmed, Restore: e2b.RestoreSelection, ResolveChange: e2b.ResolveChange, Credential: true, PublicOrigin: true, BuildDirect: buildE2B, Mode: "direct", ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, Normalize: e2b.NormalizeSelection}, + "docker": { + Policy: docker.Policy(), Mode: "nodes", BuildLocal: buildDocker, + ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, + Normalize: nodeSelection(docker.ValidateSpecification), + }, + "microsandbox": { + Policy: microsandbox.Policy(), Mode: "nodes", BuildLocal: buildMicrosandbox, + Checkpoint: true, IdleSeconds: 300, RetentionSeconds: 86400, + ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, + Normalize: nodeSelection(microsandbox.ValidateSpecification), + }, + "e2b": { + Policy: e2b.Policy(), Mode: "direct", BuildDirect: buildE2B, + Credential: true, PublicOrigin: true, + ReplaceCredential: e2b.ReplaceCredential, CredentialRequiresReset: e2b.CredentialRequiresReset, + CredentialUnconfirmed: e2b.ErrRequestUnconfirmed, Restore: e2b.RestoreSelection, + ResolveChange: e2b.ResolveChange, Normalize: e2b.NormalizeSelection, + ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, + }, } func Lookup(kind string) (Adapter, error) { diff --git a/services/agents-api/internal/store/sandbox_generations_test.go b/services/agents-api/internal/store/sandbox_generations_test.go index e61d951a2..09d749eed 100644 --- a/services/agents-api/internal/store/sandbox_generations_test.go +++ b/services/agents-api/internal/store/sandbox_generations_test.go @@ -30,7 +30,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { assertSandboxSnapshotEquivalent(t, s.pool) ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} retained, err := s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 1 || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "" || retained.Specification.Resources.CPUs == input.Resources.CPUs { + if err != nil || retained.Generation != 1 || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" || retained.Specification.Resources.CPUs == input.Resources.CPUs { t.Fatal(retained, err) } input.E2B.APIKey = "replacement-secret" @@ -40,7 +40,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { t.Fatal(changed, err) } retained, err = s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 1 || retained.E2B.APIKey != input.E2B.APIKey || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "" { + if err != nil || retained.Generation != 1 || retained.E2B.APIKey != input.E2B.APIKey || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" { t.Fatal("old generation did not use committed key", err) } if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_allocations SET deployment_generation=3 WHERE id=$1`, owner.ID); err == nil { @@ -50,7 +50,7 @@ func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { t.Fatal(err) } generations, err := s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(generations) != 1 || generations[0].Generation != 1 || generations[0].E2B.APIURL != "" { + if err != nil || len(generations) != 1 || generations[0].Generation != 1 || generations[0].E2B.APIURL != "https://api.e2b.app" { t.Fatal(generations, err) } if _, err = w.RequestRuntimeCleanup(t.Context(), owner); err != nil { diff --git a/services/agents-api/tools/microsandbox-provider/go.mod b/services/agents-api/tools/microsandbox-provider/go.mod index 0c2e4bb9b..8830d39ab 100644 --- a/services/agents-api/tools/microsandbox-provider/go.mod +++ b/services/agents-api/tools/microsandbox-provider/go.mod @@ -7,7 +7,10 @@ require ( github.com/superradcompany/microsandbox/sdk/go v0.7.2 ) -require github.com/google/uuid v1.6.0 // indirect +require ( + github.com/google/uuid v1.6.0 // indirect + golang.org/x/sync v0.22.0 // indirect +) // The helper ships from this repository; the SDK itself is a released module. replace github.com/MiniMax-AI-Dev/parsar => ../../../.. diff --git a/services/agents-api/tools/microsandbox-provider/go.sum b/services/agents-api/tools/microsandbox-provider/go.sum index e28f0957e..77cb4e04c 100644 --- a/services/agents-api/tools/microsandbox-provider/go.sum +++ b/services/agents-api/tools/microsandbox-provider/go.sum @@ -2,3 +2,5 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/superradcompany/microsandbox/sdk/go v0.7.2 h1:aO70srBRgu50rNZTtzQTOO3xxPSthkZLkq3kjWvmsqg= github.com/superradcompany/microsandbox/sdk/go v0.7.2/go.mod h1:p7Tm/p9zkO7sHO3N8A1fiTVeLB2w/fQoKdYlpN/5neA= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= From 3afb4fb5ec34f951da898d4c5b100b44a69b4bbf Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 01:21:59 +0800 Subject: [PATCH 3/5] fix(sandbox): derive node retention capacity from registered capabilities --- .../internal/sandbox/providers/registry.go | 10 ++++++++++ .../sandbox/providers/registry_test.go | 17 +++++++++++++++++ .../store/runtime_node_configuration.go | 3 ++- .../internal/store/runtime_nodes.go | 19 ++++++------------- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/services/agents-api/internal/sandbox/providers/registry.go b/services/agents-api/internal/sandbox/providers/registry.go index 0b3edbb6a..a06a2c30e 100644 --- a/services/agents-api/internal/sandbox/providers/registry.go +++ b/services/agents-api/internal/sandbox/providers/registry.go @@ -65,6 +65,16 @@ func Lookup(kind string) (Adapter, error) { } func IsNode(kind string) bool { a, e := Lookup(kind); return e == nil && a.Mode == "nodes" } func SupportsCheckpoint(kind string) bool { a, e := Lookup(kind); return e == nil && a.Checkpoint } + +// RetainedLimit keeps nodes without checkpoint support within their active capacity. +func RetainedLimit(kind string, active, retained int) int { + a, err := Lookup(kind) + if err == nil && a.Mode == "nodes" && !a.Checkpoint { + return active + } + return retained +} + func ValidateSpecification(kind string, s sandbox.DeploymentSpec) error { a, e := Lookup(kind) if e != nil { diff --git a/services/agents-api/internal/sandbox/providers/registry_test.go b/services/agents-api/internal/sandbox/providers/registry_test.go index 4bef4efda..17212d63a 100644 --- a/services/agents-api/internal/sandbox/providers/registry_test.go +++ b/services/agents-api/internal/sandbox/providers/registry_test.go @@ -77,3 +77,20 @@ func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { t.Fatal("mixed configuration admitted", err) } } + +func TestRetainedLimitUsesRegisteredCapabilities(t *testing.T) { + const kind = "capacity-test-provider" + defer delete(adapters, kind) + for _, checkpoint := range []bool{false, true} { + adapters[kind] = Adapter{Mode: "nodes", Checkpoint: checkpoint} + for _, retained := range []int{0, 20} { + want := 10 + if checkpoint { + want = retained + } + if got := RetainedLimit(kind, 10, retained); got != want { + t.Fatalf("checkpoint=%v retained=%d: got %d, want %d", checkpoint, retained, got, want) + } + } + } +} diff --git a/services/agents-api/internal/store/runtime_node_configuration.go b/services/agents-api/internal/store/runtime_node_configuration.go index 3bdb58f90..1a3349349 100644 --- a/services/agents-api/internal/store/runtime_node_configuration.go +++ b/services/agents-api/internal/store/runtime_node_configuration.go @@ -7,6 +7,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "github.com/jackc/pgx/v5/pgtype" ) @@ -96,7 +97,7 @@ func (s *Store) RuntimeNodeGenerationConfiguration(ctx context.Context, nodeID, if node == nil && d.AdmissionPaused { return ErrSandboxDeploymentConflict } - result = RuntimeNodeConfiguration{MaxActive: int(active), MaxRetained: retainedLimit(d.ProviderKind, int(active), int(retained)), InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: s.publicURL, Generation: selected, Specification: spec, SpecificationDigest: spec.Digest(d.ProviderKind)} + result = RuntimeNodeConfiguration{MaxActive: int(active), MaxRetained: providers.RetainedLimit(d.ProviderKind, int(active), int(retained)), InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: s.publicURL, Generation: selected, Specification: spec, SpecificationDigest: spec.Digest(d.ProviderKind)} return nil }) return result, err diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index 99f6fdcb7..be41bb545 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" "strings" "time" @@ -26,14 +27,6 @@ func validRuntimeDigest(value string) bool { return err == nil && len(decoded) == 32 && hex.EncodeToString(decoded) == value } -// retainedLimit reports the retained-sandbox limit a provider can use. Docker -// never suspends a sandbox, so its retained limit always equals its active limit. -func retainedLimit(provider string, active, retained int) int { - if provider == "docker" { - return active - } - return retained -} func validateRuntimeNode(name string, active, retained int) error { if strings.TrimSpace(name) == "" || len(name) > 128 || strings.ContainsAny(name, "\x00\r\n") { return &AdminValidationError{Code: "invalid_name", Param: "name", MaxLength: 128, message: ErrInvalidInput.Error()} @@ -138,7 +131,7 @@ func runtimeNodeViews(rows []sqlc.ListRuntimeNodesRow) ([]RuntimeNode, error) { return nil, err } health.ProviderReady = n.Online && n.ServingReady - out = append(out, RuntimeNode{Rollout: nodeRollout(n), RuntimeNodeHealth: health, Running: n.Running, Snapshots: n.Snapshots, ID: runtimeUUID(n.ID), Name: n.Name, CoreURL: n.CoreUrl, EnrollmentID: optionalUUID(n.EnrollmentID), Provider: n.ProviderKind, Online: n.Online, LastSeenAt: seen, MaxActive: int(n.MaxActive), MaxRetained: retainedLimit(n.ProviderKind, int(n.MaxActive), int(n.MaxRetained)), Active: n.Active, Reserved: n.Reserved, Retained: n.Retained, CleanupPending: n.CleanupPending, CreatedAt: n.CreatedAt.Time}) + out = append(out, RuntimeNode{Rollout: nodeRollout(n), RuntimeNodeHealth: health, Running: n.Running, Snapshots: n.Snapshots, ID: runtimeUUID(n.ID), Name: n.Name, CoreURL: n.CoreUrl, EnrollmentID: optionalUUID(n.EnrollmentID), Provider: n.ProviderKind, Online: n.Online, LastSeenAt: seen, MaxActive: int(n.MaxActive), MaxRetained: providers.RetainedLimit(n.ProviderKind, int(n.MaxActive), int(n.MaxRetained)), Active: n.Active, Reserved: n.Reserved, Retained: n.Retained, CleanupPending: n.CleanupPending, CreatedAt: n.CreatedAt.Time}) } return out, nil } @@ -157,7 +150,7 @@ func (s *Store) CreateRuntimeEnrollment(ctx context.Context, capacity RuntimeNod id := uuid.New() result := RuntimeNodeEnrollmentToken{Token: hex.EncodeToString(bytes[:]), ID: id.String()} err := s.runtimeManagerTransaction(ctx, func(q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - capacity.MaxRetained = retainedLimit(d.ProviderKind, capacity.MaxActive, capacity.MaxRetained) + capacity.MaxRetained = providers.RetainedLimit(d.ProviderKind, capacity.MaxActive, capacity.MaxRetained) if err := validateRuntimeNode("enrollment", capacity.MaxActive, capacity.MaxRetained); err != nil { return err } @@ -223,7 +216,7 @@ func (s *Store) EnrollRuntimeNode(ctx context.Context, token string, input Runti } else if !errors.Is(err, pgx.ErrNoRows) { return err } - row, err := q.InsertRuntimeNode(ctx, sqlc.InsertRuntimeNodeParams{ID: id, InstallationID: d.InstallationID, Name: input.Name, BackendFingerprint: input.BackendFingerprint, CredentialSha256: runtimeTokenDigest(input.Credential), MaxActive: receipt.MaxActive, MaxRetained: int32(retainedLimit(d.ProviderKind, int(receipt.MaxActive), int(receipt.MaxRetained))), SpecificationDigest: input.SpecificationDigest, DeploymentGeneration: int64(input.DeploymentGeneration), CoreUrl: input.CoreURL, EnrollmentID: receipt.ID}) + row, err := q.InsertRuntimeNode(ctx, sqlc.InsertRuntimeNodeParams{ID: id, InstallationID: d.InstallationID, Name: input.Name, BackendFingerprint: input.BackendFingerprint, CredentialSha256: runtimeTokenDigest(input.Credential), MaxActive: receipt.MaxActive, MaxRetained: int32(providers.RetainedLimit(d.ProviderKind, int(receipt.MaxActive), int(receipt.MaxRetained))), SpecificationDigest: input.SpecificationDigest, DeploymentGeneration: int64(input.DeploymentGeneration), CoreUrl: input.CoreURL, EnrollmentID: receipt.ID}) if err != nil { return err } @@ -240,7 +233,7 @@ func (s *Store) EnrollRuntimeNode(ctx context.Context, token string, input Runti return result, err } func nodeIdentity(n sqlc.RuntimeNode, kind string) RuntimeNodeIdentity { - return RuntimeNodeIdentity{SpecificationDigest: n.SpecificationDigest, DeploymentGeneration: uint64(n.DeploymentGeneration), NodeID: runtimeUUID(n.ID), InstallationID: runtimeUUID(n.InstallationID), Provider: kind, BackendFingerprint: n.BackendFingerprint, MaxActive: int(n.MaxActive), MaxRetained: retainedLimit(kind, int(n.MaxActive), int(n.MaxRetained))} + return RuntimeNodeIdentity{SpecificationDigest: n.SpecificationDigest, DeploymentGeneration: uint64(n.DeploymentGeneration), NodeID: runtimeUUID(n.ID), InstallationID: runtimeUUID(n.InstallationID), Provider: kind, BackendFingerprint: n.BackendFingerprint, MaxActive: int(n.MaxActive), MaxRetained: providers.RetainedLimit(kind, int(n.MaxActive), int(n.MaxRetained))} } func (s *Store) AuthenticateRuntimeNode(ctx context.Context, nodeID, credential string) (RuntimeNodeIdentity, error) { id, err := parseConnectionGeneration(nodeID) @@ -291,7 +284,7 @@ func (s *Store) UpdateRuntimeNode(ctx context.Context, nodeID string, input Runt return err } return s.runtimeManagerTransaction(ctx, func(q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - input.MaxRetained = retainedLimit(d.ProviderKind, input.MaxActive, input.MaxRetained) + input.MaxRetained = providers.RetainedLimit(d.ProviderKind, input.MaxActive, input.MaxRetained) if err := validateRuntimeNode(input.Name, input.MaxActive, input.MaxRetained); err != nil { return err } From 66d62bd4c69b5dde2017d3a68d53ccb9d9722569 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 01:29:32 +0800 Subject: [PATCH 4/5] fix(sandbox): preserve endpoint identity across migration rollback --- .../provider_registration_migration_test.go | 53 +++++++++++++++++++ .../000089_provider_registration.sql | 11 +++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 services/agents-api/internal/store/provider_registration_migration_test.go diff --git a/services/agents-api/internal/store/provider_registration_migration_test.go b/services/agents-api/internal/store/provider_registration_migration_test.go new file mode 100644 index 000000000..1b539a98a --- /dev/null +++ b/services/agents-api/internal/store/provider_registration_migration_test.go @@ -0,0 +1,53 @@ +package store + +import ( + "database/sql" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +func TestProviderRegistrationDowngradePreservesCustomEndpoints(t *testing.T) { + s, w, view, input := webSpecificationFixture(t, "e2b") + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + archiveAllocation(t, w, tenant, session, view.InstallationID) + input.E2B.APIURL, input.E2B.Domain = "https://api.example.test", "example.test" + if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: view.Generation}); err != nil { + t.Fatal(err) + } + tenant, session = managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + archiveAllocation(t, w, tenant, session, view.InstallationID) + input.E2B.Template = "next:" + uuid.NewString() + if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 2}); err != nil { + t.Fatal(err) + } + db := sql.OpenDB(stdlib.GetConnector(*s.pool.Config().ConnConfig)) + defer db.Close() + migrations, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err := migrations.DownTo(t.Context(), 88); err != nil { + t.Fatal(err) + } + for generation, want := range map[int]string{1: "", 2: input.E2B.APIURL} { + var endpoint string + if err := db.QueryRowContext(t.Context(), "SELECT e2b_api_url FROM runtime_deployment_generations WHERE generation=$1", generation).Scan(&endpoint); err != nil || endpoint != want { + t.Fatal("downgrade changed endpoint identity", generation, endpoint, err) + } + } + var endpoint string + if err := db.QueryRowContext(t.Context(), "SELECT e2b_api_url FROM runtime_deployment").Scan(&endpoint); err != nil || endpoint != input.E2B.APIURL { + t.Fatal("downgrade changed current endpoint", endpoint, err) + } + if _, err := db.ExecContext(t.Context(), "UPDATE runtime_deployment_generations SET e2b_domain='changed.test' WHERE generation=1"); err == nil || !strings.Contains(err.Error(), "Retained sandbox specifications are immutable") { + t.Fatal("downgrade left specifications mutable", err) + } + if _, err := migrations.DownTo(t.Context(), 86); err == nil || !strings.Contains(err.Error(), "custom endpoint is retained") { + t.Fatal("downgrade discarded a custom endpoint", err) + } +} diff --git a/services/agents-api/migrations/000089_provider_registration.sql b/services/agents-api/migrations/000089_provider_registration.sql index 2f8207e0d..4a4e743ed 100644 --- a/services/agents-api/migrations/000089_provider_registration.sql +++ b/services/agents-api/migrations/000089_provider_registration.sql @@ -25,7 +25,7 @@ ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_template_bu e2b_template <> '' OR (e2b_template_build_status IS NULL AND e2b_template_cpus IS NULL AND e2b_template_memory_mib IS NULL AND e2b_template_root_disk_mib IS NULL) ); -ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_e2b_endpoint_check; +ALTER TABLE runtime_deployment DROP CONSTRAINT IF EXISTS runtime_deployment_e2b_endpoint_check; ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_e2b_endpoint_check CHECK ( (e2b_api_url = '' AND e2b_domain = '') OR (e2b_template <> '' AND e2b_api_url <> '' AND e2b_domain <> '') @@ -37,6 +37,15 @@ ALTER TABLE runtime_deployment_generations ADD CONSTRAINT runtime_deployment_gen ); -- +goose Down +-- Restore the previous representation of official endpoints. Custom endpoints +-- remain explicit so older downgrade guards still protect their ownership. +UPDATE runtime_deployment SET e2b_api_url = '', e2b_domain = '' +WHERE e2b_api_url = 'https://api.e2b.app' AND e2b_domain = 'e2b.app'; +-- This transaction changes representation only, not the endpoint or resource owner. +ALTER TABLE runtime_deployment_generations DISABLE TRIGGER immutable_sandbox_specification; +UPDATE runtime_deployment_generations SET e2b_api_url = '', e2b_domain = '' +WHERE e2b_api_url = 'https://api.e2b.app' AND e2b_domain = 'e2b.app'; +ALTER TABLE runtime_deployment_generations ENABLE TRIGGER immutable_sandbox_specification; -- Narrowing registration cannot silently invalidate retained resource ownership. ALTER TABLE runtime_deployment DROP CONSTRAINT runtime_deployment_provider_kind_check; ALTER TABLE runtime_deployment ADD CONSTRAINT runtime_deployment_provider_kind_check CHECK (provider_kind IN ('','docker','microsandbox','e2b')); From 1961bad244a41d0f5b47024a0dda9b487ec9d5b4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 01:32:24 +0800 Subject: [PATCH 5/5] fix(api): map adapter configuration validation to client errors --- .../agents-api/internal/api/core_store_validation_test.go | 1 + services/agents-api/internal/api/errors.go | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/services/agents-api/internal/api/core_store_validation_test.go b/services/agents-api/internal/api/core_store_validation_test.go index bdd1addd0..d63266419 100644 --- a/services/agents-api/internal/api/core_store_validation_test.go +++ b/services/agents-api/internal/api/core_store_validation_test.go @@ -28,6 +28,7 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { details map[string]any publicMessage, publicCode string }{ + {&sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"}, "invalid_sandbox_configuration", "resources", nil, "E2B template build resources are outside the supported sandbox limits; select another build", "invalid_sandbox_configuration"}, {nameErr, "invalid_name", "name", map[string]any{"max_length": float64(128)}, "Invalid resource identifier or request limits.", "invalid_request"}, {upperCapacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request"}, {capacityErr, "invalid_node_capacity", "max_active", map[string]any{"min": float64(1), "max": float64(1000000)}, "Invalid resource identifier or request limits.", "invalid_request"}, diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index bddc9e93f..a2530596e 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -9,6 +9,7 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -93,6 +94,11 @@ func writeFieldError(w http.ResponseWriter, err error) bool { } func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFoundParam ...string) { + // Adapter discovery and persisted configuration share the same public error. + var validation *sandbox.ValidationError + if errors.As(err, &validation) { + err = &store.SandboxConfigurationError{Message: validation.Message, Validation: validation} + } if writeCoreValidationError(w, err) { return }