feat(scum): add file config workbench
This commit is contained in:
@@ -316,6 +316,25 @@ type GamePluginPage struct {
|
||||
BridgeActions []string
|
||||
}
|
||||
|
||||
// PluginFileWorkspace is a bounded, logical catalog for a plugin-owned files
|
||||
// workbench. Keys are logical identifiers, never host paths.
|
||||
type PluginFileWorkspace struct {
|
||||
DefaultDirectoryKey string
|
||||
Directories []PluginLogicalDirectory
|
||||
Files []PluginLogicalFile
|
||||
ConfigFields []PluginConfigField
|
||||
}
|
||||
|
||||
type PluginLogicalDirectory struct{ Key, Label, Scope string }
|
||||
type PluginLogicalFile struct {
|
||||
Key, DirectoryKey, Label, Kind, StreamKey string
|
||||
Editable bool
|
||||
}
|
||||
type PluginConfigField struct {
|
||||
Key, FileKey, ConfigKey, Label, Description, Control, DefaultValue, RestartImpact string
|
||||
Minimum, Maximum int
|
||||
}
|
||||
|
||||
type GamePluginBridge struct {
|
||||
Actions []string
|
||||
}
|
||||
@@ -595,6 +614,7 @@ type GamePluginManifest struct {
|
||||
Permissions []string
|
||||
Actions PluginLifecycleActions
|
||||
Pages []GamePluginPage
|
||||
FileWorkspace PluginFileWorkspace
|
||||
AI GamePluginManifestAI
|
||||
ProductionLifecycle GamePluginProductionLifecycle
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
@@ -624,6 +644,7 @@ type GamePlugin struct {
|
||||
LifecycleActions PluginLifecycleActions
|
||||
BridgeActions []string
|
||||
Pages []GamePluginPage
|
||||
FileWorkspace PluginFileWorkspace
|
||||
Tags []string
|
||||
AIPurposes []string
|
||||
ProductionLifecycle GamePluginProductionLifecycle
|
||||
@@ -1611,6 +1632,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
|
||||
plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields)
|
||||
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
|
||||
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
||||
plugin.FileWorkspace = CopyPluginFileWorkspace(plugin.FileWorkspace)
|
||||
plugin.Tags = CopyStringSlice(plugin.Tags)
|
||||
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
|
||||
plugin.ProductionLifecycle = CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle)
|
||||
@@ -1662,6 +1684,7 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
||||
manifest.Capabilities = CopyStringSlice(manifest.Capabilities)
|
||||
manifest.Permissions = CopyStringSlice(manifest.Permissions)
|
||||
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
|
||||
manifest.FileWorkspace = CopyPluginFileWorkspace(manifest.FileWorkspace)
|
||||
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
|
||||
manifest.ProductionLifecycle = CopyGamePluginProductionLifecycle(manifest.ProductionLifecycle)
|
||||
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
|
||||
@@ -1670,6 +1693,13 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
||||
return manifest
|
||||
}
|
||||
|
||||
func CopyPluginFileWorkspace(workspace PluginFileWorkspace) PluginFileWorkspace {
|
||||
workspace.Directories = append([]PluginLogicalDirectory(nil), workspace.Directories...)
|
||||
workspace.Files = append([]PluginLogicalFile(nil), workspace.Files...)
|
||||
workspace.ConfigFields = append([]PluginConfigField(nil), workspace.ConfigFields...)
|
||||
return workspace
|
||||
}
|
||||
|
||||
func CopyPluginCreateFields(fields []PluginCreateField) []PluginCreateField {
|
||||
if fields == nil {
|
||||
return nil
|
||||
|
||||
@@ -183,6 +183,38 @@ type GamePluginPageBody struct {
|
||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||
}
|
||||
|
||||
type PluginLogicalDirectoryBody struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
type PluginLogicalFileBody struct {
|
||||
Key string `json:"key"`
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
StreamKey string `json:"streamKey,omitempty"`
|
||||
Editable bool `json:"editable,omitempty"`
|
||||
}
|
||||
type PluginConfigFieldBody struct {
|
||||
Key string `json:"key"`
|
||||
FileKey string `json:"fileKey"`
|
||||
ConfigKey string `json:"configKey"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Control string `json:"control"`
|
||||
Minimum int `json:"minimum,omitempty"`
|
||||
Maximum int `json:"maximum,omitempty"`
|
||||
DefaultValue string `json:"defaultValue,omitempty"`
|
||||
RestartImpact string `json:"restartImpact"`
|
||||
}
|
||||
type PluginFileWorkspaceBody struct {
|
||||
DefaultDirectoryKey string `json:"defaultDirectoryKey"`
|
||||
Directories []PluginLogicalDirectoryBody `json:"directories"`
|
||||
Files []PluginLogicalFileBody `json:"files"`
|
||||
ConfigFields []PluginConfigFieldBody `json:"configFields"`
|
||||
}
|
||||
|
||||
type GamePluginBridgeBody struct {
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
@@ -303,6 +335,7 @@ type GamePluginManifestBody struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
Actions PluginLifecycleActionsBody `json:"actions"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"`
|
||||
AI GamePluginManifestAIBody `json:"ai,omitempty"`
|
||||
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
@@ -332,6 +365,7 @@ type GamePluginCreateRequest struct {
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"`
|
||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
AIPurposes []string `json:"aiPurposes,omitempty"`
|
||||
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle,omitempty"`
|
||||
@@ -358,6 +392,7 @@ type GamePluginResponse struct {
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||
@@ -933,6 +968,7 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
|
||||
Permissions: domain.CopyStringSlice(request.Manifest.Permissions),
|
||||
Actions: request.Manifest.Actions.ToDomain(),
|
||||
Pages: pagesToDomain(request.Manifest.Pages),
|
||||
FileWorkspace: fileWorkspaceToDomain(request.Manifest.FileWorkspace),
|
||||
AI: request.Manifest.AI.ToDomain(),
|
||||
ProductionLifecycle: request.Manifest.ProductionLifecycle.ToDomain(),
|
||||
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||
@@ -942,6 +978,34 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
|
||||
}
|
||||
}
|
||||
|
||||
func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorkspace {
|
||||
workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: body.DefaultDirectoryKey}
|
||||
for _, item := range body.Directories {
|
||||
workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
||||
}
|
||||
for _, item := range body.Files {
|
||||
workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
|
||||
}
|
||||
for _, item := range body.ConfigFields {
|
||||
workspace.ConfigFields = append(workspace.ConfigFields, domain.PluginConfigField{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
func fileWorkspaceFromDomain(workspace domain.PluginFileWorkspace) PluginFileWorkspaceBody {
|
||||
workspace = domain.CopyPluginFileWorkspace(workspace)
|
||||
body := PluginFileWorkspaceBody{DefaultDirectoryKey: workspace.DefaultDirectoryKey}
|
||||
for _, item := range workspace.Directories {
|
||||
body.Directories = append(body.Directories, PluginLogicalDirectoryBody{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
||||
}
|
||||
for _, item := range workspace.Files {
|
||||
body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
|
||||
}
|
||||
for _, item := range workspace.ConfigFields {
|
||||
body.ConfigFields = append(body.ConfigFields, PluginConfigFieldBody{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (bridge GamePluginBridgeBody) ToDomain() domain.GamePluginBridge {
|
||||
return domain.GamePluginBridge{Actions: domain.CopyStringSlice(bridge.Actions)}
|
||||
}
|
||||
@@ -1048,6 +1112,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
|
||||
LifecycleActions: request.LifecycleActions.ToDomain(),
|
||||
BridgeActions: domain.CopyStringSlice(request.BridgeActions),
|
||||
Pages: pagesToDomain(request.Pages),
|
||||
FileWorkspace: fileWorkspaceToDomain(request.FileWorkspace),
|
||||
Tags: domain.CopyStringSlice(request.Tags),
|
||||
AIPurposes: domain.CopyStringSlice(request.AIPurposes),
|
||||
ProductionLifecycle: request.ProductionLifecycle.ToDomain(),
|
||||
@@ -1295,6 +1360,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
|
||||
LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions),
|
||||
BridgeActions: plugin.BridgeActions,
|
||||
Pages: pagesFromDomain(plugin.Pages),
|
||||
FileWorkspace: fileWorkspaceFromDomain(plugin.FileWorkspace),
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
ProductionLifecycle: productionLifecycleFromDomain(plugin.ProductionLifecycle),
|
||||
|
||||
@@ -704,6 +704,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
||||
LifecycleActions: manifest.Actions,
|
||||
BridgeActions: manifest.Bridge.Actions,
|
||||
Pages: manifest.Pages,
|
||||
FileWorkspace: manifest.FileWorkspace,
|
||||
Tags: manifest.Tags,
|
||||
AIPurposes: manifest.AI.Purposes,
|
||||
ProductionLifecycle: manifest.ProductionLifecycle,
|
||||
|
||||
@@ -146,6 +146,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
||||
violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...)
|
||||
violations = append(violations, validateBridgeActions("bridgeActions", plugin.BridgeActions)...)
|
||||
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
||||
violations = append(violations, validatePluginFileWorkspace("fileWorkspace", plugin.FileWorkspace)...)
|
||||
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
||||
violations = append(violations, validateProductionLifecycle("productionLifecycle", plugin.ProductionLifecycle, true)...)
|
||||
@@ -207,6 +208,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
violations = append(violations, validateBridgeActions("manifest.bridge.actions", manifest.Bridge.Actions)...)
|
||||
violations = append(violations, validateLifecycleActions(manifest.Actions)...)
|
||||
violations = append(violations, validatePluginPages(manifest.Pages)...)
|
||||
violations = append(violations, validatePluginFileWorkspace("manifest.fileWorkspace", manifest.FileWorkspace)...)
|
||||
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
||||
violations = append(violations, validateProductionLifecycle("manifest.productionLifecycle", manifest.ProductionLifecycle, true)...)
|
||||
@@ -1400,6 +1402,46 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
|
||||
return violations
|
||||
}
|
||||
|
||||
func validatePluginFileWorkspace(prefix string, workspace domain.PluginFileWorkspace) []string {
|
||||
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) == 0 && len(workspace.Files) == 0 && len(workspace.ConfigFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
directories := map[string]bool{}
|
||||
files := map[string]domain.PluginLogicalFile{}
|
||||
for i, item := range workspace.Directories {
|
||||
field := fmt.Sprintf("%s.directories[%d]", prefix, i)
|
||||
if !validDistributionLogicalKey(item.Key) || item.Label == "" || !oneOf(item.Scope, "config", "logs") {
|
||||
violations = append(violations, field+" is invalid")
|
||||
}
|
||||
if directories[item.Key] {
|
||||
violations = append(violations, field+".key duplicates another directory")
|
||||
}
|
||||
directories[item.Key] = true
|
||||
}
|
||||
if !directories[workspace.DefaultDirectoryKey] {
|
||||
violations = append(violations, prefix+".defaultDirectoryKey must reference a declared directory")
|
||||
}
|
||||
for i, item := range workspace.Files {
|
||||
field := fmt.Sprintf("%s.files[%d]", prefix, i)
|
||||
if !validDistributionLogicalKey(item.Key) || !directories[item.DirectoryKey] || item.Label == "" || !oneOf(item.Kind, "config", "log") || (item.Kind == "log" && item.StreamKey == "") {
|
||||
violations = append(violations, field+" is invalid")
|
||||
}
|
||||
if _, exists := files[item.Key]; exists {
|
||||
violations = append(violations, field+".key duplicates another file")
|
||||
}
|
||||
files[item.Key] = item
|
||||
}
|
||||
for i, item := range workspace.ConfigFields {
|
||||
field := fmt.Sprintf("%s.configFields[%d]", prefix, i)
|
||||
file, exists := files[item.FileKey]
|
||||
if !validDistributionLogicalKey(item.Key) || !exists || file.Kind != "config" || !file.Editable || item.ConfigKey == "" || item.Label == "" || item.Description == "" || !oneOf(item.Control, "text", "number", "boolean", "port") || !oneOf(item.RestartImpact, "none", "restart-required") || (item.Minimum > 0 && item.Maximum > 0 && item.Minimum > item.Maximum) {
|
||||
violations = append(violations, field+" is invalid")
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateBridgeActions(field string, actions []string) []string {
|
||||
var violations []string
|
||||
for i, action := range actions {
|
||||
|
||||
@@ -35,6 +35,28 @@ func TestValidateGamePluginManifestRegistration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationValidatesLogicalFileWorkspace(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.FileWorkspace = domain.PluginFileWorkspace{
|
||||
DefaultDirectoryKey: "config",
|
||||
Directories: []domain.PluginLogicalDirectory{{Key: "config", Label: "配置", Scope: "config"}},
|
||||
Files: []domain.PluginLogicalFile{{Key: "settings", DirectoryKey: "config", Label: "Settings.ini", Kind: "config", Editable: true}},
|
||||
ConfigFields: []domain.PluginConfigField{{Key: "max-players", FileKey: "settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, RestartImpact: "restart-required"}},
|
||||
}
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected safe logical file workspace: %v", err)
|
||||
}
|
||||
registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "../host"
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
|
||||
t.Fatal("expected unsafe logical directory reference to fail")
|
||||
}
|
||||
registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "config"
|
||||
registration.Manifest.FileWorkspace.Files[0].Editable = false
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
|
||||
t.Fatal("expected a configuration field without an editable owning file to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Description = "requires direct run socket and raw AI key material"
|
||||
|
||||
Reference in New Issue
Block a user