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 3809c38..56c3dc6 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -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.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. -- [ ] 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 diff --git a/plugins/examples/scum-server-plugin/companion/README.md b/plugins/examples/scum-server-plugin/companion/README.md index 2349f88..d331567 100644 --- a/plugins/examples/scum-server-plugin/companion/README.md +++ b/plugins/examples/scum-server-plugin/companion/README.md @@ -1,5 +1,10 @@ # 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. 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. diff --git a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md new file mode 100644 index 0000000..eb39dab --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md @@ -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 "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. diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go b/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go new file mode 100644 index 0000000..d37f5e8 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go @@ -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) + } +} diff --git a/plugins/examples/scum-server-plugin/companion/events.go b/plugins/examples/scum-server-plugin/companion/events.go index 2bb99f9..4f45ac4 100644 --- a/plugins/examples/scum-server-plugin/companion/events.go +++ b/plugins/examples/scum-server-plugin/companion/events.go @@ -1,32 +1,17 @@ package companion -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "fmt" - "regexp" - "strings" - "time" -) +// SemanticEventProducerAvailability is intentionally fail-closed. The pinned +// UE4SS reference exposes command dispatch and online chat only; it does not +// expose a versioned server-side login, logout, position, vehicle, or network +// identity producer. Do not add a parser until such a source is versioned. +type SemanticEventProducerAvailability struct { + Available bool + Reason string +} -const semanticEventsSnapshotType = "semantic.events" -const semanticEventsSchemaVersion = "1" -const semanticEventsRetentionSeconds = 7 * 24 * 60 * 60 -const semanticEventsMaxRecords = 1000 - -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 VerifiedSemanticEventProducer() SemanticEventProducerAvailability { + return SemanticEventProducerAvailability{ + Available: false, + Reason: "no versioned SCUM server-side semantic event producer is installed", + } } -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}) } diff --git a/plugins/examples/scum-server-plugin/companion/events_test.go b/plugins/examples/scum-server-plugin/companion/events_test.go new file mode 100644 index 0000000..1f8d3f0 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/events_test.go @@ -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) + } +}