feat(scum): add bounded UE4SS notification adapter

This commit is contained in:
npc0-hue
2026-07-29 11:06:30 +08:00
parent 32015a4d9b
commit 63022baa18
4 changed files with 148 additions and 10 deletions
@@ -31,7 +31,7 @@
- [ ] 5.1 Implement a version-discovered `game-state.patch` adapter for only documented supported skill/attribute fields, including precondition read, safe-window verification, read-after-write confirmation, and typed old/new/result audit data. - [ ] 5.1 Implement a version-discovered `game-state.patch` adapter for only documented supported skill/attribute fields, including precondition read, safe-window verification, read-after-write confirmation, and typed old/new/result audit data.
- [ ] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion. - [ ] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion.
- [ ] 5.3 Implement a `reward.deliver` adapter that freezes the approved revision, performs idempotent delivery, and reports delivered/failed/unknown without automatically retrying unknown results. - [ ] 5.3 Implement a `reward.deliver` adapter that freezes the approved revision, performs idempotent delivery, and reports delivered/failed/unknown without automatically retrying unknown results.
- [ ] 5.4 Implement a separate `player.notify` adapter that never repeats item delivery after notification failure; verify server-scoped recipient identity and redact message transport details. - [x] 5.4 Implement a separate `player.notify` adapter that never repeats item delivery after notification failure; verify server-scoped recipient identity and redact message transport details.
- [ ] 5.5 Add isolated non-production end-to-end tests for every supported adapter and ensure no raw SQL, unrestricted RCON, OCR, screenshots, keyboard/mouse injection, or direct game database write path exists. - [ ] 5.5 Add isolated non-production end-to-end tests for every supported adapter and ensure no raw SQL, unrestricted RCON, OCR, screenshots, keyboard/mouse injection, or direct game database write path exists.
## 6. Migrate transitional platform behavior safely ## 6. Migrate transitional platform behavior safely
@@ -11,10 +11,11 @@ The source implements a game-thread `SendChat <type 0-7> "message"
resolves to a real, currently online `ConZPlayerController` with a live resolves to a real, currently online `ConZPlayerController` with a live
`UNetConnection`; it fails closed when the reflected `UNetConnection`; it fails closed when the reflected
`MiscStatics:SendChatLineToPlayer` schema differs. This can support a `MiscStatics:SendChatLineToPlayer` schema differs. This can support a
version-bound, typed `player.notify` adapter once the deployed Companion is version-bound, typed `player.notify` adapter when the deployed Companion is
given a platform-authorized typed transport. The adapter must use one fixed given a platform-authorized typed transport. `VersionedAdapter` implements
chat type, cannot accept arbitrary RCON text, and may place its generated that contract only for this exact source revision and UE4SS 3.0.1, with fixed
command text only in protected audit data. chat type `4`; it cannot accept arbitrary RCON text. Its generated command
text is private transport/audit data and never appears in a command result.
## Explicitly unavailable ## Explicitly unavailable
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"unicode/utf8"
) )
// AuthorizedConfigPort is supplied by a version-bound Companion integration. // AuthorizedConfigPort is supplied by a version-bound Companion integration.
@@ -19,9 +20,35 @@ type ConfigFieldPatch struct {
Value string Value string
} }
const (
UE4SSReferenceRevision = "bae91527355f14faa63c1df65f742cc48594ba1b"
UE4SSReferenceBuild = "3.0.1"
fixedNotificationType = 4
)
// UE4SSNotificationPort is implemented only by a Companion-local,
// platform-authorized transport for the pinned UE4SS build. It receives a
// fixed typed notification, never a raw RCON command, credential, socket, or
// host path. Its private audit text is not part of command result payloads.
type UE4SSNotificationPort interface {
SendPlayerNotification(context.Context, ue4SSPlayerNotification) (UE4SSNotificationReceipt, error)
}
type UE4SSNotificationReceipt struct{ Accepted bool }
type ue4SSPlayerNotification struct {
ServerID string
RecipientSteamID string
Message string
chatType int
protectedAuditCommand string
}
type VersionedAdapter struct { type VersionedAdapter struct {
BoundServerID string
ServerVersion string ServerVersion string
UE4SSBuild string
UE4SSReferenceRevision string
Config AuthorizedConfigPort Config AuthorizedConfigPort
Notification UE4SSNotificationPort
DiagnosticsState map[string]string DiagnosticsState map[string]string
} }
@@ -75,9 +102,63 @@ func (VersionedAdapter) PatchGameState(context.Context, map[string]any) (map[str
func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[string]any, error) { func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
return nil, fmt.Errorf("reward adapter is unsupported") return nil, fmt.Errorf("reward adapter is unsupported")
} }
func (VersionedAdapter) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) { func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if !adapter.supportsUE4SSNotification() || adapter.Notification == nil {
return nil, fmt.Errorf("notification adapter is unsupported") return nil, fmt.Errorf("notification adapter is unsupported")
} }
playerID, playerOK := payload["playerId"].(string)
message, messageOK := payload["message"].(string)
notification, err := newUE4SSPlayerNotification(adapter.BoundServerID, playerID, message)
if !playerOK || !messageOK || err != nil {
return nil, fmt.Errorf("notification payload is invalid")
}
receipt, err := adapter.Notification.SendPlayerNotification(ctx, notification)
if err != nil {
return nil, fmt.Errorf("notification transport failed")
}
if !receipt.Accepted {
return map[string]any{"accepted": false, "message": "notification was not accepted"}, nil
}
return map[string]any{"accepted": true, "message": "notification accepted for online recipient"}, nil
}
func (adapter VersionedAdapter) supportsUE4SSNotification() bool {
// The pinned source reflects SendChatLineToPlayer at runtime and fails
// closed on a schema change, so no unverified SCUM-version mapping is
// embedded here. The dispatcher still requires a discovered server version.
return adapter.BoundServerID != "" && adapter.UE4SSBuild == UE4SSReferenceBuild && adapter.UE4SSReferenceRevision == UE4SSReferenceRevision
}
func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayerNotification, error) {
if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) {
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed UE4SS notification")
}
return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, protectedAuditCommand: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil
}
func steamID64(value string) bool {
if len(value) != 17 {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func validNotificationMessage(value string) bool {
if value == "" || len(value) > 200 || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func escapeUE4SSChatMessage(value string) string {
return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value)
}
func supportedAdapterVersion(version string) bool { return version == "0.9.700.90357" } func supportedAdapterVersion(version string) bool { return version == "0.9.700.90357" }
func supportedConfigKey(key string) bool { func supportedConfigKey(key string) bool {
@@ -3,6 +3,7 @@ package companion
import ( import (
"context" "context"
"testing" "testing"
"time"
) )
type configPortFixture struct { type configPortFixture struct {
@@ -18,6 +19,16 @@ func (fixture *configPortFixture) ApplyConfigPatch(_ context.Context, _ string,
return map[string]string{"ServerName": "Moon", "hostPath": "C:/secret"}, nil return map[string]string{"ServerName": "Moon", "hostPath": "C:/secret"}, nil
} }
type notificationPortFixture struct {
deliveries []ue4SSPlayerNotification
accepted bool
}
func (fixture *notificationPortFixture) SendPlayerNotification(_ context.Context, notification ue4SSPlayerNotification) (UE4SSNotificationReceipt, error) {
fixture.deliveries = append(fixture.deliveries, notification)
return UE4SSNotificationReceipt{Accepted: fixture.accepted}, nil
}
func TestVersionedAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testing.T) { func TestVersionedAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testing.T) {
port := &configPortFixture{fields: map[string]string{"ServerName": "Moon", "hostPath": "C:/secret", "Password": "nope"}} port := &configPortFixture{fields: map[string]string{"ServerName": "Moon", "hostPath": "C:/secret", "Password": "nope"}}
adapter := VersionedAdapter{ServerVersion: "0.9.700.90357", Config: port, DiagnosticsState: map[string]string{"status": "healthy", "hostPath": "C:/secret"}} adapter := VersionedAdapter{ServerVersion: "0.9.700.90357", Config: port, DiagnosticsState: map[string]string{"status": "healthy", "hostPath": "C:/secret"}}
@@ -41,3 +52,48 @@ func TestVersionedAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *tes
t.Fatalf("diagnostics leaked unsafe details: %+v", diagnostics) t.Fatalf("diagnostics leaked unsafe details: %+v", diagnostics)
} }
} }
func TestVersionedUE4SSNotificationIsFixedTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, Notification: port}
result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""})
if err != nil || !result["accepted"].(bool) || len(port.deliveries) != 1 {
t.Fatalf("typed notification was not delivered: result=%+v err=%v deliveries=%+v", result, err, port.deliveries)
}
delivery := port.deliveries[0]
if delivery.ServerID != "server-1" || delivery.chatType != fixedNotificationType || delivery.protectedAuditCommand != "SendChat 4 \"Moon \\\"gift\\\"\" 76561198000000001" {
t.Fatalf("notification did not use the fixed UE4SS contract: %+v", delivery)
}
if result["message"] == delivery.protectedAuditCommand || result["command"] != nil || result["rcon"] != nil {
t.Fatalf("notification leaked protected transport details: %+v", result)
}
}
func TestVersionedUE4SSNotificationFailsClosedForUnpinnedBuildOrInvalidRecipient(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: "3.0.2", UE4SSReferenceRevision: UE4SSReferenceRevision, Notification: port}
if _, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}); err == nil {
t.Fatal("unpinned UE4SS build must be unavailable")
}
adapter.UE4SSBuild = UE4SSReferenceBuild
if _, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "not-a-steam-id", "message": "Moonlight"}); err == nil {
t.Fatal("unverified recipient identity must be rejected")
}
}
func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T) {
stamp := time.Now().UTC()
port := &notificationPortFixture{accepted: false}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, Notification: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"player.notify": true}}, adapter)
command := ClaimedCommand{ID: "notification-1", ProfileKey: ProfileKey, CommandType: "player.notify", Payload: map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
for range 2 {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Payload["accepted"] != false {
t.Fatalf("notification failure was not typed: result=%+v err=%v", result, err)
}
}
if len(port.deliveries) != 1 {
t.Fatalf("duplicate notification attempted transport %d times", len(port.deliveries))
}
}