refactor(scum): declare protected run requests

This commit is contained in:
npc0-hue
2026-07-29 22:37:16 +08:00
parent d7465bfd32
commit 99be8f0f3a
28 changed files with 497 additions and 152 deletions
@@ -1,29 +1,33 @@
## Design
The platform owns only reusable authorization, server isolation, auditing,
queues, opaque storage, and channels to Run. The SCUM plugin owns its page,
allowlists, schemas, event parsers, and Companion adapters. `platform_web`
mounts the declared plugin page generically.
The platform owns reusable authorization, tenant/server isolation, approvals,
auditing, expiry, queues, protected storage, and channels to Run. The SCUM
plugin owns its page, request generation, schemas, event parsers, and
Companion adapters. `platform_web` mounts the declared plugin page generically.
Run emits SCUM process stdout/stderr records through the durable log channel;
these are not server execution logs. The Companion parses only declared,
A bridge command may declare a protected request transport of kind `sql`,
`rcon`, or `program`. The declaration names only logical transport and target
keys plus a bounded text field; it cannot name a DSN, path, socket, credential,
or executable. A plugin can generate the request text, but Platform retains it
as protected payload, emits only redacted audit metadata, and forwards it only
after the normal server scope, permission, approval, expiry, and queue checks.
Run consumes a fenced, server-bound authorized request and resolves secrets and
the actual transport locally. Platform does not parse game-specific SQL, RCON,
or program syntax. `program` means a management-program transport accepted by
Run policy, never an operating-system shell.
Run emits SCUM process stdout/stderr console records through the durable log
channel; these are not file execution logs. The Companion parses only declared,
bounded record formats into semantic events. Unknown records make a bounded
diagnostic and are skipped. A per-server correlation digest may be derived
locally but never includes a raw network value in an upload.
The Companion receives only typed commands and invokes only registered typed
ports. Its game-data port exposes allowlisted player, vehicle, and position
data as bounded projections, never DSNs, paths, credentials, or rows. Fixed
server-management ports expose only declared operations. State changes read
the precondition, verify a safe window, write allowed fields, then confirm the
write. Reward delivery freezes a grant and maps each receipt to delivered,
failed, or unknown without retrying unknown outcomes. A command's failure or
unknown result affects that command alone.
The Companion receives only authorized, server-bound bridge commands and
bounded console records. Plugins, pages, and AI never receive DSNs, paths,
credentials, raw connections, sockets, or shell access. Run results are bounded
to `succeeded`, `failed`, or `unknown` with safe diagnostics. A request failure,
unknown text, or unsupported field affects that request alone.
Runtime capability/schema probes decide whether a particular handler is
available. They do not depend on a server/game/UE4SS/database version, build,
or source revision, and a failed probe never disables unrelated features.
`vehicle.spawn` is the one fixed administration template. It accepts only a
catalogued identifier and builds exactly `#spawnvehicle <vehicleCode>` inside
the Companion. The text stays private to its typed transport/audit boundary.
@@ -5,13 +5,16 @@ features are not disabled by an update string. The Companion uses typed,
platform-authorized non-production fixtures for configuration, player-state,
reward, notification, and vehicle operations; no remote server is contacted.
Run's required integration boundary is a bounded stdout/stderr record stream,
typed database projections, and fixed administration ports. It must not expose
paths, DSNs, credentials, raw rows, arbitrary SQL, shell, socket, or RCON to
the plugin, platform web, or AI. Unknown console formats create a bounded
diagnostic and no event.
Run's required integration boundary is a bounded stdout/stderr console record
stream and declared protected SQL, RCON, or management-program transports.
Plugins generate bounded request text, while Platform authorizes, approves,
queues, redacts, and forwards it only to the bound Run request. Run alone
resolves its local transport. Paths, DSNs, credentials, raw connections, host
paths, sockets, and host OS shell access never reach the plugin, platform web,
or AI. Unknown console or request formats create a bounded diagnostic for the
affected request and no fabricated event.
Remaining production enablement is operational: a deployed Run implementation
must provide the declared typed ports. Until then only the affected operation
must provide the declared protected transports. Until then only the affected operation
is reported unavailable; the plugin page and unrelated feature capabilities
remain active.
@@ -8,8 +8,8 @@ plugin bundle and Companion channel.
| --- | --- | --- | --- |
| `api/game_player_handlers.go`, `service/game_players.go`, `domain/game_players.go` | SCUM player profiles, sessions, risk projections | `features/players` page data projected from declared `scum.login`/`scum.logout` semantic events | Companion parser and event uploader |
| `api/game_map_trajectory_handlers.go`, `service/game_map_trajectories.go`, `domain/game_map_trajectories.go` | SCUM map conversion and trajectory projection | `features/trajectories` catalog and page projection | A verified server-side source; otherwise the page remains unavailable |
| `api/game_gift_handlers.go`, `service/game_gifts.go`, `domain/game_gifts.go` | SCUM catalog, frozen revisions and grant workflow | `features/rewards` contracts plus `reward.deliver` and `player.notify` handlers | Compatible Companion reward handler and approved revision |
| `api/game_player_handlers.go`, `service/game_player_state_patch.go`, `domain/game_player_state_patch.go` | SCUM field catalog and state-patch approval | `features/state-patches` versioned field catalog and `game-state.patch` handler | Version discovery and verified safe window |
| `api/game_gift_handlers.go`, `service/game_gifts.go`, `domain/game_gifts.go` | SCUM catalog and grant workflow | `features/rewards` contracts plus declared protected request handlers | Available server-bound handler and approval |
| `api/game_player_handlers.go`, `service/game_player_state_patch.go`, `domain/game_player_state_patch.go` | SCUM field catalog and state-patch approval | `features/state-patches` declarative field catalog and protected request handler | Runtime schema availability and approval |
| `components/ScumFileConfigWorkbench.tsx` | SCUM configuration workbench | SCUM page bundle configuration catalog | Companion `config.read`/`config.patch` availability |
| `components/GamePlayerIntelligencePanel.tsx`, `GameGiftCatalogPanel.tsx`, `ScumMapTrajectoryPanel.tsx` | SCUM panels imported by the host | SCUM page bundle module | Generic manifest bundle validation and plugin-page host |
| `contracts/scumOperations.ts`, `schemas/scumOperations.ts` | `game.scum` host branch | manifest-driven bundle contract | Generic page-bundle registry |
@@ -2,22 +2,32 @@
SCUM plugin behavior must survive server updates without treating a game, UE4SS,
database, build, or revision string as a feature kill switch. The prior plan
incorrectly used static compatibility gates.
also incorrectly treated plugin-generated SQL and management-command text as a
direct-access surface. Generating text is distinct from possessing a DSN, RCON
credential, host path, socket, or shell capability.
## What Changes
- Move all SCUM feature authority to the plugin and its Companion, with generic
platform authorization, isolation, audit, queue, storage, and Run channels.
platform authorization, isolation, approval, audit, expiry, queue, protected
storage, and Run channels.
- Replace build/version/revision gates with runtime schema and capability probes.
- Let Run provide bounded SCUM stdout/stderr records, typed database reads, and
fixed administration operations only through platform-authorized channels.
- Require field allowlists, pre-read/safe-window/write-confirmation flows, and
`succeeded`/`failed`/`unknown` results for mutating adapters.
- Preserve fixed-template `vehicle.spawn`; its private `#spawnvehicle
<vehicleCode>` audit text never enters a result or page payload.
- Let plugins declare and generate bounded SQL, RCON, or program-management
request text for a logical, server-bound protected transport. Platform stores,
authorizes, approves, audits, expires, and forwards that opaque payload; Run
alone resolves the bound transport and executes the authorized request.
- Keep platform transport-agnostic: it validates declarations, scope, limits,
lifecycle, and redaction but does not parse SCUM SQL, RCON, or program syntax.
- Let Run provide bounded SCUM process stdout/stderr console records through the
platform log channel for plugin parsing. These are not file execution logs.
- Require bounded `succeeded`/`failed`/`unknown` result classifications and safe
diagnostics. Unknown text, command formats, and fields affect only the one
request and never disable unrelated features.
## Non-Goals
No arbitrary RCON, SQL, shell, socket, path, DSN, credential, raw database
row, OCR, screenshot, keyboard/mouse injection, or desktop automation is
introduced. No SCUM import or `game.scum` branch is added to `platform_web`.
No plugin, page, AI request, or result projection receives a DSN, database
path, raw connection, RCON credential, host path, direct socket, or shell.
Protected program-management requests are not host OS shell requests. No OCR,
screenshot, keyboard/mouse injection, desktop automation, Run source, SCUM
import, or `game.scum` branch is added to `platform_web`.
@@ -13,40 +13,33 @@ A probe or command failure SHALL affect only that handler invocation.
- **THEN** the Companion returns a typed unavailable/failed/unknown result for
that command and does not disable an unrelated plugin feature
### Requirement: Run data channels are bounded
### Requirement: Protected requests are platform mediated
Run SHALL send SCUM stdout/stderr records to the Companion through the durable
log channel and SHALL provide database data only as typed allowlisted
projections and fixed server-management operations. No plugin, web page, or
AI request SHALL receive a path, DSN, credential, raw row, arbitrary SQL,
shell, socket, or RCON command.
The SCUM plugin SHALL be able to generate bounded SQL, RCON, or
program-management request text for a declared logical protected transport.
Platform SHALL authorize, isolate by tenant and server, approve, audit with
redaction, expire, queue, store, and forward each opaque request to the bound
Run endpoint. Platform SHALL not parse SCUM SQL, RCON, or program syntax. Run
SHALL execute only a current, authorized, server-bound request and return a
bounded `succeeded`, `failed`, or `unknown` result with safe diagnostics.
No plugin, web page, or AI request SHALL receive a path, DSN, raw connection,
credential, host path, direct socket, or shell capability.
#### Scenario: Unsupported request format
- **WHEN** Run cannot recognize a request text, command format, or field
- **THEN** it returns `failed` or `unknown` with a safe diagnostic for that
request and does not disable an unrelated capability
### Requirement: SCUM console records use the log channel
Run SHALL send bounded SCUM process stdout/stderr console records through the
durable platform log channel. The Companion SHALL parse only declared bounded
formats and skip unknown lines with a bounded diagnostic. Console records are
not file execution logs.
#### Scenario: Unknown console output
- **WHEN** stdout or stderr does not match a declared semantic parser
- **THEN** the Companion records a bounded diagnostic and uploads no semantic
event or raw line
### Requirement: Mutations prove safety
State patch adapters SHALL use field allowlists, a pre-read, safe-window
verification, a bounded write, and read-after-write confirmation. Reward
adapters SHALL freeze their typed grant and return delivered, failed, or
unknown without automatically retrying unknown outcomes.
#### Scenario: Confirmation cannot be established
- **WHEN** a typed write or post-write read cannot establish success
- **THEN** the Companion returns `unknown` and does not repeat the operation
### Requirement: Vehicle spawning remains fixed
`vehicle.spawn` SHALL accept only a catalogued vehicle code and create exactly
`#spawnvehicle <vehicleCode>` inside the Companion. Protected audit text SHALL
not be present in command results or browser payloads.
#### Scenario: Unsafe spawn input
- **WHEN** input includes an unlisted code, an extra field, command text, SQL,
a path, credential, socket, shell text, or RCON text
- **THEN** no transport call occurs and validation fails
@@ -4,7 +4,8 @@
The SCUM plugin SHALL own SCUM schemas, allowlists, migration adapters,
Companion behavior, and feature UI. The platform SHALL retain only reusable
authorization, isolation, auditing, queues, storage, and generic plugin-host
authorization, isolation, approval, auditing, expiry, queues, protected
storage, generic Run transport declarations, and generic plugin-host
primitives. `platform_web` SHALL not import SCUM code or branch on `game.scum`.
#### Scenario: Page mounting
@@ -35,3 +36,18 @@ to the server and feature, never to a game version.
- **WHEN** no unique server-feature migration flag is present
- **THEN** historical records remain readable and plugin writes stay disabled
### Requirement: Protected request declarations are generic
The plugin manifest and SDK SHALL support generic declared protected request
transports for SQL, RCON, and management-program text. Declarations SHALL use
only logical server-bound transport/target keys and bounded text fields.
Browser projections and audit records SHALL redact request text. Declarations
shall not grant credentials, paths, raw connections, direct sockets, or host OS
shell execution.
#### Scenario: Plugin generates an SQL request
- **WHEN** the plugin queues SQL text through a declared protected transport
- **THEN** Platform stores and audits only its protected/redacted form and
forwards it only after generic authorization and approval checks
@@ -1,22 +1,17 @@
## 1. Replace version gates with runtime probes
## 1. Reopen the architecture boundary
- [x] 1.1 Remove SCUM/game/UE4SS/database build, revision, and version feature gates from the change contract, manifest-facing feature layer, Companion registry, adapters, and tests.
- [x] 1.2 Make handler availability server-bound and capability/schema-probe based; isolate failure to the affected command.
- [x] 1.1 Replace the prior SQL/RCON prohibition and fixed SCUM command template in proposal, design, and specifications with generic protected request semantics.
- [x] 1.2 Preserve runtime probe isolation while removing every SCUM/UE4SS/database build, revision, and version gate.
## 2. Establish typed Run/Companion boundaries
## 2. Define browser-side protected request contracts
- [x] 2.1 Define restricted typed ports for configuration, player state, rewards, notifications, and fixed server administration with no raw paths, DSNs, rows, credentials, SQL, shell, sockets, or RCON.
- [x] 2.2 Parse bounded Run stdout/stderr records into semantic events; skip unknown formats with bounded diagnostics and irreversible per-server correlation.
- [x] 2.3 Implement state pre-read, safe-window, allowlisted write, and read-after-write confirmation with typed results.
- [x] 2.4 Implement frozen typed reward delivery results without automatic unknown retries.
- [x] 2.5 Preserve the fixed private vehicle-spawn template and its allowlist.
- [x] 2.1 Add generic SQL, RCON, and management-program protected transport declarations to the manifest schema, platform domain validation, and plugin SDK.
- [x] 2.2 Permit only a declared bounded request-text field for protected commands; continue rejecting credentials, DSNs, paths, raw connections, direct sockets, and host OS shell material.
- [x] 2.3 Keep queue, approval, server/tenant isolation, expiry, and Run-facing protected payload semantics generic; redact text from browser responses and audit summaries.
- [x] 2.4 Declare SCUM plugin protected database and management transports without adding SCUM parsing or a fixed command template.
## 3. Complete plugin-owned migration
## 3. Verify and deliver
- [x] 3.1 Remove version-scoped feature catalogs, page context, API requests, and migration flags in favor of runtime schema/capability availability.
- [x] 3.2 Keep platform records read-only with provenance and leave platform-web generic.
## 4. Verify and deliver
- [x] 4.1 Run Companion, plugin, manifest, OpenSpec strict, structure, and scoped source-boundary verification.
- [x] 4.2 Stage scoped files, commit, and push `main`.
- [x] 3.1 Update focused Go and TypeScript tests for declarations, request generation, redaction, and safe rejection.
- [x] 3.2 Run focused Go/TS tests, OpenSpec strict validation, and structure verification.
- [ ] 3.3 Stage scoped files, commit, and push `main` (commit created; push remains blocked by remote SSH access).
+19
View File
@@ -9,6 +9,7 @@ const (
GameClientBridgeCommandClaimed GameClientBridgeCommandState = "claimed"
GameClientBridgeCommandSucceeded GameClientBridgeCommandState = "succeeded"
GameClientBridgeCommandFailed GameClientBridgeCommandState = "failed"
GameClientBridgeCommandUnknown GameClientBridgeCommandState = "unknown"
GameClientBridgeCommandCancelled GameClientBridgeCommandState = "cancelled"
GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired"
)
@@ -39,6 +40,17 @@ type GameClientBridgeCommandDeclaration struct {
ResultSchemaRef string
TimeoutSeconds int
MaxPayloadBytes int
ProtectedRequest *GameClientBridgeProtectedRequestDeclaration
}
// GameClientBridgeProtectedRequestDeclaration binds plugin-generated text to a
// logical server transport. It never carries its resolved connection details.
type GameClientBridgeProtectedRequestDeclaration struct {
Kind string
TransportKey string
TargetKey string
TextField string
MaxTextBytes int
}
type GameClientBridgeSnapshotDeclaration struct {
@@ -108,6 +120,7 @@ type GameClientBridgeResultStatus string
const (
GameClientBridgeResultSucceeded GameClientBridgeResultStatus = "succeeded"
GameClientBridgeResultFailed GameClientBridgeResultStatus = "failed"
GameClientBridgeResultUnknown GameClientBridgeResultStatus = "unknown"
GameClientBridgeResultCancelled GameClientBridgeResultStatus = "cancelled"
)
@@ -384,6 +397,12 @@ func CopyGameClientBridgePayload(value map[string]any) map[string]any {
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
for index := range value.Commands {
if value.Commands[index].ProtectedRequest != nil {
copy := *value.Commands[index].ProtectedRequest
value.Commands[index].ProtectedRequest = &copy
}
}
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
+3
View File
@@ -1045,6 +1045,9 @@ const (
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql"
JobCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon"
JobCapabilityRemoteRunProgram = "remote.run.program.command"
JobCapabilityRunSelfUpdate = "run.self-update"
JobCapabilityDistributionBuild = "distribution.build"
JobCapabilityDependenciesCheck = "dependencies.check"
+33 -10
View File
@@ -262,14 +262,23 @@ type GamePluginRemoteAccessBody struct {
}
type GameClientBridgeCommandDeclarationBody struct {
Type string `json:"type"`
Title string `json:"title"`
Permission string `json:"permission"`
ApprovalLevel string `json:"approvalLevel"`
PayloadSchemaRef string `json:"payloadSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds"`
MaxPayloadBytes int `json:"maxPayloadBytes"`
Type string `json:"type"`
Title string `json:"title"`
Permission string `json:"permission"`
ApprovalLevel string `json:"approvalLevel"`
PayloadSchemaRef string `json:"payloadSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds"`
MaxPayloadBytes int `json:"maxPayloadBytes"`
ProtectedRequest *GameClientBridgeProtectedRequestDeclarationBody `json:"protectedRequest,omitempty"`
}
type GameClientBridgeProtectedRequestDeclarationBody struct {
Kind string `json:"kind"`
TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"`
TextField string `json:"textField"`
MaxTextBytes int `json:"maxTextBytes"`
}
type GameClientBridgeSnapshotDeclarationBody struct {
@@ -1110,7 +1119,7 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces
func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest {
commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands))
for index, command := range body.Commands {
commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestToDomain(command.ProtectedRequest)}
}
snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots))
for index, snapshot := range body.Snapshots {
@@ -1135,6 +1144,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
}
func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
if value == nil {
return nil
}
return &domain.GameClientBridgeProtectedRequestDeclaration{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes}
}
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
return domain.PluginLifecycleActions{
Install: actions.Install,
@@ -1527,7 +1543,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
value = domain.CopyGameClientBridgeManifest(value)
commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands))
for index, command := range value.Commands {
commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestFromDomain(command.ProtectedRequest)}
}
snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots))
for index, snapshot := range value.Snapshots {
@@ -1552,6 +1568,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
}
func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
if value == nil {
return nil
}
return &GameClientBridgeProtectedRequestDeclarationBody{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes}
}
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
items := make([]MarketplacePluginResponse, len(plugins))
for i, plugin := range plugins {
+36 -2
View File
@@ -1,6 +1,7 @@
package service
import (
"crypto/sha256"
"encoding/json"
"fmt"
"reflect"
@@ -251,6 +252,30 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue
return values
}
func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error {
if declaration == nil {
return nil
}
if len(payload) != 1 {
return validationError("protected bridge request must contain only its declared text field")
}
value, exists := payload[declaration.TextField]
if !exists {
return validationError("protected bridge request text field is required")
}
text, ok := value.(string)
if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes {
return validationError("protected bridge request text is invalid")
}
return nil
}
func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string {
text, _ := payload[declaration.TextField].(string)
digest := sha256.Sum256([]byte(text))
return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8])
}
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
@@ -274,6 +299,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
}
if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil {
return domain.GameClientBridgeCommand{}, err
}
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
if err == nil {
@@ -312,7 +340,11 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
CreatedAt: stamp,
UpdatedAt: stamp,
}
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command")
summary := "queued declared game client bridge command"
if declaration.ProtectedRequest != nil {
summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload)
}
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
@@ -426,6 +458,8 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid
command.State = domain.GameClientBridgeCommandSucceeded
case domain.GameClientBridgeResultFailed:
command.State = domain.GameClientBridgeCommandFailed
case domain.GameClientBridgeResultUnknown:
command.State = domain.GameClientBridgeCommandUnknown
case domain.GameClientBridgeResultCancelled:
command.State = domain.GameClientBridgeCommandCancelled
}
@@ -684,7 +718,7 @@ func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, compon
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
switch state {
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
return true
default:
return false
@@ -1,6 +1,7 @@
package service
import (
"strings"
"testing"
"time"
@@ -82,6 +83,38 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
}
}
func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
plugin, err := svc.store.GamePlugins().Get("game.scum")
if err != nil {
t.Fatal(err)
}
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "database", Kind: "sqlite", TargetKey: "database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}}
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 60, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "database", TargetKey: "database", TextField: "requestText", MaxTextBytes: 1024}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
text := "UPDATE players SET rank = 2 WHERE id = 7"
request := domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-1", ExpiresAt: clock.Add(time.Minute)}
command, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatalf("queue protected request: %v", err)
}
if command.ApprovalState != domain.GameClientBridgeApprovalPending {
t.Fatalf("protected request bypassed approval: %#v", command)
}
if _, err := svc.queueGameClientBridgeCommand("user-1", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text, "unexpected": true}, IdempotencyKey: "protected-extra", ExpiresAt: clock.Add(time.Minute)}); err == nil {
t.Fatal("protected request accepted undeclared payload field")
}
events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: command.ID})
if err != nil || len(events) != 1 {
t.Fatalf("protected request audit: events=%#v err=%v", events, err)
}
if strings.Contains(events[0].Summary, text) || !strings.Contains(events[0].Summary, "text=redacted") {
t.Fatalf("audit leaked protected request: %#v", events[0])
}
}
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
request := bridgeQueueRequest(*clock, "scope-key")
+1 -1
View File
@@ -19,7 +19,7 @@ func scumDeploymentTestPlugin() domain.GamePlugin {
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: "version", Kind: "version.matches", 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}},
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}},
}}},
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ func ValidateGameClientBridgeResultRequest(request domain.GameClientBridgeResult
if request.FencingToken == 0 {
violations = append(violations, "fencingToken is required")
}
if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultCancelled {
if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultUnknown && request.Status != domain.GameClientBridgeResultCancelled {
violations = append(violations, "status is invalid")
}
violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512)
@@ -52,7 +52,7 @@ func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) {
{name: "claim token", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: " session"}), want: "sessionToken"},
{name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"},
{name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"},
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unknown"}), want: "status"},
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unexpected"}), want: "status"},
{name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"},
{name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"},
{name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"},
+53 -6
View File
@@ -420,10 +420,14 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile")
}
}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range runtimeProfiles.TransportProfiles {
transports[transport.Key] = transport
}
commandTypes := map[string]struct{}{}
for index, command := range bridge.Commands {
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
if !clientManagerIdentifierPattern.MatchString(command.Type) || command.ProtectedRequest == nil && unsafeGameClientBridgeCommandType(command.Type) {
violations = append(violations, prefix+".type is invalid or unsafe")
}
if _, exists := commandTypes[command.Type]; exists {
@@ -448,6 +452,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
violations = append(violations, prefix+".maxPayloadBytes is invalid")
}
violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...)
}
snapshotTypes := map[string]struct{}{}
for index, snapshot := range bridge.Snapshots {
@@ -468,10 +473,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
}
}
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range runtimeProfiles.TransportProfiles {
transports[transport.Key] = transport
}
for index, template := range bridge.QueryTemplates {
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(template.Key) {
@@ -649,6 +650,50 @@ func unsafeGameClientBridgeCommandType(value string) bool {
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
}
func validateGameClientBridgeProtectedRequest(prefix string, request *domain.GameClientBridgeProtectedRequestDeclaration, transports map[string]domain.RuntimeTransportProfile) []string {
if request == nil {
return nil
}
var violations []string
if !oneOf(request.Kind, "sql", "rcon", "program") {
violations = append(violations, prefix+".kind is invalid")
}
for field, value := range map[string]string{"transportKey": request.TransportKey, "targetKey": request.TargetKey, "textField": request.TextField} {
if !validDistributionLogicalKey(value) || unsafeGameClientBridgePayloadKey(value) {
violations = append(violations, prefix+"."+field+" is invalid")
}
}
if request.MaxTextBytes < 1 || request.MaxTextBytes > maxGameClientBridgePayloadString {
violations = append(violations, prefix+".maxTextBytes is invalid")
}
transport, exists := transports[request.TransportKey]
if !exists {
return append(violations, prefix+".transportKey must reference a declared runtime transport profile")
}
if transport.TargetKey != request.TargetKey {
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
}
wantKind, wantCapability := "", ""
switch request.Kind {
case "sql":
wantCapability = domain.JobCapabilityRemoteRunProtectedSQL
case "rcon":
wantKind, wantCapability = "rcon", domain.JobCapabilityRemoteRunProtectedRCON
case "program":
wantKind, wantCapability = "program", domain.JobCapabilityRemoteRunProgram
}
if request.Kind == "sql" && transport.Kind != "mysql" && transport.Kind != "sqlite" {
violations = append(violations, prefix+".transportKey must use mysql or sqlite for sql requests")
}
if wantKind != "" && transport.Kind != wantKind {
violations = append(violations, prefix+".transportKey does not match protected request kind")
}
if wantCapability != "" && !containsString(transport.Capabilities, wantCapability) {
violations = append(violations, prefix+".transportKey is missing required protected transport capability")
}
return violations
}
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
var violations []string
violations = appendRequired(violations, "pluginId", request.PluginID)
@@ -1902,6 +1947,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
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.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
@@ -1934,7 +1980,8 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunDBMySQLQuery,
domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunRCONCommand:
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
return true
default:
return false
+2 -2
View File
@@ -296,7 +296,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
violations = append(violations, recordRuntimeProfileKey(transportKeys, prefix+".key", transport.Key)...)
if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon") {
if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program") {
violations = append(violations, prefix+".kind is invalid")
}
if transport.TargetKey != "" {
@@ -480,7 +480,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
}
func containsRequiredVerification(checks []domain.RuntimeServerVerificationCheck) bool {
required := map[string]bool{"executable.present": false, "version.matches": false, "port.bound": false, "config.readable": false, "process.healthy": false}
required := map[string]bool{"executable.present": false, "port.bound": false, "config.readable": false, "process.healthy": false}
for _, check := range checks {
if check.Required {
if _, ok := required[check.Kind]; ok {
@@ -5,12 +5,13 @@ feature gate: no SCUM game, database, UE4SS build, or source revision controls
plugin availability.
The Companion declares availability from its server-bound typed ports and
runtime schema probes. Notification and fixed vehicle spawning can use a local
typed transport, but callers never supply a command, socket, credential, path,
or raw transport reply. Vehicle spawning creates only the private
`#spawnvehicle <vehicleCode>` template from the plugin allowlist.
runtime schema probes. Plugin-generated SQL, RCON, and management-program
request text is declared through the protected Platform-to-Run transport, not
executed by the Companion. Callers never receive a socket, credential, path,
or raw transport reply. A management-program request is not host OS shell
access.
Semantic events come from bounded Run stdout/stderr records. Unknown records
create diagnostics and never produce fabricated events. Run database access is
limited to typed allowlisted projections and safe mutations; DSNs, rows, SQL,
and credentials do not leave Run.
Semantic events come from bounded Run stdout/stderr console records. Unknown
records create diagnostics and never produce fabricated events. DSNs, rows,
connections, and credentials do not leave Run; request text is protected and
redacted from browser and audit projections.
@@ -68,7 +68,7 @@ func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testi
}
}
func TestVersionedUE4SSNotificationIsFixedTypedAndRedacted(t *testing.T) {
func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""})
@@ -10,7 +10,8 @@ import (
)
// SafeAdapter is intentionally narrow: it receives typed values only and has
// no raw RCON, SQL, host-path, credential, or shell access.
// no direct transport, host-path, credential, or shell access. Protected SQL,
// RCON, and management-program text is forwarded to Run by Platform, not here.
type SafeAdapter interface {
ReadConfiguration(context.Context) (map[string]any, error)
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
@@ -47,6 +47,9 @@
"remote.run.process.start",
"remote.run.process.stop",
"remote.run.logs.transfer",
"remote.run.protected.sql",
"remote.run.protected.rcon",
"remote.run.program.command",
"client-manager.deploy",
"client-manager.control",
"client-manager.update",
@@ -67,7 +70,10 @@
"remote.run.files.write",
"remote.run.process.start",
"remote.run.process.stop",
"remote.run.logs.transfer"
"remote.run.logs.transfer",
"remote.run.protected.sql",
"remote.run.protected.rcon",
"remote.run.program.command"
],
"logTransfer": true
},
@@ -190,6 +196,39 @@
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 4096
},
{
"type": "database.request",
"title": "Execute approved SCUM database request",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 16384,
"protectedRequest": { "kind": "sql", "transportKey": "scum-database", "targetKey": "scum-database", "textField": "requestText", "maxTextBytes": 16384 }
},
{
"type": "management.rcon.request",
"title": "Execute approved SCUM management command",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 8192,
"protectedRequest": { "kind": "rcon", "transportKey": "scum-management", "targetKey": "scum-management", "textField": "requestText", "maxTextBytes": 8192 }
},
{
"type": "management.program.request",
"title": "Execute approved SCUM management program request",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 8192,
"protectedRequest": { "kind": "program", "transportKey": "scum-program", "targetKey": "scum-program", "textField": "requestText", "maxTextBytes": 8192 }
}
],
"snapshots": [
@@ -271,7 +310,10 @@
"vehicle.spawn",
"event.start",
"restart.prepare",
"maintenance.prepare"
"maintenance.prepare",
"database.request",
"management.rcon.request",
"management.program.request"
],
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
"featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"]
@@ -482,7 +524,6 @@
],
"discoveryMarkers": [
{ "key": "scum-executable", "kind": "file.exists", "targetKey": "scum/server-executable", "required": true },
{ "key": "scum-version", "kind": "command.version", "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 },
@@ -490,7 +531,6 @@
],
"verificationChecks": [
{ "key": "executable", "kind": "executable.present", "targetKey": "scum/server-executable", "required": true },
{ "key": "version", "kind": "version.matches", "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 }
@@ -498,6 +538,22 @@
}
],
"logSources": [
{
"key": "scum-console-stdout",
"kind": "process.stdout",
"targetKey": "scum/server-process",
"streamKey": "scum.console.stdout",
"cursorKind": "sequence",
"retentionDays": 30
},
{
"key": "scum-console-stderr",
"kind": "process.stderr",
"targetKey": "scum/server-process",
"streamKey": "scum.console.stderr",
"cursorKind": "sequence",
"retentionDays": 30
},
{
"key": "scum-chat-events",
"kind": "file.tail",
@@ -686,6 +742,24 @@
"remote.rsync.read",
"remote.rsync.write"
]
},
{
"key": "scum-database",
"kind": "sqlite",
"targetKey": "scum-database",
"capabilities": ["remote.run.protected.sql"]
},
{
"key": "scum-management",
"kind": "rcon",
"targetKey": "scum-management",
"capabilities": ["remote.run.protected.rcon"]
},
{
"key": "scum-program",
"kind": "program",
"targetKey": "scum-program",
"capabilities": ["remote.run.program.command"]
}
],
"clientManagers": [
@@ -742,10 +816,6 @@
"offlineAfterSeconds": 120,
"requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"]
},
"compatibility": {
"minimumVersion": "1.0.0",
"allowDowngrade": false
},
"updatePolicy": {
"strategy": "manual-staged",
"requireApproval": true,
@@ -0,0 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["requestText"],
"properties": { "requestText": { "type": "string", "minLength": 1, "maxLength": 16384 } }
}
@@ -0,0 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["outcome"],
"properties": { "outcome": { "enum": ["succeeded", "failed", "unknown"] }, "diagnostic": { "type": "string", "maxLength": 512 } }
}
@@ -299,7 +299,20 @@
"payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 },
"protectedRequest": { "$ref": "#/$defs/gameClientBridgeProtectedRequest" }
}
},
"gameClientBridgeProtectedRequest": {
"type": "object",
"required": ["kind", "transportKey", "targetKey", "textField", "maxTextBytes"],
"additionalProperties": false,
"properties": {
"kind": { "enum": ["sql", "rcon", "program"] },
"transportKey": { "$ref": "#/$defs/logicalKey" },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"maxTextBytes": { "type": "integer", "minimum": 1, "maximum": 16384 }
}
},
"gameClientBridgeSnapshot": {
@@ -384,6 +397,9 @@
"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",
@@ -605,7 +621,7 @@
"prerequisites": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerPrerequisite" }, "maxItems": 16 },
"configMappings": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerConfigMapping" }, "minItems": 1, "maxItems": 32, "uniqueItems": true },
"discoveryMarkers": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerDiscoveryMarker" }, "minItems": 1, "maxItems": 32, "uniqueItems": true },
"verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 5, "maxItems": 16, "uniqueItems": true }
"verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 4, "maxItems": 16, "uniqueItems": true }
}
},
"runtimeServerPrerequisite": {
@@ -651,7 +667,7 @@
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"kind": { "enum": ["file", "ftp", "rsync", "mysql", "sqlite", "rcon"] },
"kind": { "enum": ["file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program"] },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 }
}
@@ -789,7 +805,7 @@
"allOf": [
{
"if": { "required": ["deployment"] },
"then": { "required": ["version", "lifecycle", "health", "compatibility", "updatePolicy"] }
"then": { "required": ["version", "lifecycle", "health", "updatePolicy"] }
}
]
},
+35 -3
View File
@@ -393,7 +393,7 @@ function validateServerDeploymentProfiles(manifest: unknown): string[] {
if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`);
mappingKeys.add(mapping.fieldKey);
}
const requiredChecks = new Set(["executable.present", "version.matches", "port.bound", "config.readable", "process.healthy"]);
const requiredChecks = new Set(["executable.present", "port.bound", "config.readable", "process.healthy"]);
for (const check of profile.verificationChecks ?? []) {
if (check.required) requiredChecks.delete(check.kind);
}
@@ -601,7 +601,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
}
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string };
type ProtectedRequest = { kind?: string; transportKey?: string; targetKey?: string; textField?: string; maxTextBytes?: number };
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string; protectedRequest?: ProtectedRequest };
type BridgeQueryTemplate = {
key?: string;
permission?: string;
@@ -693,13 +694,44 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
for (const [index, command] of (bridge.commands ?? []).entries()) {
const location = `manifest.gameClientBridge.commands[${index}]`;
const type = command.type ?? "";
const unsafeTypeReason = unsafeGameClientBridgeCommandTypeReason(type);
const unsafeTypeReason = command.protectedRequest ? undefined : unsafeGameClientBridgeCommandTypeReason(type);
if (unsafeTypeReason) {
errors.push(`${location}.type: ${unsafeTypeReason}`);
}
if (!command.approvalLevel) {
errors.push(`${location}.approvalLevel: approval metadata is required`);
}
const protectedRequest = command.protectedRequest;
if (protectedRequest) {
if (!new Set(["sql", "rcon", "program"]).has(protectedRequest.kind ?? "")) {
errors.push(`${location}.protectedRequest.kind: must be sql, rcon, or program`);
}
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(protectedRequest.textField ?? "")) {
errors.push(`${location}.protectedRequest.textField: must be a safe bounded field name`);
}
if (!Number.isInteger(protectedRequest.maxTextBytes) || (protectedRequest.maxTextBytes ?? 0) < 1 || (protectedRequest.maxTextBytes ?? 0) > 16384) {
errors.push(`${location}.protectedRequest.maxTextBytes: must be between 1 and 16384`);
}
const transport = transportProfiles.find((candidate) => candidate.key === protectedRequest.transportKey);
if (!transport) {
errors.push(`${location}.protectedRequest.transportKey: must reference a declared runtime transport profile`);
} else {
if (!protectedRequest.targetKey || protectedRequest.targetKey !== transport.targetKey) {
errors.push(`${location}.protectedRequest.targetKey: must match the declared runtime transport target`);
}
const expectedCapability = { sql: "remote.run.protected.sql", rcon: "remote.run.protected.rcon", program: "remote.run.program.command" }[protectedRequest.kind ?? ""];
if (protectedRequest.kind === "sql" && transport.kind !== "mysql" && transport.kind !== "sqlite") {
errors.push(`${location}.protectedRequest.transportKey: sql requests require mysql or sqlite transport`);
}
if ((protectedRequest.kind === "rcon" && transport.kind !== "rcon") || (protectedRequest.kind === "program" && transport.kind !== "program")) {
errors.push(`${location}.protectedRequest.transportKey: transport kind does not match protected request kind`);
}
if (expectedCapability && !transport.capabilities?.includes(expectedCapability)) {
errors.push(`${location}.protectedRequest.transportKey: is missing required protected transport capability`);
}
}
}
for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) {
if (ref && !isSafeRelativeJsonRef(ref)) {
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
+1 -1
View File
@@ -37,7 +37,7 @@ Run distribution, dependency, log backfill, and client-manager requests use `cre
Client-manager lifecycle requests remain Platform-mediated. A plugin declaration does not grant access by itself: Platform rechecks the installed plugin, server owner/administrator scope, runtime binding, assigned Run endpoint capabilities, current distribution target/revision/key generation, and durable installation state before dispatching a typed job.
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, redacts, queues, and forwards it to Run. A management program is not host OS shell access. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
Production plugin lifecycle requests use `createProductionPluginLifecycleRequest`. Envelopes contain only plugin/server scope, enumerated operation, optional target version, confirmation, and idempotency key. Platform rechecks the manifest `productionLifecycle` declaration, dependency policy, disruptive approval, endpoint capacity, compatibility, and prior idempotency inputs before dispatch.
+14 -3
View File
@@ -211,7 +211,17 @@ export interface GamePluginRemoteAccess {
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired";
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "unknown" | "cancelled" | "expired";
export type GameClientBridgeProtectedRequestKind = "sql" | "rcon" | "program";
export interface GameClientBridgeProtectedRequestDeclaration {
kind: GameClientBridgeProtectedRequestKind;
transportKey: string;
targetKey: string;
textField: string;
maxTextBytes: number;
}
export interface GameClientBridgeCommandDeclaration {
type: string;
@@ -221,7 +231,8 @@ export interface GameClientBridgeCommandDeclaration {
payloadSchemaRef: string;
resultSchemaRef?: string;
timeoutSeconds: number;
maxPayloadBytes: number;
maxPayloadBytes: number;
protectedRequest?: GameClientBridgeProtectedRequestDeclaration;
}
export interface GameClientBridgeSnapshotDeclaration {
@@ -306,7 +317,7 @@ export interface GameClientBridgeStatus {
}
export interface GameClientBridgeCommandResult {
status: "succeeded" | "failed" | "cancelled";
status: "succeeded" | "failed" | "unknown" | "cancelled";
summary?: string;
payload?: Record<string, unknown>;
completedAt: string;
+35 -15
View File
@@ -26,6 +26,7 @@ import {
parseBridgeExecutionResponse,
parseAIInvocationResponse,
type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type RuntimeLogEventDeclaration,
@@ -173,17 +174,19 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
});
it("declares a fixed, schema-bound vehicle spawn instead of a raw command surface", () => {
it("declares bounded protected SQL and management request surfaces", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string }>; features: Array<{ key: string; requiredHandlers?: string[] }> } };
const command = manifest.gameClientBridge.commands.find((candidate) => candidate.type === "vehicle.spawn");
expect(command).toBeDefined();
expect(manifest.gameClientBridge.features.find((feature) => feature.key === "vehicle.spawn")?.requiredHandlers).toEqual(["vehicle.spawn"]);
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.payloadSchemaRef), "utf8"));
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.resultSchemaRef!), "utf8"));
expect(payload).toMatchObject({ additionalProperties: false, required: ["vehicleCode"], properties: { vehicleCode: { enum: ["BPC_Laika_C", "BPC_WolfsWagen_C"] } } });
expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/command|rcon|target|credential|socket|shell|sql/);
expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } });
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string; protectedRequest?: { kind: string; textField: string; transportKey: string; targetKey: string } }> } };
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
expect(commands.map((command) => command.protectedRequest?.kind)).toEqual(expect.arrayContaining(["sql", "rcon", "program"]));
for (const command of commands) {
expect(command.protectedRequest?.textField).toBe("requestText");
expect(command.protectedRequest?.transportKey).toBe(command.protectedRequest?.targetKey);
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command.payloadSchemaRef), "utf8"));
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command.resultSchemaRef!), "utf8"));
expect(payload).toMatchObject({ additionalProperties: false, required: ["requestText"] });
expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } });
}
});
it("declares a frozen SCUM install/adopt template with explicit mapping and verification checks", () => {
@@ -191,7 +194,7 @@ describe("plugin manifest validation", () => {
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(5);
expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(4);
});
it("rejects an SCUM template mapping an undeclared field", () => {
@@ -212,18 +215,22 @@ describe("plugin manifest validation", () => {
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
});
it("removes direct RCON, database, and DLL extension declarations", () => {
it("declares protected database and management transports without direct access", () => {
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
runtimeProfiles?: {
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>;
transportProfiles?: Array<{ kind?: string }>;
transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>;
};
};
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
expect(local?.capabilities).not.toContain("remote.run.rcon.command");
expect(local?.transportKeys).not.toContain("rcon");
expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.kind === "mysql" || profile.kind === "rcon")).toBe(false);
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: ["remote.run.protected.sql"] }),
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
]));
});
it("defines a generated SCUM companion config without inline proof or session material", () => {
@@ -588,7 +595,7 @@ describe("plugin manifest validation", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[];
runtimeProfiles?: {
logSources?: Array<{ key: string; retentionDays?: number }>;
logSources?: Array<{ key: string; kind?: string; streamKey?: string; retentionDays?: number }>;
logEvents?: Array<RuntimeLogEventDeclaration>;
};
};
@@ -597,6 +604,8 @@ describe("plugin manifest validation", () => {
const logEvents = manifest.runtimeProfiles?.logEvents ?? [];
expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes));
expect(logSources.get("scum-console-stdout")).toMatchObject({ kind: "process.stdout", streamKey: "scum.console.stdout" });
expect(logSources.get("scum-console-stderr")).toMatchObject({ kind: "process.stderr", streamKey: "scum.console.stderr" });
expect(new Set(logEvents.map((event) => event.key)).size).toBe(logEvents.length);
expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length);
for (const event of logEvents) {
@@ -949,6 +958,17 @@ describe("plugin SDK", () => {
expect(request).not.toHaveProperty("hostPath");
expect(request).not.toHaveProperty("dsn");
});
it("types protected request declarations while retaining text redaction boundaries", () => {
const declaration: GameClientBridgeProtectedRequestDeclaration = { kind: "sql", transportKey: "scum-database", targetKey: "scum-database", textField: "requestText", maxTextBytes: 4096 };
expect(declaration).toMatchObject({ kind: "sql", textField: "requestText" });
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/dsn|hostpath|socket|credential|password/);
const errors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.commands[0].type = "database.request";
manifest.gameClientBridge.commands[0].protectedRequest = { kind: "sql", transportKey: "missing", targetKey: "missing", textField: "requestText", maxTextBytes: 512 };
});
expect(errors.some((error) => error.includes("protectedRequest.transportKey"))).toBe(true);
});
it("checks declared bridge permissions", () => {
const context: PluginBridgeContext = {
pluginId: "game.example",