Remove legacy client-manager workflows

This commit is contained in:
npc0-hue
2026-09-03 13:08:08 +08:00
parent bf3c382d15
commit fe09d21a56
56 changed files with 304 additions and 4121 deletions
@@ -8,33 +8,19 @@ import (
"browser.local/platform/domain"
)
func TestValidateClientManagerLifecycleContractsAndTransitions(t *testing.T) {
profile := domain.RuntimeClientManagerProfile{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.2.3", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"client-manager"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "client-manager", Arguments: []string{"--config", "config.json"}, RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0", MaximumVersion: "2.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}
if err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{profile}}); err != nil {
t.Fatalf("validate safe lifecycle profile: %v", err)
}
unsafe := profile
unsafe.Deployment.ExecutableRef = "/Users/operator/client-manager"
unsafe.Deployment.Arguments = []string{"bash -c", "curl | bash"}
unsafe.Health.OfflineAfterSeconds = 30
unsafe.Compatibility.MinimumVersion = "3.0.0"
err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{unsafe}})
if err == nil || !strings.Contains(err.Error(), "safe relative path") || !strings.Contains(err.Error(), "health") || !strings.Contains(err.Error(), "compatibility") {
t.Fatalf("expected unsafe lifecycle rejection, got %v", err)
}
if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying); err != nil {
t.Fatalf("valid lifecycle transition rejected: %v", err)
}
if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleOnline); err == nil {
t.Fatal("expected evidence-skipping lifecycle transition rejection")
func TestValidateRuntimeProfilesRejectsLegacyClientManagers(t *testing.T) {
profile := domain.RuntimeClientManagerProfile{Key: "scum-client-manager", DisplayName: "SCUM Client Manager"}
err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{profile}})
if err == nil || !strings.Contains(err.Error(), "runtimeProfiles.clientManagers is no longer supported") {
t.Fatalf("expected legacy client-manager profile rejection, got %v", err)
}
}
func TestValidateClientManagerSessionHeartbeatAndRedaction(t *testing.T) {
func TestValidateClientManagerPersistenceContractsFailClosed(t *testing.T) {
stamp := time.Date(2026, 7, 18, 4, 0, 0, 0, time.UTC)
installation := domain.ClientManagerInstallation{ID: "cm-install-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", TargetOS: "linux", TargetArch: "amd64", Status: domain.ClientManagerLifecycleOnline, Phase: "healthy", KeyGeneration: 1, DeploymentGeneration: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", CreatedAt: stamp, UpdatedAt: stamp}
if err := ValidateClientManagerInstallation(installation); err != nil {
t.Fatalf("validate installation: %v", err)
t.Fatalf("validate historical installation projection: %v", err)
}
installation.HealthReason = "Bearer stolen-session"
if err := ValidateClientManagerInstallation(installation); err == nil {
@@ -42,7 +28,7 @@ func TestValidateClientManagerSessionHeartbeatAndRedaction(t *testing.T) {
}
session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: "cm-install-1", ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", ArtifactID: "artifact-1", KeyGeneration: 1, DeploymentGeneration: 1, TokenHash: strings.Repeat("a", 64), Capabilities: []string{"component.heartbeat"}, Status: domain.ClientManagerSessionActive, ExpiresAt: stamp.Add(time.Minute), CreatedAt: stamp, UpdatedAt: stamp}
if err := ValidateClientManagerSession(session); err != nil {
t.Fatalf("validate hashed session: %v", err)
t.Fatalf("validate historical hashed session: %v", err)
}
if err := ValidateClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: "cm-install-1", SessionToken: "component-session", Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "password=leak", Capabilities: []string{"component.heartbeat"}, SentAt: stamp}); err == nil {
t.Fatal("expected unsafe heartbeat reason rejection")
+1 -1
View File
@@ -31,7 +31,7 @@ func ValidateRunControlHello(hello domain.RunControlHello) error {
if hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0 {
violations = appendRequired(violations, "serverInstanceId", hello.ServerInstanceID)
violations = appendRequired(violations, "pluginId", hello.PluginID)
if hello.ComponentKind != domain.DistributionComponentRun && hello.ComponentKind != domain.DistributionComponentClientManager {
if hello.ComponentKind != domain.DistributionComponentRun {
violations = append(violations, "componentKind is invalid")
}
if hello.KeyGeneration <= 0 {
+3 -3
View File
@@ -325,8 +325,8 @@ func ValidateComponentKeyResetRequest(request domain.ComponentKeyResetRequest) e
if !validDistributionComponentKind(request.ComponentKind) {
violations = append(violations, "componentKind is invalid")
}
if request.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(request.ComponentKey) == "" {
violations = append(violations, "componentKey is required for client-manager")
if request.ComponentKind != domain.DistributionComponentRun {
violations = append(violations, "componentKind must be run")
}
if request.ComponentKey != "" && !validDistributionLogicalKey(request.ComponentKey) {
violations = append(violations, "componentKey is invalid")
@@ -389,7 +389,7 @@ func validateRepositoryURL(field string, value string) []string {
func validDistributionComponentKind(kind domain.DistributionComponentKind) bool {
switch kind {
case domain.DistributionComponentRun, domain.DistributionComponentClientManager:
case domain.DistributionComponentRun:
return true
default:
return false
@@ -7,7 +7,7 @@ import (
"browser.local/platform/domain"
)
func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest, domain.GamePluginRuntimeProfiles) {
func TestValidateGameClientBridgeCompanionDeclarationIsUnsupported(t *testing.T) {
bridge := domain.GameClientBridgeManifest{
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
Companion: domain.GameClientBridgeCompanionDeclaration{
@@ -26,107 +26,8 @@ func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest,
RequestTimeoutSeconds: 15,
},
}
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{
Key: "scum-client-manager",
ConfigTemplates: []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}},
Health: domain.RuntimeClientManagerHealth{IntervalSeconds: 30, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}},
}}}
return bridge, profiles
}
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
bridge, profiles := validGameClientBridgeCompanionManifest()
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, profiles); len(violations) != 0 {
t.Fatalf("expected valid companion declaration, got %v", violations)
}
tests := []struct {
name string
expected string
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
}{
{name: "undeclared profile", expected: "profileKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
bridge.Companion.ProfileKey = "missing"
}},
{name: "undeclared template", expected: "configTemplateKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
bridge.Companion.ConfigTemplateKey = "missing"
}},
{name: "unsafe schema", expected: "configSchemaRef", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
bridge.Companion.ConfigSchemaRef = "/etc/config.json"
}},
{name: "insecure tls", expected: "security policy", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
bridge.Companion.TLSPolicy = "skip-verification"
}},
{name: "heartbeat mismatch", expected: "must match", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
bridge.Companion.HeartbeatIntervalSeconds = 31
}},
{name: "missing bridge capability", expected: "game-client.bridge", mutate: func(_ *domain.GameClientBridgeManifest, profiles *domain.GamePluginRuntimeProfiles) {
profiles.ClientManagers[0].Health.RequiredCapabilities = []string{"component.register", "component.heartbeat", "component.health"}
}},
{name: "partial declaration", expected: "profile or config template key", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
bridge.Companion.ProfileKey = ""
}},
{name: "reserved proof environment", expected: "proofMaterialEnv", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
bridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
test.mutate(&candidateBridge, &candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, nil, candidateProfiles)
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
t.Fatalf("expected %q violation, got %v", test.expected, violations)
}
})
}
}
func TestValidateClientManagerCompanionConfigInputFailsClosed(t *testing.T) {
valid := domain.ClientManagerCompanionConfigInput{
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
ConfigTemplateKey: "client-config",
ConfigTemplateRef: "config.yaml.example",
ConfigOutputRef: "config.yaml",
ConfigSchemaRef: "schemas/companion/config.schema.json",
ConfigFormat: "yaml",
PlatformBaseURLSource: "run-control",
InstallationID: "installation-1",
ServerInstanceID: "server-1",
PluginID: "game.scum",
ProfileKey: "scum-client-manager",
ArtifactID: "artifact-1",
Version: "1.0.0",
SourceRevision: "revision-1",
TargetOS: "windows",
TargetArch: "amd64",
KeyGeneration: 1,
DeploymentGeneration: 2,
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
RegistrationProof: "hmac-sha256",
ProofMaterialSource: "component-package",
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
SessionMode: "component-session",
TLSPolicy: "verify-system-roots",
HeartbeatIntervalSeconds: 30,
CommandPollIntervalSeconds: 5,
RequestTimeoutSeconds: 15,
}
if err := ValidateClientManagerCompanionConfigInput(valid); err != nil {
t.Fatalf("expected valid companion input, got %v", err)
}
for name, mutate := range map[string]func(*domain.ClientManagerCompanionConfigInput){
"insecure tls": func(value *domain.ClientManagerCompanionConfigInput) { value.TLSPolicy = "skip-verification" },
"legacy session": func(value *domain.ClientManagerCompanionConfigInput) { value.SessionMode = "shared-token" },
"reserved env": func(value *domain.ClientManagerCompanionConfigInput) { value.ProofMaterialEnv = "PATH" },
"unsafe template": func(value *domain.ClientManagerCompanionConfigInput) { value.ConfigTemplateRef = "../config.yaml" },
} {
t.Run(name, func(t *testing.T) {
candidate := domain.CopyClientManagerCompanionConfigInput(valid)
mutate(&candidate)
if err := ValidateClientManagerCompanionConfigInput(candidate); err == nil {
t.Fatalf("expected invalid companion input: %+v", candidate)
}
})
violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, domain.GamePluginRuntimeProfiles{})
if !strings.Contains(strings.Join(violations, "; "), "gameClientBridge.companion is no longer supported") {
t.Fatalf("expected companion unsupported violation, got %v", violations)
}
}
+4 -55
View File
@@ -478,53 +478,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, field+".maxCommands is invalid")
}
if companionPresent {
prefix := field + ".companion"
companion := bridge.Companion
if !clientManagerIdentifierPattern.MatchString(companion.ProfileKey) || !clientManagerIdentifierPattern.MatchString(companion.ConfigTemplateKey) {
violations = append(violations, prefix+" profile or config template key is invalid")
}
if !safeRelativeJSONRef(companion.ConfigSchemaRef) {
violations = append(violations, prefix+".configSchemaRef must be a safe relative JSON reference")
}
if companion.ConfigFormat != "yaml" || companion.PlatformBaseURLSource != "run-control" || companion.RegistrationProof != "hmac-sha256" || companion.ProofMaterialSource != "component-package" || companion.SessionMode != "component-session" || companion.TLSPolicy != "verify-system-roots" {
violations = append(violations, prefix+" bootstrap security policy is invalid")
}
if !validCompanionProofEnvironment(companion.ProofMaterialEnv) {
violations = append(violations, prefix+".proofMaterialEnv is invalid")
}
if companion.HeartbeatIntervalSeconds < 5 || companion.HeartbeatIntervalSeconds > 300 || companion.CommandPollIntervalSeconds < 1 || companion.CommandPollIntervalSeconds > 60 || companion.RequestTimeoutSeconds < 1 || companion.RequestTimeoutSeconds > 60 {
violations = append(violations, prefix+" timing policy is invalid")
}
managerFound := false
for _, manager := range runtimeProfiles.ClientManagers {
if manager.Key != companion.ProfileKey {
continue
}
managerFound = true
if manager.Health.IntervalSeconds != companion.HeartbeatIntervalSeconds {
violations = append(violations, prefix+".heartbeatIntervalSeconds must match the Client Manager health interval")
}
for _, capability := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
if !containsString(manager.Health.RequiredCapabilities, capability) {
violations = append(violations, prefix+" requires Client Manager capability "+capability)
}
}
templateFound := false
for _, template := range manager.ConfigTemplates {
if template.Key == companion.ConfigTemplateKey {
templateFound = true
if template.OutputRef != "config.yaml" {
violations = append(violations, prefix+" config template must materialize config.yaml")
}
}
}
if !templateFound {
violations = append(violations, prefix+".configTemplateKey must reference the Client Manager profile")
}
}
if !managerFound {
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile")
}
violations = append(violations, field+".companion is no longer supported")
}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range runtimeProfiles.TransportProfiles {
@@ -2395,7 +2349,7 @@ func validPluginMarketplaceStateAction(action domain.PluginMarketplaceStateActio
func validPluginRunCapability(capability string) bool {
switch capability {
case "process.install", "process.start", "process.stop", "process.restart", "process.status",
case "process.install", "process.start", "process.stop", "process.restart", "process.status",
"config.write",
"files.list", "files.read", "files.write", "files.patch",
"file.list", "file.read", "file.write", "file.patch",
@@ -2408,9 +2362,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
"ai.invoke":
return true
@@ -2528,7 +2480,7 @@ func validScopedInputRef(ref string) bool {
func validPluginPermission(permission string) bool {
switch permission {
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke":
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke":
return true
default:
return false
@@ -2546,7 +2498,6 @@ func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
domain.PluginBridgeActionRunDistribution,
domain.PluginBridgeActionDependenciesRequest,
domain.PluginBridgeActionLogsBackfillRequest,
domain.PluginBridgeActionClientManager,
domain.PluginBridgeActionPluginLifecycle,
domain.PluginBridgeActionAIInvoke:
return true
@@ -2575,8 +2526,6 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
return []string{"server.dependencies.manage"}
case domain.PluginBridgeActionLogsBackfillRequest:
return []string{"server.logs.read"}
case domain.PluginBridgeActionClientManager:
return []string{"server.client-manager.manage"}
case domain.PluginBridgeActionPluginLifecycle:
return []string{"server.lifecycle"}
case domain.PluginBridgeActionAIInvoke:
+2 -2
View File
@@ -8,6 +8,6 @@
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress summaries, artifact metadata, log stream cursors, and run capability compatibility.
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
# Client Manager lifecycle validation
# Runtime and bridge validation
Lifecycle validation rejects undeclared operations, stale attempt/deployment/key generations, cross-owner/server/profile/target/revision artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Registration additionally requires a current component-key HMAC, fresh nonce/timestamp, matching artifact and capabilities, and a monotonic heartbeat sequence. Safe DTOs are redacted before they cross the Platform boundary.
Runtime validation rejects undeclared operations, stale attempt/key generations, cross-owner/server/target artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Game-client bridge validation rejects legacy companion declarations and keeps operator-facing DTOs redacted before they cross the Platform boundary.
+5 -139
View File
@@ -24,7 +24,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
transportProfiles := map[string]domain.RuntimeTransportProfile{}
dataTargetKeys := map[string]struct{}{}
dataTargetWorkspaces := map[string]struct{}{}
managerKeys := map[string]struct{}{}
dllExtensionKeys := map[string]struct{}{}
dllExtensionStates := map[string]string{}
discoveryKeys := map[string]struct{}{}
@@ -66,7 +65,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
}
violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...)
if profile.ClientManagerRef != "" {
violations = append(violations, validateProfileKey(prefix+".clientManagerRef", profile.ClientManagerRef)...)
violations = append(violations, prefix+".clientManagerRef is no longer supported")
}
for j, key := range profile.DLLExtensionRefs {
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.dllExtensionRefs[%d]", prefix, j), key)...)
@@ -235,7 +234,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query") {
violations = append(violations, prefix+".kind is invalid")
}
if source.TargetKey != "" {
@@ -299,133 +298,8 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport")
}
}
for i, manager := range profiles.ClientManagers {
prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", manager.Key)...)
violations = append(violations, recordRuntimeProfileKey(managerKeys, prefix+".key", manager.Key)...)
parsed, err := url.Parse(manager.RepositoryURL)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") {
violations = append(violations, prefix+".repository.url must be a credential-free HTTPS .git URL")
}
if !oneOf(manager.RevisionPolicy, "pinned", "branch", "tag") {
violations = append(violations, prefix+".repository.revisionPolicy is invalid")
}
switch manager.RevisionPolicy {
case "pinned":
if manager.Revision == "" {
violations = append(violations, prefix+".repository.revision is required for pinned policy")
}
case "branch":
if manager.Branch == "" {
violations = append(violations, prefix+".repository.branch is required for branch policy")
}
case "tag":
if manager.Tag == "" {
violations = append(violations, prefix+".repository.tag is required for tag policy")
}
}
if !oneOf(manager.BuildSystem, "go", "npm", "cargo", "make") {
violations = append(violations, prefix+".build.system is invalid")
}
if len(manager.SupportedTargets) == 0 {
violations = append(violations, prefix+".supportedTargets must not be empty")
}
if len(manager.OutputArtifacts) == 0 {
violations = append(violations, prefix+".outputArtifacts must not be empty")
}
for field, value := range map[string]string{"displayName": manager.DisplayName, "branch": manager.Branch, "tag": manager.Tag, "revision": manager.Revision, "workspaceRef": manager.WorkspaceRef, "entryRef": manager.EntryRef} {
violations = append(violations, validateSafeRuntimeValue(prefix+"."+field, value)...)
}
targets := map[string]struct{}{}
for j, target := range manager.SupportedTargets {
if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") {
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j))
}
targetKey := target.OS + "/" + target.Arch
if _, exists := targets[targetKey]; exists {
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is duplicated", prefix, j))
}
targets[targetKey] = struct{}{}
}
configKeys := map[string]struct{}{}
for j, config := range manager.ConfigTemplates {
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...)
violations = append(violations, recordRuntimeProfileKey(configKeys, fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...)
violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.templateRef", config.TemplateRef)...)
violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.outputRef", config.OutputRef)...)
}
for j, output := range manager.OutputArtifacts {
violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.outputArtifacts[%d]", prefix, j), output)...)
}
violations = append(violations, duplicateViolations(prefix+".outputArtifacts", manager.OutputArtifacts)...)
if manager.Deployment.Mode != "" {
if manager.Deployment.Mode != "run-supervised" {
violations = append(violations, prefix+".deployment.mode is invalid")
}
if !validSemanticVersion(manager.Version) {
violations = append(violations, prefix+".version must be semantic when deployment is declared")
}
violations = append(violations, validateSafeRelativeRuntimePath(prefix+".deployment.executableRef", manager.Deployment.ExecutableRef)...)
if !containsString(manager.OutputArtifacts, manager.Deployment.ExecutableRef) {
violations = append(violations, prefix+".deployment.executableRef must name an output artifact")
}
for j, argument := range manager.Deployment.Arguments {
if !regexp.MustCompile(`^[A-Za-z0-9_./:=@+-]{1,120}$`).MatchString(argument) {
violations = append(violations, fmt.Sprintf("%s.deployment.arguments[%d] is invalid", prefix, j))
}
violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.deployment.arguments[%d]", prefix, j), argument)...)
}
if len(manager.Deployment.RequiredRunCapabilities) == 0 || !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy) {
violations = append(violations, prefix+".deployment.requiredRunCapabilities must include client-manager.deploy")
}
for j, capability := range manager.Deployment.RequiredRunCapabilities {
if !oneOf(capability, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall) {
violations = append(violations, fmt.Sprintf("%s.deployment.requiredRunCapabilities[%d] is invalid", prefix, j))
}
}
violations = append(violations, duplicateViolations(prefix+".deployment.requiredRunCapabilities", manager.Deployment.RequiredRunCapabilities)...)
if len(manager.Lifecycle.Actions) == 0 || manager.Lifecycle.StartupTimeoutSeconds < 1 || manager.Lifecycle.StartupTimeoutSeconds > 300 || manager.Lifecycle.StopTimeoutSeconds < 1 || manager.Lifecycle.StopTimeoutSeconds > 120 {
violations = append(violations, prefix+".lifecycle actions and bounded timeouts are required")
}
for j, action := range manager.Lifecycle.Actions {
if !oneOf(action, "start", "stop", "restart", "status", "update", "rollback", "uninstall") {
violations = append(violations, fmt.Sprintf("%s.lifecycle.actions[%d] is invalid", prefix, j))
}
}
violations = append(violations, duplicateViolations(prefix+".lifecycle.actions", manager.Lifecycle.Actions)...)
if containsAny(manager.Lifecycle.Actions, []string{"start", "stop", "restart", "status"}) && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerControl) {
violations = append(violations, prefix+".lifecycle control actions require client-manager.control")
}
if containsString(manager.Lifecycle.Actions, "update") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUpdate) {
violations = append(violations, prefix+".lifecycle update requires client-manager.update")
}
if containsString(manager.Lifecycle.Actions, "rollback") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerRollback) {
violations = append(violations, prefix+".lifecycle rollback requires client-manager.rollback")
}
if containsString(manager.Lifecycle.Actions, "uninstall") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUninstall) {
violations = append(violations, prefix+".lifecycle uninstall requires client-manager.uninstall")
}
if !oneOf(manager.Health.Mode, "component-heartbeat", "process") || manager.Health.IntervalSeconds < 5 || manager.Health.IntervalSeconds > 300 || manager.Health.DegradedAfterSeconds < manager.Health.IntervalSeconds*2 || manager.Health.OfflineAfterSeconds <= manager.Health.DegradedAfterSeconds || manager.Health.OfflineAfterSeconds > 3600 {
violations = append(violations, prefix+".health mode and thresholds are invalid")
}
for j, capability := range manager.Health.RequiredCapabilities {
if !oneOf(capability, "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream") {
violations = append(violations, fmt.Sprintf("%s.health.requiredCapabilities[%d] is invalid", prefix, j))
}
}
if manager.Health.Mode == "component-heartbeat" && !containsAny(manager.Health.RequiredCapabilities, []string{"component.register"}) || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.heartbeat") || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.health") {
violations = append(violations, prefix+".health component-heartbeat requires register, heartbeat, and health capabilities")
}
minimum, minimumOK := semanticVersionTuple(manager.Compatibility.MinimumVersion)
maximum, maximumOK := semanticVersionTuple(manager.Compatibility.MaximumVersion)
version, _ := semanticVersionTuple(manager.Version)
if manager.Compatibility.MinimumVersion != "" && !minimumOK || manager.Compatibility.MaximumVersion != "" && !maximumOK || minimumOK && maximumOK && compareSemanticVersion(minimum, maximum) > 0 || minimumOK && compareSemanticVersion(version, minimum) < 0 || maximumOK && compareSemanticVersion(version, maximum) > 0 {
violations = append(violations, prefix+".compatibility version bounds are invalid")
}
if manager.UpdatePolicy.Strategy != "manual-staged" || !manager.UpdatePolicy.RequireApproval || !manager.UpdatePolicy.RetainPrevious || manager.UpdatePolicy.HealthConfirmationSeconds < manager.Health.IntervalSeconds || manager.UpdatePolicy.HealthConfirmationSeconds > 600 {
violations = append(violations, prefix+".updatePolicy must be approved, staged, health checked, and retain previous")
}
}
if len(profiles.ClientManagers) > 0 {
violations = append(violations, "runtimeProfiles.clientManagers is no longer supported")
}
for i, extension := range profiles.DLLExtensions {
prefix := fmt.Sprintf("runtimeProfiles.dllExtensions[%d]", i)
@@ -442,11 +316,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].transportKeys references undeclared transport %q", i, key))
}
}
if profile.ClientManagerRef != "" {
if _, ok := managerKeys[profile.ClientManagerRef]; !ok {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].clientManagerRef references undeclared client manager", i))
}
}
if len(profile.DLLExtensionRefs) > 0 {
if profile.Mode != "local-process" || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) || len(profile.Platforms) != 1 || profile.Platforms[0] != "windows" {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d] DLL extensions require a windows local-process start profile", i))
@@ -626,11 +495,8 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
for i, transport := range profiles.TransportProfiles {
check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities)
}
for i, manager := range profiles.ClientManagers {
check(fmt.Sprintf("runtimeProfiles.clientManagers[%d].deployment.requiredRunCapabilities", i), manager.Deployment.RequiredRunCapabilities)
return violations
}
return violations
}
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
var violations []string