refactor(scum): declare protected run requests
This commit is contained in:
@@ -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`.
|
||||
|
||||
+23
-30
@@ -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
|
||||
|
||||
+17
-1
@@ -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).
|
||||
|
||||
@@ -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 = ©
|
||||
}
|
||||
}
|
||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 := ¬ificationPortFixture{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,
|
||||
|
||||
+7
@@ -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 } }
|
||||
}
|
||||
+7
@@ -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"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user