test(scum): verify companion dispatch boundaries
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
- [x] 3.1 Implement authenticated registration, bounded dispatch polling, acknowledgement, idempotent result completion, backoff, and typed diagnostics in the SCUM Companion.
|
- [x] 3.1 Implement authenticated registration, bounded dispatch polling, acknowledgement, idempotent result completion, backoff, and typed diagnostics in the SCUM Companion.
|
||||||
- [x] 3.2 Add a handler registry that validates declared schema, bound server, approval, server version, capability discovery, expiry, and idempotency before invoking an adapter.
|
- [x] 3.2 Add a handler registry that validates declared schema, bound server, approval, server version, capability discovery, expiry, and idempotency before invoking an adapter.
|
||||||
- [x] 3.3 Implement safe configuration read/patch and diagnostics adapters that use only platform-authorized channels and redact host paths, credentials, and raw command text.
|
- [x] 3.3 Implement safe configuration read/patch and diagnostics adapters that use only platform-authorized channels and redact host paths, credentials, and raw command text.
|
||||||
- [ ] 3.4 Add Companion integration tests for command claiming, duplicate delivery, cancellation/expiry, malformed payloads, unsupported versions, and redaction.
|
- [x] 3.4 Add Companion integration tests for command claiming, duplicate delivery, cancellation/expiry, malformed payloads, unsupported versions, and redaction.
|
||||||
|
|
||||||
## 4. Add verified SCUM data collectors
|
## 4. Add verified SCUM data collectors
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# SCUM Companion One-Shot Smoke
|
# SCUM Companion One-Shot Smoke
|
||||||
|
|
||||||
|
The currently pinned UE4SS reference does not provide semantic player or map
|
||||||
|
events. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the supported
|
||||||
|
`SendChat` evidence and the exact unavailable contracts; this Companion never
|
||||||
|
infers those events from arbitrary log lines.
|
||||||
|
|
||||||
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
|
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
|
||||||
|
|
||||||
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
|
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Pinned UE4SS capability evidence
|
||||||
|
|
||||||
|
This Companion has inspected the read-only reference repository at commit
|
||||||
|
`bae91527355f14faa63c1df65f742cc48594ba1b` (`scum_simple_rcon_ue4ss` v0.1.0,
|
||||||
|
verified build target RE-UE4SS 3.0.1).
|
||||||
|
|
||||||
|
## Verified capability
|
||||||
|
|
||||||
|
The source implements a game-thread `SendChat <type 0-7> "message"
|
||||||
|
[SteamID64]` path. A targeted send accepts only a 17-digit SteamID64 that
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Explicitly unavailable
|
||||||
|
|
||||||
|
The reference contains no versioned server-side producer or documented API for:
|
||||||
|
|
||||||
|
- successful player login/logout records;
|
||||||
|
- raw network identity/fingerprint values suitable for correlation;
|
||||||
|
- player or vehicle position, or player/vehicle transitions;
|
||||||
|
- item/reward delivery; or
|
||||||
|
- skill/attribute read, safe-window checking, or mutation.
|
||||||
|
|
||||||
|
Therefore the Companion must not parse invented `LOGIN`/`LOGOUT` lines, upload
|
||||||
|
semantic events, correlate network identifiers, or claim trajectory, reward,
|
||||||
|
or game-state-patch support from this reference. The missing contract is a
|
||||||
|
version-pinned UE4SS extension/API that defines the event or operation schema,
|
||||||
|
identity binding, acknowledgement/result semantics, and non-production
|
||||||
|
integration fixture for each capability.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This exercises the bounded claim/ack/complete boundary as one flow. The
|
||||||
|
// fixture deliberately mixes replay, expired, malformed, unsupported, and
|
||||||
|
// redaction-sensitive commands so none can fall through to an adapter.
|
||||||
|
func TestDispatcherIntegrationContainsUnsafeAndUnavailableCommands(t *testing.T) {
|
||||||
|
stamp := time.Now().UTC()
|
||||||
|
adapter := &adapterFixture{}
|
||||||
|
registry := NewHandlerRegistry(HandlerAvailability{
|
||||||
|
BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true,
|
||||||
|
Capabilities: map[string]bool{"config.read": true},
|
||||||
|
}, adapter)
|
||||||
|
fixture := &dispatchFixture{commands: []ClaimedCommand{
|
||||||
|
{ID: "safe-read", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
|
||||||
|
{ID: "safe-read", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
|
||||||
|
{ID: "cancelled-before-claim", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 2, LeaseExpiresAt: stamp.Add(-time.Second), ExpiresAt: stamp.Add(-time.Second)},
|
||||||
|
{ID: "malformed", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 3, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
|
||||||
|
{ID: "unsupported-version", ProfileKey: ProfileKey, CommandType: "player.notify", Payload: map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}, FencingToken: 4, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
|
||||||
|
}}
|
||||||
|
|
||||||
|
dispatcher := Dispatcher{Client: fixture, Registry: registry, Now: func() time.Time { return stamp }}
|
||||||
|
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
|
||||||
|
t.Fatalf("dispatch integration: %v", err)
|
||||||
|
}
|
||||||
|
if adapter.reads != 1 {
|
||||||
|
t.Fatalf("duplicate delivery invoked the adapter %d times", adapter.reads)
|
||||||
|
}
|
||||||
|
if len(fixture.acks) != 3 || fixture.acks[0] != "safe-read" || fixture.acks[1] != "safe-read" || fixture.acks[2] != "unsupported-version" {
|
||||||
|
t.Fatalf("only live, validated commands may be acknowledged: %v", fixture.acks)
|
||||||
|
}
|
||||||
|
if len(fixture.completed) != 5 {
|
||||||
|
t.Fatalf("every claimed command needs a terminal result: %+v", fixture.completed)
|
||||||
|
}
|
||||||
|
if fixture.completed[0].Payload["hostPath"] != nil || fixture.completed[1].Payload["hostPath"] != nil {
|
||||||
|
t.Fatalf("adapter output leaked protected details: %+v", fixture.completed[:2])
|
||||||
|
}
|
||||||
|
if fixture.completed[2].Payload["result"] != "validation-failed" || fixture.completed[3].Payload["result"] != "validation-failed" || fixture.completed[4].Payload["result"] != "unsupported" {
|
||||||
|
t.Fatalf("terminal failure classification is unsafe: %+v", fixture.completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,17 @@
|
|||||||
package companion
|
package companion
|
||||||
|
|
||||||
import (
|
// SemanticEventProducerAvailability is intentionally fail-closed. The pinned
|
||||||
"context"
|
// UE4SS reference exposes command dispatch and online chat only; it does not
|
||||||
"crypto/hmac"
|
// expose a versioned server-side login, logout, position, vehicle, or network
|
||||||
"crypto/sha256"
|
// identity producer. Do not add a parser until such a source is versioned.
|
||||||
"encoding/hex"
|
type SemanticEventProducerAvailability struct {
|
||||||
"fmt"
|
Available bool
|
||||||
"regexp"
|
Reason string
|
||||||
"strings"
|
}
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const semanticEventsSnapshotType = "semantic.events"
|
func VerifiedSemanticEventProducer() SemanticEventProducerAvailability {
|
||||||
const semanticEventsSchemaVersion = "1"
|
return SemanticEventProducerAvailability{
|
||||||
const semanticEventsRetentionSeconds = 7 * 24 * 60 * 60
|
Available: false,
|
||||||
const semanticEventsMaxRecords = 1000
|
Reason: "no versioned SCUM server-side semantic event producer is installed",
|
||||||
|
}
|
||||||
type SemanticEvent struct { Type string `json:"type"`; OccurredAt time.Time `json:"occurredAt"`; PlayerID string `json:"playerId,omitempty"`; DisplayName string `json:"displayName,omitempty"`; NetworkCorrelation string `json:"networkCorrelation,omitempty"` }
|
|
||||||
var supportedLoginLine = regexp.MustCompile(`^LOGIN player=([A-Za-z0-9._:-]{1,96}) name=([^\n]{1,80}) at=([0-9TZ:+.-]{20,40})(?: network=([^\s]{1,128}))?$`)
|
|
||||||
var supportedLogoutLine = regexp.MustCompile(`^LOGOUT player=([A-Za-z0-9._:-]{1,96}) at=([0-9TZ:+.-]{20,40})$`)
|
|
||||||
|
|
||||||
// ParseSemanticEvent supports only versioned, allow-listed extension output.
|
|
||||||
// Unknown formats deliberately yield no event and may be reported as a bounded
|
|
||||||
// diagnostic by the caller.
|
|
||||||
func ParseSemanticEvent(line string, serverCorrelationKey []byte) (SemanticEvent, bool) {
|
|
||||||
if match := supportedLoginLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[3]); if err != nil { return SemanticEvent{}, false }; event := SemanticEvent{Type: "scum.login", PlayerID: match[1], DisplayName: match[2], OccurredAt: occurred}; if match[4] != "" { event.NetworkCorrelation = irreversibleServerCorrelation(serverCorrelationKey, match[4]) }; return event, true }
|
|
||||||
if match := supportedLogoutLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[2]); if err != nil { return SemanticEvent{}, false }; return SemanticEvent{Type: "scum.logout", PlayerID: match[1], OccurredAt: occurred}, true }
|
|
||||||
return SemanticEvent{}, false
|
|
||||||
}
|
}
|
||||||
func irreversibleServerCorrelation(key []byte, source string) string { if len(key) == 0 || source == "" { return "" }; mac := hmac.New(sha256.New, key); _, _ = mac.Write([]byte(source)); return hex.EncodeToString(mac.Sum(nil)) }
|
|
||||||
func (client *Client) UploadSemanticEvents(ctx context.Context, streamKey string, sequence uint64, events []SemanticEvent) (AcceptedSnapshot, error) { if len(events) == 0 || len(events) > 100 { return AcceptedSnapshot{}, fmt.Errorf("semantic event batch is invalid") }; payload := map[string]any{"events": events}; return client.UploadSnapshot(ctx, Snapshot{Type: semanticEventsSnapshotType, SchemaVersion: semanticEventsSchemaVersion, StreamKey: streamKey, Sequence: sequence, ObservedAt: client.now().UTC(), Payload: payload, KeepForSeconds: semanticEventsRetentionSeconds, MaxRecords: semanticEventsMaxRecords}) }
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package companion
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVerifiedSemanticEventProducerFailsClosedWithoutASource(t *testing.T) {
|
||||||
|
availability := VerifiedSemanticEventProducer()
|
||||||
|
if availability.Available || availability.Reason == "" {
|
||||||
|
t.Fatalf("semantic events must remain unavailable without a versioned source: %+v", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user