feat(scum): add bounded UE4SS notification adapter
This commit is contained in:
@@ -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,8 +102,62 @@ 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" }
|
||||||
|
|||||||
@@ -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 := ¬ificationPortFixture{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 := ¬ificationPortFixture{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 := ¬ificationPortFixture{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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user