diff --git a/openspec/changes/move-scum-deployment-lifecycle-to-plugin/.openspec.yaml b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/.openspec.yaml new file mode 100644 index 0000000..ffa710f --- /dev/null +++ b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-31 diff --git a/openspec/changes/move-scum-deployment-lifecycle-to-plugin/design.md b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/design.md new file mode 100644 index 0000000..f5c52ad --- /dev/null +++ b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/design.md @@ -0,0 +1,37 @@ +## Design + +The lifecycle boundary becomes: + +- Platform validates the selected plugin, resolves the plugin lifecycle action reference, packages plugin action assets into generated run workspaces, and dispatches a generic lifecycle job with typed inputs. +- The SCUM plugin owns Windows scripts/action specs that check whether `SCUMServer.exe` exists, install or update through SteamCMD, stop before update, write plugin-declared config values, and start with plugin-declared `port`/`MaxPlayers` plus the plugin default `-log`. +- Run executes the referenced action through generic primitives. It never branches on `game.scum`, `SCUMServer.exe`, or app `3792580`. + +### SCUM Plugin Action Model + +The SCUM install action is the authoritative setup action. It runs before first start and may also be reused as a pre-start update action. The action checks for SteamCMD, downloads it if missing, stops the SCUM process when an update is needed, runs: + +```powershell +./steamcmd.exe +force_install_dir C:/scumserver +login anonymous +app_update 3792580 validate +quit +``` + +Then the start action runs: + +```powershell +C:/scumserver\SCUM\Binaries\Win64\SCUMServer.exe -port= -MaxPlayers= -log +``` + +The concrete path may come from server deployment inputs, but the SCUM executable relative path, app id, SteamCMD arguments, and `-log` default are plugin assets, not platform/run code. + +### Platform Changes + +Platform no longer freezes a `ServerDeploymentPlan` for SCUM or requires `deployment.scum.v1`. It keeps generic deployment definitions and dispatches plugin action refs. Deployment projections can still show queued/running/failed lifecycle state from generic receipts, but detailed SCUM evidence is plugin-generated output/log/artifact data, not a platform-owned SCUM evidence schema. + +### Run Changes + +Run removes the SCUM deployment executor and SCUM capability advertisement. Assignments carrying `serverDeploymentPlan` are rejected as legacy unsupported input. Generic lifecycle template execution remains, including managed process start/stop/status, file operations, logs, and artifacts. + +## Risks / Trade-Offs + +- Existing tests expecting SCUM evidence need to shift to plugin-action dispatch assertions. +- The first plugin script implementation must be careful about Windows quoting and idempotency. +- If future games need richer setup flows, add generic action primitives or structured lifecycle DSL features without moving game policy into run. diff --git a/openspec/changes/move-scum-deployment-lifecycle-to-plugin/proposal.md b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/proposal.md new file mode 100644 index 0000000..43f3a7e --- /dev/null +++ b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/proposal.md @@ -0,0 +1,28 @@ +## Why + +The current SCUM deployment flow hardcodes SteamCMD, SCUM executable paths, app id `3792580`, config writes, and start arguments inside platform/run code. That violates the intended boundary: the SCUM plugin should own game lifecycle policy while platform dispatches and run executes generic, bounded actions. + +## What Changes + +- **BREAKING**: Retire the SCUM-specific `deployment.scum.v1` run capability and the platform-to-run `serverDeploymentPlan` path for SCUM installs/adoptions. +- Move SCUM install/update/start command ownership into the SCUM plugin action bundle. +- Use plugin lifecycle actions for idempotent "install if missing, update if present, then start" behavior. +- Keep platform limited to manifest/action validation, distribution packaging, lifecycle job dispatch, and generic result projection. +- Keep run limited to generic action execution, scoped file/process operations, logs, artifacts, and capability enforcement. + +## Capabilities + +### New Capabilities + +- `plugin-owned-game-lifecycle`: Game plugins own game-specific lifecycle commands, default launch flags, app IDs, executable paths, install/update policies, and pre-start checks. + +### Modified Capabilities + +- `platform-side-distribution-builds`: Generated run packages must carry plugin-owned lifecycle action assets without requiring run to advertise game-specific deployment capabilities. + +## Impact + +- Affects `plugins/examples/scum-server-plugin` action assets and manifest declarations. +- Affects `platform/service`, `platform/domain`, `platform/dto`, `platform/validator`, and protocol docs by removing SCUM-specific deployment plan dispatch/gating. +- Affects the independent `run` repository by removing SCUM-specific runtime execution and capability advertisement. +- Requires tests proving SCUM lifecycle jobs are plugin action jobs and run no longer contains a SCUM deployment executor. diff --git a/openspec/changes/move-scum-deployment-lifecycle-to-plugin/specs/plugin-owned-game-lifecycle/spec.md b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/specs/plugin-owned-game-lifecycle/spec.md new file mode 100644 index 0000000..8d18bf8 --- /dev/null +++ b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/specs/plugin-owned-game-lifecycle/spec.md @@ -0,0 +1,42 @@ +# plugin-owned-game-lifecycle Specification + +## Purpose + +Ensure game-specific server lifecycle behavior lives in game plugins while platform and run remain generic. + +## ADDED Requirements + +### Requirement: Game plugins own game-specific lifecycle policy + +The system SHALL keep game-specific install, update, pre-start, start argument, stop, status, executable path, app id, and default launch flag policy in plugin-owned manifests, action specs, templates, or scripts. + +#### Scenario: SCUM lifecycle declares concrete commands in plugin assets + +- **WHEN** SCUM requires SteamCMD installation/update or server start +- **THEN** the Steam app id `3792580`, `SCUMServer.exe` relative path, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` are provided by SCUM plugin assets or plugin startup fields +- **AND** platform and run do not hardcode those values to make SCUM lifecycle work + +### Requirement: Platform dispatches plugin lifecycle actions generically + +The platform SHALL dispatch lifecycle jobs using plugin-declared action references and generic lifecycle/deployment inputs, and SHALL NOT require a game-specific run capability for SCUM deployment. + +#### Scenario: SCUM install job is queued + +- **WHEN** a SCUM server create/install lifecycle job is created +- **THEN** the job target key references the SCUM plugin install action +- **AND** the job execution input does not include a SCUM-specific server deployment plan +- **AND** required run capabilities contain only generic lifecycle/deployment capabilities + +### Requirement: Run executes generic actions only + +Run SHALL execute lifecycle action templates and scoped process/file operations generically, and SHALL NOT branch on a game id or contain per-game deployment executors. + +#### Scenario: Legacy SCUM deployment plan reaches run + +- **WHEN** a run assignment includes a legacy `serverDeploymentPlan` +- **THEN** run rejects it as unsupported legacy input instead of executing game-specific deployment logic + +#### Scenario: Run capability report + +- **WHEN** run reports supported capabilities +- **THEN** the report does not include `deployment.scum.v1` diff --git a/openspec/changes/move-scum-deployment-lifecycle-to-plugin/tasks.md b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/tasks.md new file mode 100644 index 0000000..dfcf85b --- /dev/null +++ b/openspec/changes/move-scum-deployment-lifecycle-to-plugin/tasks.md @@ -0,0 +1,25 @@ +## 1. OpenSpec Artifacts + +- [x] 1.1 Create proposal, design, and spec delta for plugin-owned SCUM lifecycle ownership. +- [x] 1.2 Validate the OpenSpec change strictly before completion. + +## 2. Plugin Lifecycle Assets + +- [x] 2.1 Add SCUM-owned Windows install/update and start scripts/action specs with SteamCMD app update and startup arguments owned by the plugin. +- [x] 2.2 Update plugin manifest/tests so SCUM lifecycle no longer depends on `deployment.scum.v1`. + +## 3. Platform Dispatch Boundary + +- [x] 3.1 Remove SCUM-specific deployment plan creation, capability gating, and evidence validation from platform lifecycle dispatch. +- [x] 3.2 Update platform tests/protocol docs to assert generic plugin action dispatch. + +## 4. Run Executor Boundary + +- [x] 4.1 Remove the SCUM-specific run executor/capability path and reject legacy `serverDeploymentPlan` inputs generically. +- [x] 4.2 Update run tests to prove generated run capabilities exclude `deployment.scum.v1` and generic plugin actions still execute. + +## 5. Verification And Delivery + +- [x] 5.1 Run focused plugin, platform, and run tests. +- [x] 5.2 Run `scripts/check-structure.sh` and final repository status checks. +- [ ] 5.3 Commit and push main-repo and run-repo changes separately. diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index c184f71..ad2594e 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -127,6 +127,7 @@ type DistributionBuildInput struct { SecretRef string KeyGeneration int AuthKey string + WorkspaceSeed string } type DependencyExecutionInputRequest struct { diff --git a/platform/domain/resources.go b/platform/domain/resources.go index c75a740..8ea3a45 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -582,8 +582,8 @@ type RuntimeServerPrerequisite struct { Kind string } -// RuntimeServerDeploymentProfile is a frozen, game-specific deployment -// template. It contains logical references only; Run resolves them locally. +// RuntimeServerDeploymentProfile is a legacy game-specific deployment template +// declaration kept for backward-compatible manifest decoding. type RuntimeServerDeploymentProfile struct { Key string Version string @@ -624,6 +624,7 @@ type GamePluginManifest struct { Capabilities []string Permissions []string Actions PluginLifecycleActions + AssetFiles []PluginAssetFile Pages []GamePluginPage FileWorkspace PluginFileWorkspace AI GamePluginManifestAI @@ -637,6 +638,13 @@ type GamePluginManifest struct { type GamePluginManifestRegistration struct { ManifestRef string Manifest GamePluginManifest + AssetFiles []PluginAssetFile +} + +type PluginAssetFile struct { + Path string + Content string + Mode int } type GamePlugin struct { @@ -654,6 +662,7 @@ type GamePlugin struct { DeclaredPermissions []string Permissions PluginPermissions LifecycleActions PluginLifecycleActions + LifecycleAssets []PluginAssetFile BridgeActions []string Pages []GamePluginPage FileWorkspace PluginFileWorkspace @@ -1057,7 +1066,6 @@ const ( // JobCapabilityDeploymentPlan gates Run implementations that understand // protected deployment definitions, absolute paths, and custom commands. JobCapabilityDeploymentPlan = "deployment.plan.v1" - JobCapabilitySCUMDeploymentPlan = "deployment.scum.v1" JobCapabilityDeploymentShellPosix = "deployment.shell.posix-sh" JobCapabilityDeploymentShellPowerShell = "deployment.shell.powershell" JobCapabilityDeploymentShellCmd = "deployment.shell.cmd" @@ -1648,6 +1656,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin { plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields) + plugin.LifecycleAssets = CopyPluginAssetFiles(plugin.LifecycleAssets) plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) plugin.FileWorkspace = CopyPluginFileWorkspace(plugin.FileWorkspace) @@ -1699,9 +1708,19 @@ func CopyPluginMarketplacePluginSlice(plugins []PluginMarketplacePlugin) []Plugi func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistration) GamePluginManifestRegistration { registration.Manifest = CopyGamePluginManifest(registration.Manifest) + registration.AssetFiles = CopyPluginAssetFiles(registration.AssetFiles) return registration } +func CopyPluginAssetFiles(files []PluginAssetFile) []PluginAssetFile { + if files == nil { + return nil + } + out := make([]PluginAssetFile, len(files)) + copy(out, files) + return out +} + func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { manifest.Tags = CopyStringSlice(manifest.Tags) manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS) @@ -1709,6 +1728,7 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions) manifest.Capabilities = CopyStringSlice(manifest.Capabilities) manifest.Permissions = CopyStringSlice(manifest.Permissions) + manifest.AssetFiles = CopyPluginAssetFiles(manifest.AssetFiles) manifest.Pages = CopyGamePluginPageSlice(manifest.Pages) manifest.FileWorkspace = CopyPluginFileWorkspace(manifest.FileWorkspace) manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes) diff --git a/platform/domain/resources.md b/platform/domain/resources.md index ab02a32..76ba98d 100644 --- a/platform/domain/resources.md +++ b/platform/domain/resources.md @@ -111,12 +111,13 @@ Run sessions persist only a token hash, generation, status, expiry, capability f Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` contract, including discovery, lifecycle, dependency/install, log, transport, and client-manager declarations. One server binding selects one declared lifecycle profile. Platform derives allowed and required logical keys; clients cannot assert `missingKeys` or `status`. -SCUM plugins may additionally declare `runtimeProfiles.serverDeployments`. These -profiles are versioned, freeze into a leased Run assignment, and contain only -logical executable/config markers, field mappings, discovery markers, and -verification checks. A server's `DeploymentProjection` stores bounded evidence -from Run for operator views; protected paths, commands, credentials, process -ids, and sockets never enter that projection. +Plugin lifecycle assets are registered as manifest-declared files plus a +content-bearing registration payload. Platform packages those assets into +generated Run workspaces so plugin action refs such as `actions/install.json` +and script refs such as `bin/scum-start.cmd` are available before the first +install/start job. Game-specific install/update/start policy, including SCUM +SteamCMD app IDs and launch flags, stays in the plugin asset bundle rather than +in platform services or Run executors. Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations. diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 62575e8..ede5c88 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -224,6 +224,7 @@ type DistributionBuildInputResponse struct { SecretRef string `json:"secretRef"` KeyGeneration int `json:"keyGeneration"` AuthKey string `json:"authKey"` + WorkspaceSeed string `json:"workspaceSeed,omitempty"` } type DependencyExecutionInputRequest struct { @@ -568,6 +569,7 @@ func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) Distr SecretRef: input.SecretRef, KeyGeneration: input.KeyGeneration, AuthKey: input.AuthKey, + WorkspaceSeed: input.WorkspaceSeed, } } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 2f42b3c..4d5d833 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -371,6 +371,7 @@ type GamePluginManifestBody struct { Capabilities []string `json:"capabilities"` Permissions []string `json:"permissions"` Actions PluginLifecycleActionsBody `json:"actions"` + AssetFiles []PluginAssetFileBody `json:"assetFiles,omitempty"` Pages []GamePluginPageBody `json:"pages,omitempty"` FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"` AI GamePluginManifestAIBody `json:"ai,omitempty"` @@ -384,6 +385,13 @@ type GamePluginManifestBody struct { type GamePluginManifestRegistrationRequest struct { ManifestRef string `json:"manifestRef"` Manifest GamePluginManifestBody `json:"manifest"` + AssetFiles []PluginAssetFileBody `json:"assetFiles,omitempty"` +} + +type PluginAssetFileBody struct { + Path string `json:"path"` + Content string `json:"content,omitempty"` + Mode int `json:"mode,omitempty"` } type GamePluginCreateRequest struct { @@ -996,6 +1004,7 @@ func (request AIProviderUpdateRequest) ToDomain(id string, status domain.AIProvi func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePluginManifestRegistration { return domain.GamePluginManifestRegistration{ ManifestRef: request.ManifestRef, + AssetFiles: pluginAssetFilesToDomain(request.AssetFiles), Manifest: domain.GamePluginManifest{ ID: request.Manifest.ID, Name: request.Manifest.Name, @@ -1008,6 +1017,7 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities), Permissions: domain.CopyStringSlice(request.Manifest.Permissions), Actions: request.Manifest.Actions.ToDomain(), + AssetFiles: pluginAssetFilesToDomain(request.Manifest.AssetFiles), Pages: pagesToDomain(request.Manifest.Pages), FileWorkspace: fileWorkspaceToDomain(request.Manifest.FileWorkspace), AI: request.Manifest.AI.ToDomain(), @@ -1020,6 +1030,17 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi } } +func pluginAssetFilesToDomain(files []PluginAssetFileBody) []domain.PluginAssetFile { + if files == nil { + return nil + } + out := make([]domain.PluginAssetFile, len(files)) + for i, file := range files { + out[i] = domain.PluginAssetFile{Path: file.Path, Content: file.Content, Mode: file.Mode} + } + return out +} + func mapTrajectoryDeclarationToDomain(value *GameMapTrajectoryDeclarationBody) *domain.GameMapTrajectoryDeclaration { if value == nil { return nil diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index 1c26414..628bc1d 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -77,20 +77,28 @@ func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) { CreateFormSchema: "schemas/create-form.schema.json", }, Actions: PluginLifecycleActionsBody{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"}, + AssetFiles: []PluginAssetFileBody{ + {Path: "actions/install.json", Mode: 0o600}, + }, Pages: []GamePluginPageBody{ {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, }, AI: GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}}, }, + AssetFiles: []PluginAssetFileBody{ + {Path: "actions/install.json", Content: "{}", Mode: 0o600}, + }, } domainRegistration := request.ToDomain() domainRegistration.Manifest.Tags[0] = "mutated" domainRegistration.Manifest.Server.SupportedOS[0] = "darwin" + domainRegistration.Manifest.AssetFiles[0].Path = "actions/mutated.json" + domainRegistration.AssetFiles[0].Content = "mutated" domainRegistration.Manifest.Pages[0].Permissions[0] = "ai.invoke" domainRegistration.Manifest.AI.Purposes[0] = "config.suggest" - if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" { + if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.AssetFiles[0].Path != "actions/install.json" || request.AssetFiles[0].Content != "{}" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" { t.Fatalf("expected manifest request slices to be copied, got %+v", request) } } diff --git a/platform/model/resources.go b/platform/model/resources.go index ccdcf4a..977d655 100644 --- a/platform/model/resources.go +++ b/platform/model/resources.go @@ -175,6 +175,8 @@ type GamePlugin struct { Permissions PluginPermissions `json:"permissions" db:"permissions"` // LifecycleActions stores manifest lifecycle action references. LifecycleActions PluginLifecycleActions `json:"lifecycleActions" db:"lifecycle_actions"` + // LifecycleAssets stores plugin-owned lifecycle files packaged into generated Run workspaces. + LifecycleAssets []domain.PluginAssetFile `json:"lifecycleAssets,omitempty" db:"lifecycle_assets"` // Pages stores plugin-local page metadata. Pages []GamePluginPage `json:"pages" db:"pages"` // Tags stores bounded catalog tags. @@ -597,6 +599,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin { DeclaredPermissions: plugin.DeclaredPermissions, Permissions: permissionsFromDomain(plugin.Permissions), LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions), + LifecycleAssets: domain.CopyPluginAssetFiles(plugin.LifecycleAssets), Pages: pagesFromDomain(plugin.Pages), Tags: plugin.Tags, AIPurposes: plugin.AIPurposes, @@ -623,6 +626,7 @@ func (plugin GamePlugin) ToDomain() domain.GamePlugin { DeclaredPermissions: domain.CopyStringSlice(plugin.DeclaredPermissions), Permissions: plugin.Permissions.ToDomain(), LifecycleActions: plugin.LifecycleActions.ToDomain(), + LifecycleAssets: domain.CopyPluginAssetFiles(plugin.LifecycleAssets), Pages: pagesToDomain(plugin.Pages), Tags: domain.CopyStringSlice(plugin.Tags), AIPurposes: domain.CopyStringSlice(plugin.AIPurposes), diff --git a/platform/protocol/server-deployment.md b/platform/protocol/server-deployment.md index eb1bc4f..83dac08 100644 --- a/platform/protocol/server-deployment.md +++ b/platform/protocol/server-deployment.md @@ -1,74 +1,64 @@ -# Server deployment plan v1 +# Server deployment lifecycle -`deployment.plan.v1` is the capability gate for Run implementations that can -execute a protected server deployment plan. Platform only sends the plan in a -leased `RunJobAssignmentResponse.executionInput.deployment`; it never appears -in public server, job, audit, log, or plugin-bridge responses. +`deployment.plan.v1` gates only generic custom-command deployment execution: +Platform sends a protected `RunJobAssignmentResponse.executionInput.deployment` +when a server uses `custom-command` mode and Run executes the operator-reviewed +argv-oriented command inside its local policy. -SCUM controlled deployments additionally require `deployment.scum.v1`. The -leased assignment includes `executionInput.serverDeploymentPlan` with -`schemaVersion: "1"`, an operation of `install` or `adopt`, the frozen template -key/version, Steam app id, logical executable/config references, explicit field -mappings, discovery markers, and required verification checks. The plan is -secret-free and contains logical keys only; the protected deployment body still -holds the operator's paths/commands and is resolved only by Run. +Guided or existing-server game setup is plugin-owned. Platform dispatches the +plugin-declared lifecycle action reference, such as `actions/install.json`, and +may include generic deployment context (`mode`, `profileKey`, `serverRoot`, and +`createInputs`) so the plugin action can resolve its own behavior. Platform does +not create a game-specific `serverDeploymentPlan`, and SCUM no longer requires a +`deployment.scum.v1` capability. ## Capability and policy -Run advertises `deployment.plan.v1` along with its normal lifecycle -capabilities. A Run that supports shell commands additionally advertises its -local policy for `posix-sh`, `powershell`, or `cmd` out of band with its -operator configuration. Platform must not infer shell support from command -text. Empty `shell` means argv-oriented execution. +Run advertises normal lifecycle capabilities (`process.install`, +`process.start`, `process.stop`, `process.status`) and generic primitives such as +scoped files, logs, artifacts, dependency helpers, and custom deployment plan +execution. A Run that supports shell commands advertises its local shell policy +out of band with operator configuration. Platform must not infer shell support +from command text. Empty `shell` means argv-oriented execution. + +## Plugin-owned game lifecycle + +Game plugins own concrete game policy: install/update commands, app ids, +executable refs, default launch flags, stop-before-update behavior, and startup +argument construction. For SCUM, the plugin action assets own the SteamCMD flow: +stop `SCUMServer.exe` when updating, run +`steamcmd.exe +force_install_dir +login anonymous +app_update 3792580 validate +quit`, +and start `\\SCUM\\Binaries\\Win64\\SCUMServer.exe -port= -MaxPlayers= -log`. + +Generated Run distributions carry validated plugin lifecycle assets into the +server-scoped workspace. Run materializes those assets at startup and executes +them through generic action template handling; it does not branch on game ids, +Steam app ids, SCUM executable paths, or launch flags. ## Required local preflight -Before a write, install, or process action, Run validates the selected plan: +Before a custom-command action, Run validates the selected protected deployment +body: -- absolute server root and working directory are allowed anywhere permitted by - the local Run policy; they are not required to be adjacent to the Run binary; -- the effective directory, executable, permissions, timeout, plugin version, - and requested ports are locally valid; +- absolute server root and working directory are allowed only where permitted by + local Run policy; - selected shell kind and custom-command policy are enabled; - no raw command, path, secret, socket address, or credential is emitted in a result, diagnostic, log batch, or artifact name. -For an SCUM `install`, Run performs SteamCMD app installation followed by -configuration materialization and health checks. For `adopt`, Run performs a -scan first and must not reinstall or overwrite existing configuration. A -controlled SCUM install or adoption requires an explicit protected server root; -the root is never exposed in browser projections. Adoption does not require -new-install create inputs because its mapping phase is read-only unless a -separate approved write is dispatched. A -terminal SCUM result must include bounded `serverDeploymentEvidence` with -preflight, discovery, mapping, and verification states. Required mapping -results are `applied` or `unchanged`; required verification results are -`passed` (adoption may report mapping as `skipped`). Failed results include a stable `failureCode` and never include the -resolved path or command text. Successful result kinds are -`scum.install.completed` and `scum.adopt.completed`; failed result kinds are -`scum.install.failed` and `scum.adopt.failed`. - -The first-party Windows SCUM template declares `steamcmd`, Visual C++ 2012, -2013, and 2015-2022 (x86 and x64), plus DirectX runtime prerequisites. Run -checks their Windows markers before installation and uses only its fixed -Microsoft installer catalog with silent arguments when one is absent. The -template builds SteamCMD as separate arguments: `+force_install_dir`, the -protected root, anonymous login, App `3792580`, `validate`, and `+quit`. The -original root and constructed command are eligible only for opt-in local Run -diagnostics; they never enter progress or result payloads. - -An `existing-server` plan may omit installation. A `custom-command` plan -requires a start command. Guided templates remain plugin recommendations; -Run owns their local resolution and execution. +For plugin-owned actions, Run validates scoped action template paths, executable +asset refs, bounded environment variables, and lifecycle action/capability +matches. Deployment create inputs may be exposed to the action as bounded +environment variables such as `SERVER_CREATE_GAMEPORT`; protected command text is +not exposed unless the server explicitly uses custom-command mode. ## Safe progress reports Run reports bounded progress with `percent`, `phase`, and a safe message. The allowed phase vocabulary is `queued`, `claimed`, `preflight`, `scan`, `install`, -`configure`, `mapping`, `start`, and `health`. On failure it reports a stable safe error -code and summary such as `working-directory-unavailable`, never the supplied -path or command text. +`configure`, `mapping`, `start`, and `health`. On failure it reports a stable +safe error code and summary, never the supplied path or command text. -Platform treats preflight as authoritative. It does not open a direct shell, +Platform treats Run preflight as authoritative. It does not open a direct shell, SSH connection, raw socket, or host filesystem to compensate for a failed preflight. diff --git a/platform/service/control_test.go b/platform/service/control_test.go index 3c77b21..f58f8b5 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -351,7 +351,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T domain.LifecycleCapabilityStop, "process.restart", domain.LifecycleCapabilityStatus, - domain.JobCapabilitySCUMDeploymentPlan, "files.list", domain.JobCapabilityFilesRead, "files.patch", @@ -374,7 +373,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T Platforms: []string{"windows"}, }} requireManualSCUMRuntimeBindings(&plugin, "run-local") - plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}} if err := svc.store.GamePlugins().Create(plugin); err != nil { t.Fatalf("create SCUM plugin fixture: %v", err) } @@ -418,7 +416,7 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T hello.KeyGeneration = key.Generation hello.Platform = "windows" hello.Architecture = "amd64" - hello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan} + hello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus} registered, err := svc.RegisterRunHello(hello) if err != nil || !registered.Accepted { t.Fatalf("register generated SCUM Run: result=%+v err=%v", registered, err) @@ -433,15 +431,15 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err) } job := jobs[0] - if job.Capability != domain.LifecycleCapabilityInstall || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan == nil { - t.Fatalf("expected SCUM install job with protected deployment plan, job=%+v", job) + if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil { + t.Fatalf("expected SCUM install job with plugin action and generic deployment inputs, job=%+v", job) } if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs) } claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job.ExecutionInput.ServerDeploymentPlan == nil { - t.Fatalf("generated SCUM Run should claim install job with frozen plan, claim=%+v err=%v", claim, err) + if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { + t.Fatalf("generated SCUM Run should claim plugin-owned install action, claim=%+v err=%v", claim, err) } } diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index c170215..5c5c4b5 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -1,6 +1,8 @@ package service import ( + "encoding/base64" + "encoding/json" "errors" "strings" @@ -122,12 +124,17 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D if err != nil { return domain.DistributionBuildInput{}, err } + profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution) + if err != nil { + return domain.DistributionBuildInput{}, err + } return domain.DistributionBuildInput{ JobID: job.ID, ComponentKind: domain.DistributionComponentRun, ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, + ProfileKey: profileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, TargetRelease: distribution.ID, @@ -138,6 +145,7 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plainKey, + WorkspaceSeed: workspaceSeed, }, nil } @@ -183,6 +191,48 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D return domain.DistributionBuildInput{}, repo.ErrNotFound } +func (svc *CoreService) runDistributionWorkspaceSeed(distribution domain.RunDistribution) (string, string, error) { + instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID) + if err != nil { + return "", "", err + } + plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) + if err != nil { + return "", "", err + } + seed, err := encodePluginWorkspaceSeed(plugin.LifecycleAssets) + if err != nil { + return "", "", err + } + profileKey := instance.Deployment.ProfileKey + if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil { + profileKey = binding.ProfileKey + } else if !errors.Is(bindingErr, repo.ErrNotFound) { + return "", "", bindingErr + } + return profileKey, seed, nil +} + +func encodePluginWorkspaceSeed(files []domain.PluginAssetFile) (string, error) { + if len(files) == 0 { + return "", nil + } + type seedFile struct { + Path string `json:"path"` + Content string `json:"content"` + Mode int `json:"mode,omitempty"` + } + seed := make([]seedFile, len(files)) + for i, file := range files { + seed[i] = seedFile{Path: file.Path, Content: file.Content, Mode: file.Mode} + } + body, err := json.Marshal(seed) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(body), nil +} + // storeDistributionBuildArtifact records the built package under job ownership // so the existing ArtifactOwnerKindJob scope assertions keep guarding it. func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID string, payload []byte) error { diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index 6634dd3..3237fe4 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -1,6 +1,8 @@ package service import ( + "encoding/base64" + "encoding/json" "errors" "strings" "sync" @@ -31,6 +33,17 @@ func (builder captureDistributionBuilder) Build(input domain.DistributionBuildIn func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + t.Fatalf("get plugin fixture: %v", err) + } + plugin.LifecycleAssets = []domain.PluginAssetFile{ + {Path: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600}, + {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700}, + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("seed plugin lifecycle assets: %v", err) + } inputs := make(chan domain.DistributionBuildInput, 1) release := make(chan struct{}) var releaseOnce sync.Once @@ -55,6 +68,17 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { if platformInput.AuthKey == "" || platformInput.JobID != distribution.BuildJobID || platformInput.RunEndpointID != instance.RunEndpointID { t.Fatalf("platform builder received incomplete internal input: %+v", platformInput) } + decodedSeed, err := base64.StdEncoding.DecodeString(platformInput.WorkspaceSeed) + if err != nil { + t.Fatalf("decode workspace seed: %v", err) + } + var seedFiles []domain.PluginAssetFile + if err := json.Unmarshal(decodedSeed, &seedFiles); err != nil { + t.Fatalf("unmarshal workspace seed: %v", err) + } + if platformInput.ProfileKey != "local" || len(seedFiles) != 2 || seedFiles[1].Path != "bin/install-server" || seedFiles[1].Content == "" { + t.Fatalf("platform builder received incomplete plugin workspace seed: profile=%q seed=%+v", platformInput.ProfileKey, seedFiles) + } auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{ ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentRun, diff --git a/platform/service/distribution_build_jobs.go b/platform/service/distribution_build_jobs.go index f551e68..f8d9eba 100644 --- a/platform/service/distribution_build_jobs.go +++ b/platform/service/distribution_build_jobs.go @@ -51,12 +51,17 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui if err != nil { return domain.DistributionBuildInput{}, err } + profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution) + if err != nil { + return domain.DistributionBuildInput{}, err + } return domain.DistributionBuildInput{ JobID: job.ID, ComponentKind: domain.DistributionComponentRun, ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, + ProfileKey: profileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, TargetRelease: distribution.ID, @@ -67,6 +72,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plainKey, + WorkspaceSeed: workspaceSeed, }, nil } diff --git a/platform/service/distribution_builder.go b/platform/service/distribution_builder.go index 1d22fa7..c73d963 100644 --- a/platform/service/distribution_builder.go +++ b/platform/service/distribution_builder.go @@ -6,6 +6,7 @@ import ( "bytes" "compress/gzip" "context" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -158,6 +159,17 @@ func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildIn if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil { return nil, err } + seedPayload := []byte("[]") + if strings.TrimSpace(input.WorkspaceSeed) != "" { + decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed) + if err != nil { + return nil, validationError("distribution build input has an invalid workspace seed") + } + seedPayload = decoded + } + if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil { + return nil, err + } if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil { return nil, err } @@ -197,7 +209,16 @@ set -eu auth_key="$(cat /workspace/input/auth-key)" if [ "$COMPONENT_KIND" = "run" ]; then - cd /workspace/source + rm -rf /workspace/build/run-source + mkdir -p /workspace/build/run-source + cp -R /workspace/source/. /workspace/build/run-source/ + seed_b64="$(base64 /workspace/input/workspace-seed.json | tr -d '\n')" + cat > /workspace/build/run-source/config/workspace_seed_generated.go < 64 { - return validationError("SCUM deployment evidence contains too many " + name) - } - for key, value := range values { - if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$`).MatchString(key) || len(value) > 160 || strings.TrimSpace(value) != value || unsafeSCUMEvidenceValue(value) { - return validationError(fmt.Sprintf("SCUM deployment evidence contains an unsafe %s value", name)) - } - } - } - if len(evidence.FailureCode) > 80 || (evidence.FailureCode != "" && !regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,79}$`).MatchString(evidence.FailureCode)) { - return validationError("SCUM deployment failure code is invalid") - } - if result.State == domain.JobStateFailed { - if result.ExecutionResult.Kind != "scum.install.failed" && result.ExecutionResult.Kind != "scum.adopt.failed" { - return validationError("SCUM deployment failure result type is invalid") - } - if strings.TrimSpace(evidence.FailureCode) == "" { - return validationError("SCUM deployment failure must include a stable failure code") - } - return nil - } - if result.State != domain.JobStateSucceeded { - return nil - } - expectedKind := "scum.install.completed" - if plan.Operation == "adopt" { - expectedKind = "scum.adopt.completed" - } - if result.ExecutionResult.Kind != expectedKind { - return validationError("SCUM deployment result type is invalid") - } - if evidence.PreflightState != "passed" || evidence.DiscoveryState != "passed" || evidence.VerificationState != "passed" { - return validationError("SCUM deployment result is missing successful preflight, discovery, or verification evidence") - } - validMappingState := evidence.MappingState == "passed" || evidence.MappingState == "applied" || evidence.MappingState == "unchanged" - if plan.Operation == "adopt" && evidence.MappingState == "skipped" { - validMappingState = true - } - if !validMappingState { - return validationError("SCUM deployment result is missing a successful config mapping state") - } - for _, mapping := range plan.ConfigMappings { - mappingResult := evidence.MappingResults[mapping.FieldKey] - if plan.Operation == "adopt" && mappingResult == "" { - mappingResult = "skipped" - } - if mapping.Required && mappingResult != "applied" && mappingResult != "unchanged" && !(plan.Operation == "adopt" && mappingResult == "skipped") { - return validationError("SCUM deployment result is missing a required config mapping") - } - } - for _, check := range plan.VerificationChecks { - if check.Required && evidence.VerificationResults[check.Key] != "passed" { - return validationError("SCUM deployment result is missing a required verification check") - } - } - return nil -} - -func unsafeSCUMEvidenceValue(value string) bool { - lower := strings.ToLower(value) - for _, token := range []string{"password", "secret", "credential", "token", "private key", "powershell", "cmd.exe", "bash -c", "ssh://", "tcp://", "udp://"} { - if strings.Contains(lower, token) { - return true - } - } - return strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\\`) || regexp.MustCompile(`^[A-Za-z]:[\\/]`).MatchString(value) -} diff --git a/platform/service/scum_deployment_test.go b/platform/service/scum_deployment_test.go index 4e65720..ef1ebc4 100644 --- a/platform/service/scum_deployment_test.go +++ b/platform/service/scum_deployment_test.go @@ -11,16 +11,10 @@ func scumDeploymentTestPlugin() domain.GamePlugin { ID: "game.scum", CreateFields: []domain.PluginCreateField{ {Key: "serverName", Type: "text", DefaultValue: "SCUM Test"}, - {Key: "gamePort", Type: "port", DefaultValue: "7777"}, + {Key: "gamePort", Type: "port", DefaultValue: "7779"}, {Key: "queryPort", Type: "port", DefaultValue: "27015"}, - {Key: "maxPlayers", Type: "number", DefaultValue: "64"}, + {Key: "maxPlayers", Type: "number", DefaultValue: "128"}, }, - RuntimeProfiles: domain.GamePluginRuntimeProfiles{ServerDeployments: []domain.RuntimeServerDeploymentProfile{{ - Key: "scum-steamcmd-windows", Version: "1.0.0", SteamAppID: "3792580", ExecutableKey: "scum/server-executable", InstallRootKey: "server/install-root", ConfigKey: "scum/server-settings", ConfigFormat: "ini", - Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "steamcmd"}, {Key: "vcredist-2012-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2012-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x64", Kind: "windows-vcredist"}, {Key: "directx-jun2010", Kind: "windows-directx"}}, - ConfigMappings: []domain.RuntimeServerConfigMapping{{FieldKey: "serverName", ConfigKey: "server-settings.server-name", ValueType: "text", Required: true}, {FieldKey: "gamePort", ConfigKey: "server-settings.game-port", ValueType: "port", Required: true}, {FieldKey: "queryPort", ConfigKey: "server-settings.query-port", ValueType: "port", Required: true}, {FieldKey: "maxPlayers", ConfigKey: "server-settings.max-players", ValueType: "integer", Required: true}}, - VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}}, - }}}, } } @@ -42,81 +36,21 @@ func requireManualSCUMRuntimeBindings(plugin *domain.GamePlugin, profileKey stri } } -func TestSCUMDeploymentPlanSeparatesInstallAndAdopt(t *testing.T) { +func TestApplyPluginCreateDefaultsUsesPluginFields(t *testing.T) { plugin := scumDeploymentTestPlugin() - definition := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Alpha", "gamePort": "7777", "queryPort": "27015", "maxPlayers": "64"}} - plan, err := scumDeploymentPlan(plugin, definition, "install") - if err != nil || plan == nil || plan.Operation != "install" || plan.SteamAppID != "3792580" { - t.Fatalf("unexpected install plan: %+v, %v", plan, err) - } - definition.Mode = domain.ServerDeploymentModeExisting - plan, err = scumDeploymentPlan(plugin, definition, "adopt") - if err != nil || plan == nil || plan.Operation != "adopt" { - t.Fatalf("unexpected adopt plan: %+v, %v", plan, err) + definition := applyPluginCreateDefaults(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Moon"}}) + + if definition.CreateInputs["serverName"] != "Moon" || definition.CreateInputs["gamePort"] != "7779" || definition.CreateInputs["maxPlayers"] != "128" { + t.Fatalf("expected plugin defaults to fill missing guided inputs: %+v", definition.CreateInputs) } } -func TestSCUMDeploymentEvidenceRequiresAllRequiredChecks(t *testing.T) { +func TestDeploymentDefinitionsUsePluginOwnedLifecycleWithoutRuntimeBinding(t *testing.T) { plugin := scumDeploymentTestPlugin() - plan, err := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}, "install") - if err != nil { - t.Fatal(err) + if deploymentNeedsCompleteRuntimeBinding(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}) { + t.Fatal("guided plugin-owned lifecycle should not require a complete runtime binding") } - result := domain.RunJobResult{State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: "scum.install.completed", ServerDeploymentEvidence: &domain.ServerDeploymentEvidence{TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion, PreflightState: "passed", DiscoveryState: "passed", MappingState: "applied", VerificationState: "passed", MappingResults: map[string]string{"serverName": "applied", "gamePort": "applied", "queryPort": "applied", "maxPlayers": "applied"}, VerificationResults: map[string]string{"executable": "passed", "version": "passed", "game-port": "passed", "config": "passed", "process": "passed"}}}} - if err := validateSCUMDeploymentEvidence(plan, result); err != nil { - t.Fatalf("valid evidence rejected: %v", err) - } - delete(result.ExecutionResult.ServerDeploymentEvidence.VerificationResults, "process") - if err := validateSCUMDeploymentEvidence(plan, result); err == nil { - t.Fatal("missing required process check was accepted") - } -} - -func TestSCUMAdoptionMaySkipConfigurationMappingAfterDiscovery(t *testing.T) { - plugin := scumDeploymentTestPlugin() - plan, err := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: `C:\\scum`}, "adopt") - if err != nil { - t.Fatal(err) - } - result := domain.RunJobResult{State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: "scum.adopt.completed", ServerDeploymentEvidence: &domain.ServerDeploymentEvidence{ - TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion, PreflightState: "passed", DiscoveryState: "passed", MappingState: "skipped", VerificationState: "passed", - VerificationResults: map[string]string{"executable": "passed", "version": "passed", "game-port": "passed", "config": "passed", "process": "passed"}, - }}} - if err := validateSCUMDeploymentEvidence(plan, result); err != nil { - t.Fatalf("valid adoption evidence rejected: %v", err) - } -} - -func TestSCUMInstallRequiresDirectoryButAdoptionDoesNotRequireCreateInputs(t *testing.T) { - plugin := scumDeploymentTestPlugin() - install := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Alpha"}} - if err := validateScumDeploymentInputs(plugin, install); err == nil { - t.Fatal("SCUM install without a directory was accepted") - } - install.ServerRoot = `C:\\scum` - if err := validateScumDeploymentInputs(plugin, install); err != nil { - t.Fatalf("SCUM install with defaults was rejected: %v", err) - } - adopt := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: `C:\\scum`} - if err := validateScumDeploymentInputs(plugin, adopt); err != nil { - t.Fatalf("SCUM adoption without create inputs was rejected: %v", err) - } -} - -func TestSCUMDeploymentFailureRequiresStableCode(t *testing.T) { - plugin := scumDeploymentTestPlugin() - plan, _ := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting}, "adopt") - result := domain.RunJobResult{State: domain.JobStateFailed, ExecutionResult: domain.JobExecutionResult{Kind: "scum.adopt.failed", ServerDeploymentEvidence: &domain.ServerDeploymentEvidence{TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion}}} - if err := validateSCUMDeploymentEvidence(plan, result); err == nil { - t.Fatal("failed deployment without failure code was accepted") - } -} - -func TestSCUMDeploymentTargetMustMatchTemplate(t *testing.T) { - plugin := scumDeploymentTestPlugin() - plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}} - definition := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided} - if err := validateSCUMDeploymentTarget(plugin, definition, domain.RunEndpoint{Platform: "linux", Architecture: "amd64"}); err == nil { - t.Fatal("linux Run target was accepted for the Windows-only SCUM template") + if !deploymentNeedsCompleteRuntimeBinding(plugin, domain.ServerDeploymentDefinition{}) { + t.Fatal("non-deployment runtime lifecycle still requires a complete runtime binding") } } diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index 92a70db..845c76a 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -59,9 +59,6 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, definition.CreateInputs); err != nil { return domain.ServerDeploymentView{}, err } - if err := validateScumDeploymentInputs(plugin, definition); err != nil { - return domain.ServerDeploymentView{}, err - } if update.RunEndpointID != "" { if _, err := svc.store.RunEndpoints().Get(update.RunEndpointID); err != nil { return domain.ServerDeploymentView{}, err @@ -69,17 +66,7 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan instance.RunEndpointID = update.RunEndpointID } instance.Deployment = definition - operation := "install" - if definition.Mode == domain.ServerDeploymentModeExisting { - operation = "adopt" - } - if plan, planErr := scumDeploymentPlan(plugin, definition, operation); planErr != nil { - return domain.ServerDeploymentView{}, planErr - } else if plan != nil { - instance.DeploymentProjection = scumDeploymentProjection(plan, operation, definition.UpdatedAt) - } else { - instance.DeploymentProjection = domain.ServerDeploymentProjection{} - } + instance.DeploymentProjection = domain.ServerDeploymentProjection{} instance.UpdatedAt = definition.UpdatedAt if err := validator.ValidateServerInstance(instance); err != nil { return domain.ServerDeploymentView{}, err @@ -131,18 +118,12 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma return domain.ServerLifecycleResult{}, err } instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment) - if err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil { - return domain.ServerLifecycleResult{}, err - } if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil { return domain.ServerLifecycleResult{}, err } if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil { return domain.ServerLifecycleResult{}, err } - if err := validateScumDeploymentInputs(plugin, instance.Deployment); err != nil { - return domain.ServerLifecycleResult{}, err - } if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil { return domain.ServerLifecycleResult{}, err } @@ -169,11 +150,6 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma } instance.State = domain.ServerInstanceStateInstalling instance.UpdatedAt = svc.now() - if instance.DeploymentProjection.TemplateKey != "" { - instance.DeploymentProjection.State = "queued" - instance.DeploymentProjection.PreflightState = "queued" - instance.DeploymentProjection.UpdatedAt = instance.UpdatedAt - } if err := svc.store.ServerInstances().Update(instance); err != nil { return domain.ServerLifecycleResult{}, err } diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index bfdb3e3..ccd718f 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -65,13 +65,13 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl } } -func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPlan(t *testing.T) { +func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) { svc := newTestCoreService() runHello := validRunControlHello() runHello.RunEndpointID = "run-scum-guided" runHello.Platform = "windows" runHello.Architecture = "amd64" - runHello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan} + runHello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop} runHello.CapabilityReport.Fingerprint = "cap-scum-guided" session, err := svc.RegisterRunHello(runHello) if err != nil { @@ -86,7 +86,6 @@ func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPla plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"} plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}} requireManualSCUMRuntimeBindings(&plugin, "local") - plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}} if err := svc.store.GamePlugins().Create(plugin); err != nil { t.Fatalf("create SCUM plugin fixture: %v", err) } @@ -99,12 +98,15 @@ func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPla if err != nil { t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err) } - if created.Job.ExecutionInput.ServerDeploymentPlan == nil || created.Job.ExecutionInput.ServerDeploymentPlan.SteamAppID != "3792580" { - t.Fatalf("expected frozen SCUM deployment plan on queued job, got %+v", created.Job.ExecutionInput) + if created.Job.TargetKey != "actions/install.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil { + t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job) + } + if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { + t.Fatalf("SCUM install job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs) } claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job.ExecutionInput.ServerDeploymentPlan == nil { - t.Fatalf("claimed SCUM install must include frozen deployment plan, claim=%+v err=%v", claim, err) + if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { + t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err) } } diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 97dec1e..1299eb9 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -33,9 +33,6 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, create.Deployment.CreateInputs); err != nil { return domain.ServerLifecycleResult{}, err } - if err := validateScumDeploymentInputs(plugin, create.Deployment); err != nil { - return domain.ServerLifecycleResult{}, err - } } stamp := svc.now() @@ -63,15 +60,6 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings) instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision) instance.Deployment.UpdatedAt = stamp - operation := "install" - if instance.Deployment.Mode == domain.ServerDeploymentModeExisting { - operation = "adopt" - } - if plan, planErr := scumDeploymentPlan(plugin, instance.Deployment, operation); planErr != nil { - return domain.ServerLifecycleResult{}, planErr - } else if plan != nil { - instance.DeploymentProjection = scumDeploymentProjection(plan, operation, stamp) - } } instance.ConfigContent = buildLogicalServerConfig(instance) instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent)) @@ -127,14 +115,6 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { return domain.ServerLifecycleResult{}, err } - if instance.DeploymentProjection.TemplateKey != "" { - instance.DeploymentProjection.State = "queued" - instance.DeploymentProjection.PreflightState = "queued" - instance.DeploymentProjection.UpdatedAt = stamp - } - if err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil { - return domain.ServerLifecycleResult{}, err - } if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { return domain.ServerLifecycleResult{}, err } @@ -339,17 +319,6 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act return domain.Job{}, err } } - var serverDeploymentPlan *domain.ServerDeploymentPlan - if action == domain.ServerLifecycleActionCreate { - operation := "install" - if instance.Deployment.Mode == domain.ServerDeploymentModeExisting { - operation = "adopt" - } - serverDeploymentPlan, err = scumDeploymentPlan(plugin, instance.Deployment, operation) - if err != nil { - return domain.Job{}, err - } - } job, err := svc.CreateJob(domain.Job{ ID: lifecycleJobID(instance.ID, action, idempotencyKey), ServerInstanceID: instance.ID, @@ -359,12 +328,11 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act IdempotencyKey: idempotencyKey, Progress: lifecycleJobProgress(instance.Deployment), ExecutionInput: domain.JobExecutionInput{ - WorkspaceScope: profileKey, - PluginID: plugin.ID, - LifecycleOperation: lifecycleExecutionOperation(action), - DLLExtensions: dllExtensions, - Deployment: deploymentPlanForDispatch(instance.Deployment), - ServerDeploymentPlan: serverDeploymentPlan, + WorkspaceScope: profileKey, + PluginID: plugin.ID, + LifecycleOperation: lifecycleExecutionOperation(action), + DLLExtensions: dllExtensions, + Deployment: deploymentPlanForDispatch(instance.Deployment), }, }) if err != nil { @@ -520,11 +488,8 @@ func validateServerInstanceLifecycleDependencies(instance domain.ServerInstance, func lifecycleRequiredRunCapabilities(instance domain.ServerInstance, plugin domain.GamePlugin, action domain.ServerLifecycleAction) []string { required := append([]string(nil), domain.LifecycleCapabilityForAction(action)) - if instance.Deployment.Mode != "" { + if instance.Deployment.Mode == domain.ServerDeploymentModeCustom { required = append(required, domain.JobCapabilityDeploymentPlan) - if scumDeploymentCapabilityRequired(plugin, instance.Deployment) { - required = append(required, domain.JobCapabilitySCUMDeploymentPlan) - } } return compactUniqueStrings(required) } diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go index d45995f..f589079 100644 --- a/platform/service/server_lifecycle_projection.go +++ b/platform/service/server_lifecycle_projection.go @@ -58,31 +58,6 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim if err != nil { return err } - if plan := job.ExecutionInput.ServerDeploymentPlan; plan != nil { - projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection) - projection.Operation = plan.Operation - projection.TemplateKey = plan.TemplateKey - projection.TemplateVersion = plan.TemplateVersion - projection.UpdatedAt = stamp - if evidence := job.ExecutionResult.ServerDeploymentEvidence; evidence != nil { - projection.State = "verified" - projection.PreflightState = evidence.PreflightState - projection.DiscoveryState = evidence.DiscoveryState - projection.MappingState = evidence.MappingState - projection.VerificationState = evidence.VerificationState - projection.DiscoveredFacts = domain.CopyStringMap(evidence.DiscoveredFacts) - projection.MappingResults = domain.CopyStringMap(evidence.MappingResults) - projection.VerificationResults = domain.CopyStringMap(evidence.VerificationResults) - projection.FailureCode = evidence.FailureCode - } - if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled { - projection.State = "failed" - if projection.FailureCode == "" && job.State == domain.JobStateCancelled { - projection.FailureCode = "cancelled" - } - } - instance.DeploymentProjection = projection - } instance.State = nextState instance.UpdatedAt = stamp if err := validator.ValidateServerInstance(instance); err != nil { @@ -99,8 +74,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim } func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error { - plan := job.ExecutionInput.ServerDeploymentPlan - if plan == nil || job.ServerInstanceID == "" { + if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" { return nil } instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) @@ -109,9 +83,7 @@ func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp ti } projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection) projection.State = "running" - projection.Operation = plan.Operation - projection.TemplateKey = plan.TemplateKey - projection.TemplateVersion = plan.TemplateVersion + projection.Operation = job.ExecutionInput.LifecycleOperation switch job.Progress.Phase { case "preflight": projection.PreflightState = "running" diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 48e70f7..0bbd1af 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -159,6 +159,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error { violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...) violations = append(violations, validateMapTrajectoryDeclaration("mapTrajectories", plugin.MapTrajectories)...) violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...) + violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...) violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) return finish(violations) } @@ -229,10 +230,87 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...) violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...) violations = append(violations, validateMapTrajectoryDeclaration("manifest.mapTrajectories", manifest.MapTrajectories)...) + violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...) + violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...) + violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...) violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...) return finish(violations) } +func validatePluginAssetFileDeclarations(prefix string, files []domain.PluginAssetFile) []string { + if len(files) > 64 { + return []string{prefix + " has too many files"} + } + var violations []string + seen := map[string]struct{}{} + for i, file := range files { + field := fmt.Sprintf("%s[%d]", prefix, i) + if !validLogicalFileKey(file.Path) { + violations = append(violations, field+".path is unsafe") + } + if _, exists := seen[file.Path]; exists { + violations = append(violations, field+".path is duplicated") + } + seen[file.Path] = struct{}{} + if file.Content != "" { + violations = append(violations, field+".content must be supplied only in registration assetFiles") + } + if file.Mode != 0 && file.Mode != 0o600 && file.Mode != 0o700 { + violations = append(violations, field+".mode is unsafe") + } + } + return violations +} + +func validatePluginAssetFiles(prefix string, files []domain.PluginAssetFile) []string { + if len(files) > 64 { + return []string{prefix + " has too many files"} + } + var violations []string + seen := map[string]struct{}{} + for i, file := range files { + field := fmt.Sprintf("%s[%d]", prefix, i) + if !validLogicalFileKey(file.Path) { + violations = append(violations, field+".path is unsafe") + } + if _, exists := seen[file.Path]; exists { + violations = append(violations, field+".path is duplicated") + } + seen[file.Path] = struct{}{} + if len([]byte(file.Content)) > 64*1024 || strings.ContainsRune(file.Content, '\x00') || containsUnsafeRuntimeSecret(file.Content) { + violations = append(violations, field+".content is unsafe") + } + if file.Mode != 0 && (file.Mode < 0o400 || file.Mode > 0o700 || file.Mode&0o022 != 0) { + violations = append(violations, field+".mode is unsafe") + } + } + return violations +} + +func validateRegistrationAssetCoverage(declared []domain.PluginAssetFile, payload []domain.PluginAssetFile) []string { + if len(declared) == 0 { + return nil + } + allowed := map[string]struct{}{} + for _, file := range declared { + allowed[file.Path] = struct{}{} + } + provided := map[string]struct{}{} + for _, file := range payload { + provided[file.Path] = struct{}{} + if _, ok := allowed[file.Path]; !ok { + return []string{"assetFiles contains undeclared plugin asset " + file.Path} + } + } + var violations []string + for _, file := range declared { + if _, ok := provided[file.Path]; !ok { + violations = append(violations, "assetFiles is missing declared plugin asset "+file.Path) + } + } + return violations +} + func validateMapTrajectoryDeclaration(prefix string, value *domain.GameMapTrajectoryDeclaration) []string { if value == nil { return nil @@ -1711,6 +1789,9 @@ func pluginSafeStrings(plugin domain.GamePlugin) []fieldString { values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods) values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities) values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines) + for i, file := range plugin.LifecycleAssets { + values = append(values, fieldString{field: fmt.Sprintf("lifecycleAssets[%d].path", i), value: file.Path}) + } for i, page := range plugin.Pages { prefix := fmt.Sprintf("pages[%d]", i) values = append(values, @@ -1753,6 +1834,9 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods) values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities) values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines) + for i, file := range manifest.AssetFiles { + values = append(values, fieldString{field: fmt.Sprintf("assetFiles[%d].path", i), value: file.Path}) + } for i, page := range manifest.Pages { prefix := fmt.Sprintf("pages[%d]", i) values = append(values, @@ -1953,7 +2037,7 @@ func validPluginRunCapability(capability string) bool { domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, - domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, + domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, "artifacts.read", "artifacts.write", "artifact.read", "artifact.write", diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index 729a787..ebd4846 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -74,6 +74,39 @@ func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing. } } +func TestValidateGamePluginManifestRegistrationValidatesAssetFileCoverage(t *testing.T) { + registration := validGamePluginManifestRegistration() + registration.Manifest.AssetFiles = []domain.PluginAssetFile{ + {Path: "actions/install.json", Mode: 0o600}, + {Path: "bin/install-server", Mode: 0o700}, + } + registration.AssetFiles = []domain.PluginAssetFile{ + {Path: "actions/install.json", Content: "{}", Mode: 0o600}, + {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700}, + } + if err := ValidateGamePluginManifestRegistration(registration); err != nil { + t.Fatalf("expected declared asset files with matching content to validate, got %v", err) + } + + missing := domain.CopyGamePluginManifestRegistration(registration) + missing.AssetFiles = missing.AssetFiles[:1] + if err := ValidateGamePluginManifestRegistration(missing); err == nil || !strings.Contains(err.Error(), "missing declared plugin asset bin/install-server") { + t.Fatalf("expected missing asset rejection, got %v", err) + } + + extra := domain.CopyGamePluginManifestRegistration(registration) + extra.AssetFiles = append(extra.AssetFiles, domain.PluginAssetFile{Path: "bin/unregistered", Content: "unused"}) + if err := ValidateGamePluginManifestRegistration(extra); err == nil || !strings.Contains(err.Error(), "undeclared plugin asset bin/unregistered") { + t.Fatalf("expected undeclared asset rejection, got %v", err) + } + + inlineContent := domain.CopyGamePluginManifestRegistration(registration) + inlineContent.Manifest.AssetFiles[0].Content = "{}" + if err := ValidateGamePluginManifestRegistration(inlineContent); err == nil || !strings.Contains(err.Error(), "content must be supplied only") { + t.Fatalf("expected manifest inline content rejection, got %v", err) + } +} + func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testing.T) { t.Run("unsafe runtime value", func(t *testing.T) { registration := validGamePluginManifestRegistration() diff --git a/plugins/examples/dev-game-plugin/bin/game-server b/plugins/examples/dev-game-plugin/bin/game-server new file mode 100755 index 0000000..ed88cba --- /dev/null +++ b/plugins/examples/dev-game-plugin/bin/game-server @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +printf 'dev server started\n' diff --git a/plugins/examples/dev-game-plugin/bin/install-server b/plugins/examples/dev-game-plugin/bin/install-server new file mode 100755 index 0000000..7704e54 --- /dev/null +++ b/plugins/examples/dev-game-plugin/bin/install-server @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +printf 'dev install complete\n' diff --git a/plugins/examples/dev-game-plugin/manifest.json b/plugins/examples/dev-game-plugin/manifest.json index 5ca6b10..7aa50f7 100644 --- a/plugins/examples/dev-game-plugin/manifest.json +++ b/plugins/examples/dev-game-plugin/manifest.json @@ -56,6 +56,15 @@ "restart": "actions/restart.json", "status": "actions/status.json" }, + "assetFiles": [ + { "path": "actions/install.json", "mode": 384 }, + { "path": "actions/start.json", "mode": 384 }, + { "path": "actions/stop.json", "mode": 384 }, + { "path": "actions/restart.json", "mode": 384 }, + { "path": "actions/status.json", "mode": 384 }, + { "path": "bin/install-server", "mode": 448 }, + { "path": "bin/game-server", "mode": 448 } + ], "productionLifecycle": { "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], "dependencyPolicy": "optional", diff --git a/plugins/examples/minecraft-server-plugin/bin/game-server b/plugins/examples/minecraft-server-plugin/bin/game-server new file mode 100755 index 0000000..928b2a4 --- /dev/null +++ b/plugins/examples/minecraft-server-plugin/bin/game-server @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +printf 'minecraft server started\n' diff --git a/plugins/examples/minecraft-server-plugin/bin/install-server b/plugins/examples/minecraft-server-plugin/bin/install-server new file mode 100755 index 0000000..f7e9245 --- /dev/null +++ b/plugins/examples/minecraft-server-plugin/bin/install-server @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +printf 'minecraft install complete\n' diff --git a/plugins/examples/minecraft-server-plugin/manifest.json b/plugins/examples/minecraft-server-plugin/manifest.json index 0a0f1ee..8cf6835 100644 --- a/plugins/examples/minecraft-server-plugin/manifest.json +++ b/plugins/examples/minecraft-server-plugin/manifest.json @@ -98,6 +98,15 @@ "restart": "actions/restart.json", "status": "actions/status.json" }, + "assetFiles": [ + { "path": "actions/install.json", "mode": 384 }, + { "path": "actions/start.json", "mode": 384 }, + { "path": "actions/stop.json", "mode": 384 }, + { "path": "actions/restart.json", "mode": 384 }, + { "path": "actions/status.json", "mode": 384 }, + { "path": "bin/install-server", "mode": 448 }, + { "path": "bin/game-server", "mode": 448 } + ], "productionLifecycle": { "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], "dependencyPolicy": "required", diff --git a/plugins/examples/scum-server-plugin/actions/install.json b/plugins/examples/scum-server-plugin/actions/install.json index 775ad39..ecdb8f2 100644 --- a/plugins/examples/scum-server-plugin/actions/install.json +++ b/plugins/examples/scum-server-plugin/actions/install.json @@ -2,11 +2,15 @@ "version": 1, "action": "install", "mode": "oneshot", - "executableKey": "bin/install-server", + "executableKey": "bin/scum-install-update.cmd", "arguments": [], "environment": { "GAME_ID": "scum", - "SERVER_TEMPLATE": "scum-server" + "SERVER_TEMPLATE": "scum-server", + "SERVER_STEAM_APP_ID": "3792580", + "SERVER_STEAMCMD_INSTALL_DIR_ARG": "+force_install_dir", + "SERVER_STEAMCMD_UPDATE_ARGS": "+login anonymous +app_update 3792580 validate +quit", + "SERVER_EXECUTABLE_REF": "SCUM/Binaries/Win64/SCUMServer.exe" }, - "timeoutMs": 30000 + "timeoutMs": 300000 } diff --git a/plugins/examples/scum-server-plugin/actions/start.json b/plugins/examples/scum-server-plugin/actions/start.json index d14173a..ba44163 100644 --- a/plugins/examples/scum-server-plugin/actions/start.json +++ b/plugins/examples/scum-server-plugin/actions/start.json @@ -2,11 +2,15 @@ "version": 1, "action": "start", "mode": "supervised", - "executableKey": "bin/game-server", - "arguments": ["--foreground"], + "executableKey": "bin/scum-start.cmd", + "arguments": [], "environment": { "GAME_ID": "scum", - "SERVER_ACTION": "start" + "SERVER_ACTION": "start", + "SERVER_EXECUTABLE_REF": "SCUM/Binaries/Win64/SCUMServer.exe", + "SERVER_PORT_FIELD": "gamePort", + "SERVER_MAX_PLAYERS_FIELD": "maxPlayers", + "SERVER_LOG_FLAG": "-log" }, "timeoutMs": 30000 } diff --git a/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd new file mode 100755 index 0000000..e75f40b --- /dev/null +++ b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd @@ -0,0 +1,31 @@ +@echo off +setlocal EnableExtensions + +if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver" +if "%SERVER_STEAM_APP_ID%"=="" set "SERVER_STEAM_APP_ID=3792580" +if "%SERVER_STEAMCMD_INSTALL_DIR_ARG%"=="" set "SERVER_STEAMCMD_INSTALL_DIR_ARG=+force_install_dir" +if "%SERVER_STEAMCMD_UPDATE_ARGS%"=="" set "SERVER_STEAMCMD_UPDATE_ARGS=+login anonymous +app_update 3792580 validate +quit" +if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe" + +set "STEAMCMD_DIR=%SERVER_ROOT%\steamcmd" +set "STEAMCMD_EXE=%STEAMCMD_DIR%\steamcmd.exe" +set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%" + +if not exist "%SERVER_ROOT%" mkdir "%SERVER_ROOT%" +if not exist "%STEAMCMD_DIR%" mkdir "%STEAMCMD_DIR%" + +if not exist "%STEAMCMD_EXE%" ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $zip=Join-Path $env:STEAMCMD_DIR 'steamcmd.zip'; Invoke-WebRequest -Uri 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip' -OutFile $zip; Expand-Archive -Path $zip -DestinationPath $env:STEAMCMD_DIR -Force; Remove-Item $zip -Force" + if errorlevel 1 exit /b 1 +) + +taskkill /IM SCUMServer.exe /F >nul 2>nul + +pushd "%STEAMCMD_DIR%" +"%STEAMCMD_EXE%" %SERVER_STEAMCMD_INSTALL_DIR_ARG% "%SERVER_ROOT%" %SERVER_STEAMCMD_UPDATE_ARGS% +set "SCUM_STEAMCMD_RESULT=%ERRORLEVEL%" +popd +if not "%SCUM_STEAMCMD_RESULT%"=="0" exit /b %SCUM_STEAMCMD_RESULT% + +if not exist "%SCUM_EXE%" exit /b 2 +exit /b 0 diff --git a/plugins/examples/scum-server-plugin/bin/scum-start.cmd b/plugins/examples/scum-server-plugin/bin/scum-start.cmd new file mode 100755 index 0000000..c79c8b6 --- /dev/null +++ b/plugins/examples/scum-server-plugin/bin/scum-start.cmd @@ -0,0 +1,17 @@ +@echo off +setlocal EnableExtensions + +if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver" +if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe" +if "%SERVER_CREATE_GAMEPORT%"=="" set "SERVER_CREATE_GAMEPORT=7779" +if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128" +if "%SERVER_LOG_FLAG%"=="" set "SERVER_LOG_FLAG=-log" + +set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%" +if not exist "%SCUM_EXE%" exit /b 2 + +pushd "%SERVER_ROOT%" +"%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG% +set "SCUM_START_RESULT=%ERRORLEVEL%" +popd +exit /b %SCUM_START_RESULT% diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index fe0470d..5b5814f 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -4,9 +4,9 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMSta // per server, but a game version never enables or disables a feature. export const configurationCatalog: readonly SCUMConfigField[] = [ { key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, - { key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7777", restartImpact: "restart-required" }, + { key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" }, { key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" }, - { key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "64", restartImpact: "restart-required" }, + { key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" }, { key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } ]; export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }]; diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 34a72d9..da6d29d 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -22,9 +22,9 @@ "createFormSchema": "schemas/create-form.schema.json", "createFields": [ { "key": "serverName", "label": "SCUM 服务器名称", "type": "text", "required": true, "configKey": "serverName" }, - { "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "7777", "configKey": "gamePort" }, + { "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "7779", "configKey": "gamePort" }, { "key": "queryPort", "label": "查询端口", "type": "port", "required": true, "defaultValue": "27015", "configKey": "queryPort" }, - { "key": "maxPlayers", "label": "最大玩家数", "type": "number", "required": true, "defaultValue": "64", "configKey": "maxPlayers" } + { "key": "maxPlayers", "label": "最大玩家数", "type": "number", "required": true, "defaultValue": "128", "configKey": "maxPlayers" } ] }, "capabilities": [ @@ -33,7 +33,6 @@ "process.stop", "process.restart", "process.status", - "deployment.scum.v1", "files.list", "files.read", "files.patch", @@ -360,6 +359,15 @@ "restart": "actions/restart.json", "status": "actions/status.json" }, + "assetFiles": [ + { "path": "actions/install.json", "mode": 384 }, + { "path": "actions/start.json", "mode": 384 }, + { "path": "actions/stop.json", "mode": 384 }, + { "path": "actions/restart.json", "mode": 384 }, + { "path": "actions/status.json", "mode": 384 }, + { "path": "bin/scum-install-update.cmd", "mode": 448 }, + { "path": "bin/scum-start.cmd", "mode": 448 } + ], "productionLifecycle": { "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], "dependencyPolicy": "required", @@ -370,7 +378,7 @@ "defaultDirectoryKey": "scum-config", "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], "files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }], - "configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7777", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询端口", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "64", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }] + "configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7779", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询端口", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "128", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }] }, "ai": { "purposes": [ @@ -494,49 +502,6 @@ ] } ], - "serverDeployments": [ - { - "key": "scum-steamcmd-windows", - "version": "1.0.0", - "supportedTargets": [ - { "os": "windows", "arch": "amd64" } - ], - "steamAppId": "3792580", - "executableKey": "scum/server-executable", - "installRootKey": "server/install-root", - "configKey": "scum/server-settings", - "configFormat": "ini", - "prerequisites": [ - { "key": "steamcmd", "kind": "steamcmd" }, - { "key": "vcredist-2012-x86", "kind": "windows-vcredist" }, - { "key": "vcredist-2012-x64", "kind": "windows-vcredist" }, - { "key": "vcredist-2013-x86", "kind": "windows-vcredist" }, - { "key": "vcredist-2013-x64", "kind": "windows-vcredist" }, - { "key": "vcredist-2015-2022-x86", "kind": "windows-vcredist" }, - { "key": "vcredist-2015-2022-x64", "kind": "windows-vcredist" }, - { "key": "directx-jun2010", "kind": "windows-directx" } - ], - "configMappings": [ - { "fieldKey": "serverName", "configKey": "server-settings.server-name", "valueType": "text", "required": true }, - { "fieldKey": "gamePort", "configKey": "server-settings.game-port", "valueType": "port", "required": true }, - { "fieldKey": "queryPort", "configKey": "server-settings.query-port", "valueType": "port", "required": true }, - { "fieldKey": "maxPlayers", "configKey": "server-settings.max-players", "valueType": "integer", "required": true } - ], - "discoveryMarkers": [ - { "key": "scum-executable", "kind": "file.exists", "targetKey": "scum/server-executable", "required": true }, - { "key": "scum-steam-app", "kind": "steam.app", "targetKey": "server/install-root", "expected": "3792580", "required": true }, - { "key": "scum-config", "kind": "file.exists", "targetKey": "scum/server-settings", "expected": "ServerSettings.ini", "required": true }, - { "key": "scum-game-port", "kind": "port.open", "targetKey": "game-port", "required": true }, - { "key": "scum-query-port", "kind": "port.open", "targetKey": "query-port", "required": true } - ], - "verificationChecks": [ - { "key": "executable", "kind": "executable.present", "targetKey": "scum/server-executable", "required": true }, - { "key": "game-port", "kind": "port.bound", "targetKey": "game-port", "required": true }, - { "key": "config", "kind": "config.readable", "targetKey": "scum/server-settings", "required": true }, - { "key": "process", "kind": "process.healthy", "targetKey": "scum/server-executable", "required": true } - ] - } - ], "logSources": [ { "key": "scum-console-stdout", diff --git a/plugins/examples/scum-server-plugin/schemas/create-form.schema.json b/plugins/examples/scum-server-plugin/schemas/create-form.schema.json index 227f47a..69db42f 100644 --- a/plugins/examples/scum-server-plugin/schemas/create-form.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/create-form.schema.json @@ -11,7 +11,7 @@ "label": "游戏端口", "type": "port", "required": true, - "default": 7777 + "default": 7779 }, { "key": "queryPort", @@ -25,7 +25,7 @@ "label": "最大玩家数", "type": "number", "required": true, - "default": 64 + "default": 128 } ] } diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 26a89e4..22ac76b 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -157,6 +157,12 @@ "status": { "$ref": "#/$defs/relativeJsonRef" } } }, + "assetFiles": { + "type": "array", + "items": { "$ref": "#/$defs/pluginAssetFile" }, + "uniqueItems": true, + "maxItems": 64 + }, "productionLifecycle": { "type": "object", "required": ["operations", "dependencyPolicy", "approvalRequired"], @@ -222,6 +228,15 @@ "pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } }, "pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } }, "pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } }, + "pluginAssetFile": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": { "$ref": "#/$defs/relativePathRef" }, + "mode": { "type": "integer", "enum": [384, 448] } + } + }, "pluginCreateField": { "type": "object", "required": ["key", "label", "type"], @@ -378,7 +393,6 @@ "process.restart", "process.status", "deployment.plan.v1", - "deployment.scum.v1", "config.write", "files.list", "files.read", diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 4105e13..baf4418 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -134,6 +134,10 @@ function isSafeRelativeJsonRef(value: string): boolean { return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value); } +function isSafeRelativePathRef(value: string): boolean { + return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value); +} + function identifierTokens(value: string): string[] { return value .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") @@ -353,6 +357,57 @@ function referencedLifecycleActions(manifest: unknown): Array<{ action: string; return [...refs.values()]; } +type PluginAssetFileDeclaration = { path?: unknown; mode?: unknown }; + +function declaredAssetFiles(manifest: unknown): PluginAssetFileDeclaration[] { + if (typeof manifest !== "object" || manifest === null) { + return []; + } + const assetFiles = (manifest as { assetFiles?: unknown }).assetFiles; + return Array.isArray(assetFiles) ? (assetFiles as PluginAssetFileDeclaration[]) : []; +} + +function validateManifestAssetFiles(manifest: unknown, manifestDir: string): { errors: string[]; declared: Set } { + const errors: string[] = []; + const declared = new Set(); + const root = path.resolve(manifestDir); + for (const [index, file] of declaredAssetFiles(manifest).entries()) { + const location = `manifest.assetFiles[${index}]`; + if (typeof file.path !== "string" || !isSafeRelativePathRef(file.path)) { + errors.push(`${location}.path: unsafe file reference`); + continue; + } + if (declared.has(file.path)) { + errors.push(`${location}.path: duplicate asset file`); + continue; + } + declared.add(file.path); + if (file.mode !== undefined && file.mode !== 0o600 && file.mode !== 0o700) { + errors.push(`${location}.mode: unsafe file mode`); + } + const target = path.resolve(manifestDir, file.path); + const relative = path.relative(root, target); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + errors.push(`${location}.path: file escapes plugin directory`); + continue; + } + if (!fs.existsSync(target)) { + errors.push(`${location}.path: missing file ${file.path}`); + continue; + } + const stat = fs.statSync(target); + if (!stat.isFile() || stat.size > 64 * 1024) { + errors.push(`${location}.path: asset file must be a regular file under 64KiB`); + continue; + } + const body = fs.readFileSync(target); + if (body.includes(0)) { + errors.push(`${location}.path: asset file contains NUL bytes`); + } + } + return { errors, declared }; +} + function validateDependencyPlans(manifest: unknown): string[] { if (typeof manifest !== "object" || manifest === null) { return []; @@ -1187,18 +1242,28 @@ export function validateManifestFile(manifestPath: string): string[] { errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir)); errors.push(...validateRuntimeLogEventCatalog(manifest)); errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir)); + const assetValidation = validateManifestAssetFiles(manifest, manifestDir); + errors.push(...assetValidation.errors); for (const declaration of referencedLifecycleActions(manifest)) { if (!isSafeRelativeJsonRef(declaration.ref)) { errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`); continue; } + if (!assetValidation.declared.has(declaration.ref)) { + errors.push(`lifecycleAction.${declaration.action}: action file must be declared in manifest.assetFiles`); + } const actionPath = path.resolve(manifestDir, declaration.ref); if (!fs.existsSync(actionPath)) { errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`); continue; } errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action)); + const action = readJson(actionPath); + const executableKey = typeof action === "object" && action !== null ? (action as { executableKey?: unknown }).executableKey : undefined; + if (typeof executableKey === "string" && !assetValidation.declared.has(executableKey)) { + errors.push(`lifecycleAction.${declaration.action}.executableKey: ${executableKey} must be declared in manifest.assetFiles`); + } } if (typeof manifest === "object" && manifest !== null && "server" in manifest) { diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 8f04600..9861e13 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -22,11 +22,18 @@ export type RunCapability = | "process.stop" | "process.restart" | "process.status" + | "deployment.plan.v1" + | "config.write" | "files.list" | "files.read" | "files.write" | "files.patch" | "logs.read" + | "run.self-update" + | "distribution.build" + | "dependencies.check" + | "dependencies.install" + | "logs.backfill" | "remote.ftp.read" | "remote.ftp.write" | "remote.rsync.read" @@ -39,6 +46,9 @@ export type RunCapability = | "remote.run.db.sqlite.query" | "remote.run.logs.transfer" | "remote.run.rcon.command" + | "remote.run.protected.sql" + | "remote.run.protected.rcon" + | "remote.run.program.command" | "client-manager.deploy" | "client-manager.control" | "client-manager.update" @@ -646,6 +656,11 @@ export interface GamePluginBridge { actions: PluginBridgeAction[]; } +export interface PluginAssetFile { + path: string; + mode?: 384 | 448; +} + export interface GamePluginManifest { id: `game.${string}`; name: string; @@ -666,6 +681,7 @@ export interface GamePluginManifest { runtimeProfiles?: GamePluginRuntimeProfiles; gameClientBridge?: GameClientBridgeManifest; actions?: GamePluginActions; + assetFiles?: PluginAssetFile[]; productionLifecycle: { operations: ProductionPluginLifecycleOperation[]; dependencyPolicy: "required" | "optional"; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index ae85314..31e45e3 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -189,19 +189,28 @@ describe("plugin manifest validation", () => { } }); - it("declares a frozen SCUM install/adopt template with explicit mapping and verification checks", () => { - const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any; - const template = manifest.runtimeProfiles.serverDeployments[0]; - expect(template).toMatchObject({ key: "scum-steamcmd-windows", version: "1.0.0", steamAppId: "3792580", configFormat: "ini" }); - expect(template.configMappings.map((mapping: any) => mapping.fieldKey)).toEqual(["serverName", "gamePort", "queryPort", "maxPlayers"]); - expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(4); + it("declares SCUM install/update and start lifecycle through plugin assets", () => { + const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any; + const assetPaths = manifest.assetFiles.map((file: { path: string }) => file.path); + const installAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.install), "utf8")); + const startAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.start), "utf8")); + const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8"); + const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8"); + expect(manifest.runtimeProfiles.serverDeployments).toBeUndefined(); + expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd"])); + expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 validate +quit" } }); + expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", environment: { SERVER_LOG_FLAG: "-log" } }); + expect(installScript).toContain("taskkill /IM SCUMServer.exe /F"); + expect(installScript).toContain("%SERVER_STEAMCMD_INSTALL_DIR_ARG% \"%SERVER_ROOT%\" %SERVER_STEAMCMD_UPDATE_ARGS%"); + expect(startScript).toContain("-port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%"); }); - it("rejects an SCUM template mapping an undeclared field", () => { + it("rejects lifecycle executable assets missing from the manifest seed declaration", () => { const errors = validateTemporaryScumCompanionManifest((manifest) => { - manifest.runtimeProfiles.serverDeployments[0].configMappings[0].fieldKey = "hostCommand"; + manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd"); }); - expect(errors.some((error) => error.includes("configMappings") && error.includes("fieldKey"))).toBe(true); + expect(errors.some((error) => error.includes("lifecycleAction.start.executableKey") && error.includes("manifest.assetFiles"))).toBe(true); }); it("rejects unsupported or unsafe inline create-field declarations", () => { diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index 6114eb6..f4cb84c 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -635,9 +635,18 @@ printf 'validating plugin manifests\n' node - "$ROOT_DIR/plugins/examples/dev-game-plugin/manifest.json" "$WORK_DIR/register-plugin.request.json" <<'NODE' const fs = require("fs"); +const path = require("path"); const manifestPath = process.argv[2]; const outputPath = process.argv[3]; const source = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const manifestDir = path.dirname(manifestPath); +function readAssetFiles(manifest) { + return (manifest.assetFiles ?? []).map((file) => ({ + path: file.path, + mode: file.mode, + content: fs.readFileSync(path.join(manifestDir, file.path), "utf8") + })); +} const manifest = { id: source.id, name: source.name, @@ -659,6 +668,7 @@ const manifest = { stop: source.actions.stop, restart: source.actions.restart }, + assetFiles: source.assetFiles, pages: [ { key: "logs", @@ -686,7 +696,8 @@ const manifest = { }; fs.writeFileSync(outputPath, JSON.stringify({ manifestRef: "artifact://manifests/game.example/0.1.0", - manifest + manifest, + assetFiles: readAssetFiles(source) }, null, 2)); NODE @@ -696,9 +707,18 @@ reject_forbidden_fragments "$WORK_DIR/register-plugin.response.json" node - "$ROOT_DIR/plugins/examples/scum-server-plugin/manifest.json" "$WORK_DIR/register-scum-plugin.request.json" <<'NODE' const fs = require("fs"); +const path = require("path"); const manifestPath = process.argv[2]; const outputPath = process.argv[3]; const source = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const manifestDir = path.dirname(manifestPath); +function readAssetFiles(manifest) { + return (manifest.assetFiles ?? []).map((file) => ({ + path: file.path, + mode: file.mode, + content: fs.readFileSync(path.join(manifestDir, file.path), "utf8") + })); +} const localRunCapabilities = [ "process.install", "process.start", @@ -742,6 +762,7 @@ const manifest = { capabilities: localRunCapabilities, permissions: source.permissions, actions: source.actions, + assetFiles: source.assetFiles, pages: source.pages, bridge: source.bridge, ai: source.ai, @@ -752,7 +773,8 @@ const manifest = { }; fs.writeFileSync(outputPath, JSON.stringify({ manifestRef: "artifact://manifests/game.scum/0.1.0", - manifest + manifest, + assetFiles: readAssetFiles(source) }, null, 2)); NODE