From 7160c976743436b5a612de53b29e2443b3173562 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 10:24:25 +0000 Subject: [PATCH 1/2] Drain deployments saved before sandbox specifications A Web-selected Docker or microsandbox deployment saved before #95 keeps the empty specification default after migration. Current Core could neither load its provider nor authenticate its retained nodes, so its sandboxes could not be archived or cleaned up, and the specification could never be replaced. Load such a deployment for draining only: retained nodes that never recorded a specification digest or generation reconnect, fresh hosted sandboxes are refused, and node configuration and enrollment stay closed. The existing maintenance, archive and PUT sequence then records a specification and retires those nodes. E2B deployments from that period remain out of scope. --- contracts/agents-api/sandbox-deployment.md | 9 +++ docs/getting-started/operations.md | 11 ++++ .../internal/store/runtime_deployment.go | 3 + .../internal/store/runtime_nodes.go | 6 ++ .../store/sandbox_deployment_setup.go | 2 +- .../store/sandbox_deployment_switch_test.go | 59 +++++++++++++++++++ .../internal/store/sandbox_specification.go | 8 +++ 7 files changed, 97 insertions(+), 1 deletion(-) diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index c039345a9..916c3b8f4 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -230,6 +230,15 @@ Preserve identities, provider receipts, history and storage. This version does n automatically adopt the old database or delete its resources; removing an environment variable alone does not complete that migration. +A Web-managed Docker or microsandbox selection saved before specifications has +the empty migration default. Core loads it for draining only. Retained nodes +without a recorded digest or generation authenticate while the deployment is in +this state. Fresh hosted sandboxes are refused with the same error as during +maintenance. Node configuration reads and enrollment return 409 +`sandbox_specification_mismatch`. +The ordinary maintenance, archive and PUT sequence records a specification and +retires those nodes. See the [operator upgrade notes](../../docs/getting-started/operations.md#data-and-upgrades). + Unit tests, database tests and provider inspection are separate from live execution acceptance. This contract does not assert that every resource profile, provider deployment or host-reboot recovery path has been qualified. diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 90820e16e..100478438 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -156,6 +156,17 @@ Keep the original business data, credential key, Runtime history and private pro state. There is no automatic old-database conversion, force reset or resource deletion. The supported current path uses a database-managed deployment. +A Web-selected Docker or microsandbox deployment saved before deployment +specifications existed has an empty specification after migration. Current Core +loads it only to drain: nodes enrolled under the previous release reconnect with +their existing node service, and their sandboxes stay reachable for archive and +cleanup. Fresh sandbox creation, node configuration reads and enrollment are +refused. Back up as above, replace Core and Web, then use steps 1–4 with a Runtime +release from the new distribution. The replacement retires the old nodes. Stop each +retired node service, move its identity directory aside as a backup, and add the +host again with a new command from Web. Node IDs change; Session history and +persisted Files/Artifacts remain. E2B deployments from that period are not covered. + ## Exposure and network policy API and console bind to host loopback. With native Core, PostgreSQL publishes an diff --git a/services/agents-api/internal/store/runtime_deployment.go b/services/agents-api/internal/store/runtime_deployment.go index 6319181ab..a2bf62c5a 100644 --- a/services/agents-api/internal/store/runtime_deployment.go +++ b/services/agents-api/internal/store/runtime_deployment.go @@ -109,6 +109,9 @@ func checkRuntimeDeploymentAdmission(ctx context.Context, q *sqlc.Queries, insta if current.Maintenance { return fmt.Errorf("%w: sandbox creation is paused for provider maintenance", ErrEnvironmentUnavailable) } + if unspecifiedNodeDeployment(current) { + return fmt.Errorf("%w: sandbox creation requires a deployment specification", ErrEnvironmentUnavailable) + } if installation != "" { id, err := parseConnectionGeneration(installation) if err != nil || id != current.InstallationID { diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index 0a753b205..790a6cc20 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -190,6 +190,12 @@ func (s *Store) AuthenticateRuntimeNode(ctx context.Context, nodeID, credential if n.InstallationID != d.InstallationID || d.Mode != "nodes" { return RuntimeNodeIdentity{}, ErrRuntimeNodeCredential } + if unspecifiedNodeDeployment(d) { + if n.SpecificationDigest != "" || n.DeploymentGeneration != 0 { + return RuntimeNodeIdentity{}, ErrRuntimeSpecificationMismatch + } + return nodeIdentity(n, d.ProviderKind), nil + } spec, err := deploymentSpecification(d) if err != nil || n.SpecificationDigest != spec.Digest(d.ProviderKind) || n.DeploymentGeneration != d.Generation { return RuntimeNodeIdentity{}, ErrRuntimeSpecificationMismatch diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go index 926fa27fe..573083bc6 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup.go @@ -49,7 +49,7 @@ func (s *Store) GetSandboxSetup(ctx context.Context) (SandboxSetup, error) { if err := json.Unmarshal(d.Specification, &result.Specification); err != nil { return SandboxSetup{}, err } - if d.ProviderKind != "" { + if d.ProviderKind != "" && !unspecifiedNodeDeployment(d) { if err := result.Specification.Validate(d.ProviderKind); err != nil { return SandboxSetup{}, err } 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 f0571e8a5..88a17359f 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go @@ -302,3 +302,62 @@ func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { } } } + +// Migration 000069 leaves a node-backed selection saved before specifications +// with an empty specification and its nodes with empty digests. The retained +// node reconnects to drain resources; fresh admission and node configuration +// stay closed until an administrator replaces the selection. +func TestUnspecifiedNodeDeploymentDrainsBeforeReplacement(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + spec := SandboxDeploymentTestSpec("docker") + selection := SandboxDeploymentSetupRequest{DeploymentSpec: spec, Provider: "docker", CoreURL: "https://core.example"} + if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { + t.Fatal(err) + } + token, _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}) + if err != nil { + t.Fatal(err) + } + node := RuntimeNodeEnrollment{DeploymentGeneration: 1, SpecificationDigest: spec.Digest("docker"), NodeID: uuid.NewString(), Name: "Legacy", Provider: "docker", Credential: strings.Repeat("l", 64), BackendFingerprint: strings.Repeat("b", 64)} + if _, err := s.EnrollRuntimeNode(t.Context(), token, node); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_deployment SET specification='{}'"); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_nodes SET specification_digest='', deployment_generation=0"); err != nil { + t.Fatal(err) + } + + if setup, err := s.GetSandboxSetup(t.Context()); err != nil || setup.Provider != "docker" { + t.Fatal("unspecified deployment could not load for draining", err) + } + identity, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential) + if err != nil || identity.SpecificationDigest != "" || identity.DeploymentGeneration != 0 { + t.Fatal("retained unspecified node could not reconnect", identity, err) + } + if _, err := s.RuntimeNodeConfiguration(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeSpecificationMismatch) { + t.Fatal("node configuration served without a specification", err) + } + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString(), "")); !errors.Is(err, ErrEnvironmentUnavailable) { + t.Fatal("unspecified deployment admitted a fresh sandbox", err) + } + + if _, err := w.SetSandboxMaintenance(t.Context(), id, SandboxMaintenanceRequest{Maintenance: true, ExpectedGeneration: 1}); err != nil { + t.Fatal(err) + } + view, err := w.UpdateSandboxDeployment(t.Context(), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: selection, ExpectedGeneration: 1}) + if err != nil || view.Generation != 2 || view.Specification == nil { + t.Fatal("replacement did not record the specification", view, err) + } + if _, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeNodeCredential) { + t.Fatal("unspecified node survived replacement", err) + } +} diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/agents-api/internal/store/sandbox_specification.go index 8ed755ed3..1d0ff7afd 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/agents-api/internal/store/sandbox_specification.go @@ -41,6 +41,14 @@ func SandboxSetupForSelection(installationID string, input SandboxDeploymentSetu return result, nil } +// unspecifiedNodeDeployment identifies a node-backed selection saved before +// deployments carried a specification; migration left the empty default. Its +// 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.Mode == "nodes" && (d.ProviderKind == "docker" || d.ProviderKind == "microsandbox") && 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 { From 0654e977ba09a981b65e049fa7dcda8e2f79b725 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 10:50:05 +0000 Subject: [PATCH 2/2] Refuse enrollment tokens while a deployment has no specification Address blind-review follow-ups: an unspecified deployment cannot issue a node enrollment token that would always fail, the drain rule applies only to Web-managed selections, and the contract notes that GET omits the missing specification. --- contracts/agents-api/sandbox-deployment.md | 7 ++++--- services/agents-api/internal/store/runtime_nodes.go | 2 +- .../internal/store/sandbox_deployment_switch_test.go | 3 +++ .../agents-api/internal/store/sandbox_specification.go | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 916c3b8f4..062309863 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -233,9 +233,10 @@ environment variable alone does not complete that migration. A Web-managed Docker or microsandbox selection saved before specifications has the empty migration default. Core loads it for draining only. Retained nodes without a recorded digest or generation authenticate while the deployment is in -this state. Fresh hosted sandboxes are refused with the same error as during -maintenance. Node configuration reads and enrollment return 409 -`sandbox_specification_mismatch`. +this state. GET omits `specification` and `specification_digest`. Fresh hosted +sandboxes are refused with the same error as during maintenance. Enrollment +tokens return 409 `sandbox_deployment_conflict`; node configuration reads and +enrollment are refused. The ordinary maintenance, archive and PUT sequence records a specification and retires those nodes. See the [operator upgrade notes](../../docs/getting-started/operations.md#data-and-upgrades). diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index 790a6cc20..cd5050baf 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -105,7 +105,7 @@ func (s *Store) CreateRuntimeEnrollment(ctx context.Context, capacity RuntimeNod token := hex.EncodeToString(bytes[:]) var expires time.Time err := s.runtimeManagerTransaction(ctx, func(q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - if d.Mode != "nodes" || d.Maintenance { + if d.Mode != "nodes" || d.Maintenance || unspecifiedNodeDeployment(d) { return ErrSandboxDeploymentConflict } if err := q.CreateRuntimeEnrollment(ctx, sqlc.CreateRuntimeEnrollmentParams{TokenSha256: runtimeTokenDigest(token), InstallationID: d.InstallationID, MaxActive: int32(capacity.MaxActive), MaxRetained: int32(capacity.MaxRetained)}); err != nil { 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 88a17359f..b02d4f670 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go @@ -349,6 +349,9 @@ func TestUnspecifiedNodeDeploymentDrainsBeforeReplacement(t *testing.T) { if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString(), "")); !errors.Is(err, ErrEnvironmentUnavailable) { t.Fatal("unspecified deployment admitted a fresh sandbox", err) } + if _, _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("unspecified deployment issued an enrollment token", err) + } if _, err := w.SetSandboxMaintenance(t.Context(), id, SandboxMaintenanceRequest{Maintenance: true, ExpectedGeneration: 1}); err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/agents-api/internal/store/sandbox_specification.go index 1d0ff7afd..5f5c19b72 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/agents-api/internal/store/sandbox_specification.go @@ -46,7 +46,7 @@ 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.Mode == "nodes" && (d.ProviderKind == "docker" || d.ProviderKind == "microsandbox") && string(d.Specification) == "{}" + return d.WebManaged && d.Mode == "nodes" && (d.ProviderKind == "docker" || d.ProviderKind == "microsandbox") && string(d.Specification) == "{}" } func deploymentSpecification(d sqlc.RuntimeDeployment) (sandbox.DeploymentSpec, error) {