From 63022baa180de5ee866c1cc6e1fce804b8ba5bd6 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 29 Jul 2026 11:06:30 +0800 Subject: [PATCH] feat(scum): add bounded UE4SS notification adapter --- .../tasks.md | 2 +- .../companion/UE4SS_CAPABILITY.md | 9 +- .../scum-server-plugin/companion/adapters.go | 91 ++++++++++++++++++- .../companion/adapters_test.go | 56 ++++++++++++ 4 files changed, 148 insertions(+), 10 deletions(-) diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index 56c3dc6..75dfe84 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -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.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.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. ## 6. Migrate transitional platform behavior safely diff --git a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md index eb39dab..18a4e8d 100644 --- a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md +++ b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md @@ -11,10 +11,11 @@ The source implements a game-thread `SendChat "message" resolves to a real, currently online `ConZPlayerController` with a live `UNetConnection`; it fails closed when the reflected `MiscStatics:SendChatLineToPlayer` schema differs. This can support a -version-bound, typed `player.notify` adapter once the deployed Companion is -given a platform-authorized typed transport. The adapter must use one fixed -chat type, cannot accept arbitrary RCON text, and may place its generated -command text only in protected audit data. +version-bound, typed `player.notify` adapter when the deployed Companion is +given a platform-authorized typed transport. `VersionedAdapter` implements +that contract only for this exact source revision and UE4SS 3.0.1, with fixed +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 diff --git a/plugins/examples/scum-server-plugin/companion/adapters.go b/plugins/examples/scum-server-plugin/companion/adapters.go index fc76df5..376bb70 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters.go +++ b/plugins/examples/scum-server-plugin/companion/adapters.go @@ -5,6 +5,7 @@ import ( "fmt" "sort" "strings" + "unicode/utf8" ) // AuthorizedConfigPort is supplied by a version-bound Companion integration. @@ -19,10 +20,36 @@ type ConfigFieldPatch struct { 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 { - ServerVersion string - Config AuthorizedConfigPort - DiagnosticsState map[string]string + BoundServerID string + ServerVersion string + UE4SSBuild string + UE4SSReferenceRevision string + Config AuthorizedConfigPort + Notification UE4SSNotificationPort + DiagnosticsState map[string]string } func (adapter VersionedAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) { @@ -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) { return nil, fmt.Errorf("reward adapter is unsupported") } -func (VersionedAdapter) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) { - return nil, fmt.Errorf("notification adapter is unsupported") +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") + } + 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" } diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go index 1731d3f..aa10d1c 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_test.go @@ -3,6 +3,7 @@ package companion import ( "context" "testing" + "time" ) type configPortFixture struct { @@ -18,6 +19,16 @@ func (fixture *configPortFixture) ApplyConfigPatch(_ context.Context, _ string, 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) { 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"}} @@ -41,3 +52,48 @@ func TestVersionedAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *tes 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)) + } +}