From e739dd9c7957ef111b17b217aac42ea5926df713 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 28 Jul 2026 11:05:38 +0800 Subject: [PATCH] fix: separate run builders from generated workers --- .../fix-run-build-worker-role/.openspec.yaml | 2 + .../fix-run-build-worker-role/design.md | 42 ++++++++++++ .../fix-run-build-worker-role/proposal.md | 25 +++++++ .../specs/run-build-worker-roles/spec.md | 30 ++++++++ .../spec.md | 11 +++ .../fix-run-build-worker-role/tasks.md | 15 ++++ platform/service/control_test.go | 68 ++++++++++++++++++- platform/service/distributions.go | 24 +++++++ platform/service/distributions_test.go | 25 +++++++ platform/service/job_channel.go | 16 +++++ 10 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/fix-run-build-worker-role/.openspec.yaml create mode 100644 openspec/changes/fix-run-build-worker-role/design.md create mode 100644 openspec/changes/fix-run-build-worker-role/proposal.md create mode 100644 openspec/changes/fix-run-build-worker-role/specs/run-build-worker-roles/spec.md create mode 100644 openspec/changes/fix-run-build-worker-role/specs/run-distribution-and-client-managers/spec.md create mode 100644 openspec/changes/fix-run-build-worker-role/tasks.md diff --git a/openspec/changes/fix-run-build-worker-role/.openspec.yaml b/openspec/changes/fix-run-build-worker-role/.openspec.yaml new file mode 100644 index 0000000..8e7013b --- /dev/null +++ b/openspec/changes/fix-run-build-worker-role/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-27 diff --git a/openspec/changes/fix-run-build-worker-role/design.md b/openspec/changes/fix-run-build-worker-role/design.md new file mode 100644 index 0000000..ef71e68 --- /dev/null +++ b/openspec/changes/fix-run-build-worker-role/design.md @@ -0,0 +1,42 @@ +## Context + +Platform dispatches `distribution.build` through a trusted Run endpoint. A newer server lifecycle already distinguishes `DeploymentTargetID` from a dedicated `RunEndpointID`, but legacy instances can still have only `RunEndpointID`. A generated Run for such an instance was built with the shared endpoint identity and its component key, so starting it remotely replaced the local build worker registration. + +## Goals / Non-Goals + +**Goals:** + +- Preserve a local trusted Run worker as the only component that compiles generated Windows Run binaries. +- Give generated Run packages a deterministic server-scoped identity. +- Make legacy generation self-heal the unsafe endpoint binding before a build is queued. +- Prevent generated Run packages from advertising build-only worker capabilities. + +**Non-Goals:** + +- Do not move compilation into the browser, expose source trees to plugins, or require Go/Git on the game host. +- Do not alter deployment commands, game dependencies, or remote server files. + +## Decisions + +1. When a failed or draft legacy instance requests a Run distribution, Platform atomically promotes its existing endpoint to `DeploymentTargetID` and reserves `server-run-` as the generated Run endpoint. Generation is an explicit operator action and is the safe migration boundary; it avoids changing active legacy instances merely because Platform starts. +2. Component-authenticated Run hello must match the instance's dedicated Run endpoint. A component token cannot register over a shared build endpoint. +3. Run capability reporting is role-aware. A component-authenticated generated Run keeps lifecycle/file/dependency capabilities but never reports distribution build or Run self-update capabilities. A generic worker remains build-capable. +4. Source-preparation failures return a bounded category that distinguishes unavailable source, invalid source layout, and unsafe source content without exposing host paths. + +## Risks / Trade-offs + +- [A failed legacy instance needs a fresh package] → Generation performs the migration before queueing and produces a package with the new identity. +- [A stale remotely deployed package continues to reconnect] → Platform rejects it after migration because its endpoint ID no longer matches the component's dedicated identity. +- [Role-aware capabilities change scheduling] → Only build-only capabilities are removed from component packages; normal server lifecycle capability reporting is preserved. + +## Migration Plan + +1. Deploy Platform and Run changes. +2. Start the local generic build worker with `run-local-debug` and its approved source snapshot. +3. Generate Run for the failed legacy server; Platform promotes `run-local-debug` to its deployment target and embeds `server-run-` in the new Windows package. +4. Replace the old remote executable with the generated package; its stale shared-ID registration is rejected. +5. Roll back by retaining the old package and restoring the legacy binding only through an explicit recovery operation; Platform never silently maps a component token onto a shared builder endpoint. + +## Open Questions + +- None. diff --git a/openspec/changes/fix-run-build-worker-role/proposal.md b/openspec/changes/fix-run-build-worker-role/proposal.md new file mode 100644 index 0000000..1ac4174 --- /dev/null +++ b/openspec/changes/fix-run-build-worker-role/proposal.md @@ -0,0 +1,25 @@ +## Why + +A legacy server can use one endpoint ID for both the trusted local Run builder and its generated Windows Run. When the generated Run starts on the target host it authenticates with the server component key as the builder endpoint, displacing the local builder. Later Windows distribution builds are consequently executed without the approved local source snapshot and fail at source preparation. + +## What Changes + +- Separate a legacy server's build-target endpoint from the endpoint identity embedded in a newly generated Run before queuing the build. +- Reject component-authenticated Run registration against a shared build endpoint. +- Make a generated server Run omit build-only capabilities, including `distribution.build`. +- Preserve a clear, safe source-preparation failure reason for operational diagnosis. + +## Capabilities + +### New Capabilities + +- `run-build-worker-roles`: Separates trusted Run build workers from server-scoped generated Run workers and protects the endpoint identities used by each. + +### Modified Capabilities + +- `run-distribution-and-client-managers`: Run distribution generation assigns the generated package a server-scoped endpoint identity rather than reusing a legacy builder identity. + +## Impact + +- Affected roots: `platform/`, the independent `run/` checkout, and OpenSpec contracts. +- Existing failed legacy instances migrate on their next Run generation; no browser, plugin, or remote-host path/credential exposure is introduced. diff --git a/openspec/changes/fix-run-build-worker-role/specs/run-build-worker-roles/spec.md b/openspec/changes/fix-run-build-worker-role/specs/run-build-worker-roles/spec.md new file mode 100644 index 0000000..299bb6a --- /dev/null +++ b/openspec/changes/fix-run-build-worker-role/specs/run-build-worker-roles/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Generated Run identity is distinct from its build target + +When Platform generates a Run package for a failed or draft legacy server without `deploymentTargetId`, it SHALL reserve `server-run-` as the package endpoint identity and persist the previous endpoint as the deployment target before the distribution build job is queued. + +#### Scenario: Failed legacy server generates a new Windows Run + +- **GIVEN** a failed legacy server has `runEndpointId=run-local-debug` and no deployment target +- **WHEN** an authorized owner generates a Windows amd64 Run package +- **THEN** the build job targets `run-local-debug`, the server persists `deploymentTargetId=run-local-debug`, and the generated package is assigned `runEndpointId=server-run-` + +### Requirement: Shared builders reject component Run identity + +Platform SHALL reject a component-authenticated Run hello unless its endpoint identity equals the server's dedicated Run endpoint identity. + +#### Scenario: Old package tries to claim the builder ID + +- **GIVEN** an instance has a dedicated Run endpoint and a separate deployment target +- **WHEN** a Run component uses the instance key to register as the deployment target endpoint +- **THEN** Platform rejects the hello and leaves the build endpoint registration unchanged + +### Requirement: Generated Run does not advertise build-only capabilities + +A Run configured as a server component SHALL NOT advertise `distribution.build` or `run.self-update`. + +#### Scenario: Generated Run registers on the target host + +- **WHEN** a package with component kind `run` creates its capability report +- **THEN** lifecycle capabilities remain available and build-only capabilities are absent diff --git a/openspec/changes/fix-run-build-worker-role/specs/run-distribution-and-client-managers/spec.md b/openspec/changes/fix-run-build-worker-role/specs/run-distribution-and-client-managers/spec.md new file mode 100644 index 0000000..1e05f41 --- /dev/null +++ b/openspec/changes/fix-run-build-worker-role/specs/run-distribution-and-client-managers/spec.md @@ -0,0 +1,11 @@ +## MODIFIED Requirements + +### Requirement: Run distribution generation remains asynchronous and server-scoped + +The platform SHALL create a bounded `distribution.build` job on the instance deployment target and return a `building` distribution with the real job ID. The generated Run package SHALL contain a server-scoped endpoint identity and component key; a legacy shared builder endpoint MUST be promoted to the deployment target before the job is queued. + +#### Scenario: Generate from a legacy endpoint binding + +- **GIVEN** an authorized server owner selects a legacy server whose only endpoint is an online build-capable worker +- **WHEN** the owner requests a target-matched Run distribution +- **THEN** Platform queues the build on that worker and assigns the generated package its deterministic dedicated endpoint identity diff --git a/openspec/changes/fix-run-build-worker-role/tasks.md b/openspec/changes/fix-run-build-worker-role/tasks.md new file mode 100644 index 0000000..e287ab7 --- /dev/null +++ b/openspec/changes/fix-run-build-worker-role/tasks.md @@ -0,0 +1,15 @@ +## 1. Binding migration and registration fence + +- [x] 1.1 Promote a failed or draft legacy endpoint to a deployment target when Run generation is explicitly requested, reserving the deterministic server Run endpoint first. +- [x] 1.2 Reject component-authenticated registration that does not match the server's dedicated endpoint identity and prevent signed component sessions from claiming distribution builds. +- [x] 1.3 Add Platform regression coverage for legacy promotion, build targeting, stale package registration rejection, and signed-component claim rejection. + +## 2. Role-aware Run worker capabilities + +- [x] 2.1 Derive worker capabilities from the component role and omit build-only capabilities from generated Run packages. +- [x] 2.2 Add Run tests for generic builder and generated server Run capability reports. + +## 3. Verification + +- [x] 3.1 Run focused and full Platform/Run test suites, a Windows amd64 build-worker test, an approved-snapshot PE build proof, and isolated local debug smoke with generated artifact download evidence. +- [x] 3.2 Run `openspec validate fix-run-build-worker-role --strict` and `scripts/check-structure.sh`. diff --git a/platform/service/control_test.go b/platform/service/control_test.go index 5966d0d..cacbd13 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -172,7 +172,7 @@ func TestCoreServiceRunHelloRejectsStalePackageKeyAfterReset(t *testing.T) { } pkg := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID) hello := validRunControlHello() - hello.RunEndpointID = instance.RunEndpointID + hello.RunEndpointID = distribution.RunEndpointID hello.RegistrationToken = pkg.AuthKey hello.ServerInstanceID = instance.ID hello.PluginID = instance.PluginID @@ -202,6 +202,72 @@ func TestCoreServiceRunHelloRejectsStalePackageKeyAfterReset(t *testing.T) { } } +func TestCoreServiceRunHelloRejectsGeneratedRunOnPromotedBuildEndpoint(t *testing.T) { + svc, session, instance := newDistributionTestFixture(t) + builderID := instance.RunEndpointID + instance.State = domain.ServerInstanceStateFailed + if err := svc.store.ServerInstances().Update(instance); err != nil { + t.Fatalf("mark legacy server failed: %v", err) + } + if _, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "promoted-hello-fence"}); err != nil { + t.Fatalf("generate promoted Run: %v", err) + } + migrated, err := svc.GetServerInstance(instance.ID) + if err != nil { + t.Fatalf("get migrated server: %v", err) + } + key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "") + if err != nil { + t.Fatalf("get component key: %v", err) + } + hello := validRunControlHello() + hello.RunEndpointID = builderID + hello.RegistrationToken = plainKey + hello.ServerInstanceID = instance.ID + hello.PluginID = instance.PluginID + hello.ComponentKind = domain.DistributionComponentRun + hello.KeyGeneration = key.Generation + if _, err := svc.RegisterRunHello(hello); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected shared builder registration rejection, got %v", err) + } + + hello.RunEndpointID = migrated.RunEndpointID + if result, err := svc.RegisterRunHello(hello); err != nil || !result.Accepted { + t.Fatalf("expected dedicated Run registration acceptance, result=%+v err=%v", result, err) + } +} + +func TestCoreServiceComponentRunCannotClaimDistributionBuild(t *testing.T) { + svc, session, instance := newDistributionTestFixture(t) + distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "component-build-claim"}) + if err != nil { + t.Fatalf("generate legacy Run: %v", err) + } + packageConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID) + hello := validRunControlHello() + hello.RunEndpointID = instance.RunEndpointID + hello.RegistrationToken = packageConfig.AuthKey + hello.ServerInstanceID = instance.ID + hello.PluginID = instance.PluginID + hello.ComponentKind = domain.DistributionComponentRun + hello.KeyGeneration = packageConfig.KeyGeneration + registered, err := svc.RegisterRunHello(hello) + if err != nil || !registered.Accepted { + t.Fatalf("register legacy package: result=%+v err=%v", registered, err) + } + storedSession, err := svc.store.RunControlSessions().Get(instance.RunEndpointID) + if err != nil || !storedSession.RequireSignedRequests { + t.Fatalf("expected component session to require signatures, session=%+v err=%v", storedSession, err) + } + if activeSession := svc.runSessions[instance.RunEndpointID]; !activeSession.RequireSignedRequests { + t.Fatalf("expected in-memory component session to require signatures, session=%+v", activeSession) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: []string{domain.JobCapabilityDistributionBuild}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || claim.HasJob { + t.Fatalf("component Run must not claim distribution builds: claim=%+v err=%v", claim, err) + } +} + func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) { svc := newTestCoreService() hello, err := svc.RegisterRunHello(validRunControlHello()) diff --git a/platform/service/distributions.go b/platform/service/distributions.go index ef017b6..249c2ec 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -44,6 +44,9 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil { return domain.RunDistribution{}, err } + if err := svc.promoteLegacyRunBinding(&instance); err != nil { + return domain.RunDistribution{}, err + } builderEndpointID := instance.RunEndpointID if strings.TrimSpace(instance.DeploymentTargetID) != "" { builderEndpointID = instance.DeploymentTargetID @@ -125,6 +128,27 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ return domain.CopyRunDistribution(distribution), nil } +// promoteLegacyRunBinding reserves a server-scoped endpoint for a generated +// Run before a legacy server first requests a distribution. Its existing +// endpoint remains the trusted build target; reusing it in the package would +// allow the generated Run to replace the builder registration. +func (svc *CoreService) promoteLegacyRunBinding(instance *domain.ServerInstance) error { + if instance == nil || strings.TrimSpace(instance.DeploymentTargetID) != "" || (instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateFailed) { + return nil + } + builderEndpointID := strings.TrimSpace(instance.RunEndpointID) + if builderEndpointID == "" { + return validationError("legacy Run generation requires a build target endpoint") + } + instance.DeploymentTargetID = builderEndpointID + instance.RunEndpointID = dedicatedRunEndpointID(instance.ID) + instance.UpdatedAt = svc.now() + if err := validator.ValidateServerInstance(*instance); err != nil { + return err + } + return svc.store.ServerInstances().Update(*instance) +} + func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) { request = domain.CopyClientManagerBuildRequest(request) if strings.TrimSpace(request.IdempotencyKey) == "" { diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index d04ed49..d2534af 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -120,6 +120,31 @@ func TestCoreServiceBuildsDedicatedRunOnDeploymentTarget(t *testing.T) { } } +func TestCoreServicePromotesLegacyRunBindingBeforeDistributionBuild(t *testing.T) { + svc, session, instance := newDistributionTestFixture(t) + builderID := instance.RunEndpointID + instance.State = domain.ServerInstanceStateFailed + if err := svc.store.ServerInstances().Update(instance); err != nil { + t.Fatalf("mark legacy server failed: %v", err) + } + + distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "legacy-promote-build"}) + if err != nil { + t.Fatalf("generate promoted legacy Run: %v", err) + } + migrated, err := svc.GetServerInstance(instance.ID) + if err != nil { + t.Fatalf("get migrated server: %v", err) + } + if migrated.DeploymentTargetID != builderID || migrated.RunEndpointID != "server-run-"+instance.ID { + t.Fatalf("expected legacy binding promotion, got %+v", migrated) + } + job, err := svc.GetJob(distribution.BuildJobID) + if err != nil || job.RunEndpointID != builderID || distribution.RunEndpointID != migrated.RunEndpointID { + t.Fatalf("expected build target %q and dedicated package endpoint %q, job=%+v distribution=%+v err=%v", builderID, migrated.RunEndpointID, job, distribution, err) + } +} + func TestCoreServiceRejectsDistributionBuildForStaleRunEndpoint(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) svc.now = func() time.Time { return fixedTime.Add(capacityHeartbeatStaleAfter + time.Second) } diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 9df71c0..6fa636e 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -29,6 +29,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai if err != nil { return domain.RunJobClaimResult{}, err } + if session.RequireSignedRequests { + claim.Capabilities = withoutCapability(claim.Capabilities, domain.JobCapabilityDistributionBuild) + } stamp := svc.now() svc.jobMu.Lock() @@ -40,6 +43,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs { return emptyJobClaim(claim.RunEndpointID, stamp), nil } + if session.RequireSignedRequests && len(claim.Capabilities) == 0 { + return emptyJobClaim(claim.RunEndpointID, stamp), nil + } jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) if err != nil { return domain.RunJobClaimResult{}, err @@ -82,6 +88,16 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai }), nil } +func withoutCapability(capabilities []string, forbidden string) []string { + filtered := make([]string, 0, len(capabilities)) + for _, capability := range capabilities { + if capability != forbidden { + filtered = append(filtered, capability) + } + } + return filtered +} + func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, error) { if err := validator.ValidateRunJobAck(ack); err != nil { return domain.RunJobAckResult{}, err