From a82f1ff01a5cc3e4834ce45034a5c094f4b7df55 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 2 Sep 2026 17:56:49 +0800 Subject: [PATCH] Remove SCUM server-management client path --- AGENTS.md | 12 +- platform/AGENTS.md | 2 +- platform/api/resource_handlers_test.go | 10 +- platform/service/resources.go | 29 + platform/service/source_rcon_test.go | 76 ++- platform_web/README.md | 5 +- platform_web/api/client.test.ts | 30 +- .../api/clientManagerLifecycle.test.ts | 18 +- .../pages/PluginPageHostPage.test.tsx | 12 +- platform_web/pages/PluginPageHostPage.tsx | 6 +- platform_web/pages/ServersPage.tsx | 73 +-- plugins/README.md | 16 +- .../scum-server-plugin/companion/README.md | 43 -- .../companion/UE4SS_CAPABILITY.md | 17 - .../scum-server-plugin/companion/adapters.go | 565 ------------------ .../companion/adapters_e2e_test.go | 229 ------- .../companion/adapters_test.go | 288 --------- .../scum-server-plugin/companion/client.go | 516 ---------------- .../companion/client_test.go | 519 ---------------- .../cmd/scum-companion-smoke/main.go | 51 -- .../companion/cmd/scum-companion/main.go | 72 --- .../scum-server-plugin/companion/config.go | 179 ------ .../companion/config.yaml.example | 33 - .../companion/config_test.go | 16 - .../companion/console_collector.go | 118 ---- .../companion/dispatcher.go | 408 ------------- .../companion/dispatcher_integration_test.go | 46 -- .../companion/dispatcher_test.go | 115 ---- .../scum-server-plugin/companion/events.go | 120 ---- .../scum-server-plugin/companion/go.mod | 5 - .../scum-server-plugin/companion/go.sum | 4 - .../companion/log_stream.go | 124 ---- .../companion/orchestration.go | 166 ----- .../companion/orchestration_test.go | 213 ------- .../companion/player_projection.go | 122 ---- .../companion/player_projection_test.go | 99 --- .../companion/snapshot_sequences.go | 166 ----- .../companion/snapshot_sequences_test.go | 31 - .../scum-server-plugin/features/api.ts | 23 - .../scum-server-plugin/features/migration.ts | 4 +- .../scum-server-plugin/features/page-data.ts | 108 ++-- .../scum-server-plugin/features/page.ts | 12 +- .../scum-server-plugin/features/schemas.ts | 4 +- .../examples/scum-server-plugin/manifest.json | 157 +---- .../companion-health.snapshot.schema.json | 37 -- .../bridge/diagnostics.payload.schema.json | 16 - .../bridge/diagnostics.result.schema.json | 24 - .../maintenance-prepare.payload.schema.json | 2 +- .../companion/config.generated.example.json | 42 -- .../schemas/companion/config.schema.json | 97 --- plugins/tests/manifest-validation.test.ts | 227 +------ plugins/tests/scum-feature-module.test.ts | 69 +-- 52 files changed, 245 insertions(+), 5131 deletions(-) delete mode 100644 plugins/examples/scum-server-plugin/companion/README.md delete mode 100644 plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md delete mode 100644 plugins/examples/scum-server-plugin/companion/adapters.go delete mode 100644 plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/adapters_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/client.go delete mode 100644 plugins/examples/scum-server-plugin/companion/client_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/cmd/scum-companion-smoke/main.go delete mode 100644 plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go delete mode 100644 plugins/examples/scum-server-plugin/companion/config.go delete mode 100644 plugins/examples/scum-server-plugin/companion/config.yaml.example delete mode 100644 plugins/examples/scum-server-plugin/companion/config_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/console_collector.go delete mode 100644 plugins/examples/scum-server-plugin/companion/dispatcher.go delete mode 100644 plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/dispatcher_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/events.go delete mode 100644 plugins/examples/scum-server-plugin/companion/go.mod delete mode 100644 plugins/examples/scum-server-plugin/companion/go.sum delete mode 100644 plugins/examples/scum-server-plugin/companion/log_stream.go delete mode 100644 plugins/examples/scum-server-plugin/companion/orchestration.go delete mode 100644 plugins/examples/scum-server-plugin/companion/orchestration_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/player_projection.go delete mode 100644 plugins/examples/scum-server-plugin/companion/player_projection_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/snapshot_sequences.go delete mode 100644 plugins/examples/scum-server-plugin/companion/snapshot_sequences_test.go delete mode 100644 plugins/examples/scum-server-plugin/features/api.ts delete mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/companion-health.snapshot.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.payload.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.result.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/companion/config.schema.json diff --git a/AGENTS.md b/AGENTS.md index 29d4cdf..4088ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,14 +27,6 @@ Before 1.0, prioritize making the actual user-facing flows work fully. Remove or - `scum.npc0.com` is NAT-mapped to the local frontend at `http://127.0.0.1:5173/`. - GPT must ignore the `.local-debug/` directory when searching, inspecting, or modifying repository files. -## Server Management MCP Notes - -- For remote server work, use the `server-management` MCP path, not raw SSH. Start with `list_devices`, then `test_connection`, then `ssh_exec`; the configured 枣庄 device is named `枣庄服务器` with id `FyBDIohqPhRx7Cia`. -- If native MCP tool wrappers return `unsupported call`, do not keep retrying the same wrapper. Read the server-management plugin MCP config and call the local MCP JSON-RPC gateway (`initialize`, `tools/list`, `tools/call`) from a small Node script; never print or commit MCP headers, tokens, host credentials, or raw `.mcp.json` contents. -- If local access to the MCP gateway fails with `EPERM` on `127.0.0.1`, request sandbox escalation for that MCP gateway call instead of falling back to raw SSH. -- On Windows targets, assume `ssh_exec` may run under `cmd`; for PowerShell diagnostics, send `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ` to avoid quoting/glob problems. -- For SCUM current-service discovery, run bounded read-only scripts on `枣庄服务器` itself and return bounded typed results. Do not download or parse `SCUM.db` on the platform/plugin/browser side; product evidence must flow through Platform durable jobs and Run typed envelopes. Do not apply game-specific redaction to plugin-declared result fields or player records. Logs are opaque terminal data: Run and Platform must not inspect, parse, filter, redact, normalize, transform, correlate, or derive player/plugin records from stdout, stderr, or declared file-tail payloads. They only add transport envelope metadata required for sequencing, integrity, persistence, relay, and display, and retain the log body verbatim. The SCUM plugin companion may consume its declared raw stream, parse it, and create or update its own typed users and business records; this plugin-owned work is never repeated or redacted by Platform or Run. - The machine-side run executor lives in the independent repository `git@git.npc0.com:admin343/run.git`; do not re-add a `run/` source tree to this repository. Do not place implementation code outside the matching root. Shared contracts must be generated or copied through explicit contract packages, not imported by reaching across ownership boundaries casually. @@ -95,7 +87,9 @@ Platform, plugin, and run lifecycle ownership must stay separated. Run is the li - Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands. - Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport. -Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state. Run must preserve plugin-declared game records without per-game content redaction. Supervised stdout/stderr and declared file tails are opaque verbatim channels: Run and Platform only persist, relay, or display them; they never inspect, content-filter, redact, normalize, correlate, or use them to produce player/plugin data. A plugin companion may parse its own declared raw stream and publish typed plugin-owned records through the component boundary. This does not grant plugins or the browser a direct host-path, credential, or socket API outside the log channel. +Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state. Run must preserve plugin-declared game records without per-game content redaction. Supervised stdout/stderr and declared file tails are opaque verbatim channels: Run and Platform only persist, relay, or display them; they never inspect, content-filter, redact, normalize, correlate, or use them to produce player/plugin data. A plugin-owned component may parse its own declared raw stream and publish typed plugin-owned records through the component boundary. This does not grant plugins or the browser a direct host-path, credential, or socket API outside the log channel. + +SCUM current-service and player evidence must flow through Platform durable jobs, Run typed envelopes, or plugin-owned typed bridge records. Do not download or parse `SCUM.db` on the platform/plugin/browser side, and do not apply game-specific redaction to plugin-declared result fields, player records, stdout/stderr, or declared file-tail payloads. Do not hardcode game-specific deployment behavior in run or platform services. Values such as `SCUMServer.exe`, Steam app `3792580`, `steamcmd +app_update`, SCUM install directories, `-port`, `-MaxPlayers`, or `-log` belong in the SCUM plugin's manifests, action specs, templates, or scripts. diff --git a/platform/AGENTS.md b/platform/AGENTS.md index 0e3da7f..dcf9bba 100644 --- a/platform/AGENTS.md +++ b/platform/AGENTS.md @@ -29,7 +29,7 @@ Platform may read plugin manifests, validate lifecycle/action declarations, pack Platform may persist desired lifecycle state, last-known run reports, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels. -Log bodies from supervised stdout/stderr and plugin-declared file tails are opaque verbatim payloads. Platform may validate the transport envelope, sequence, checksum, and source binding, then persist, relay, and display the body unchanged. It must not inspect, parse, filter, redact, normalize, correlate, or derive player, login, user, or other plugin records from a log body. A plugin companion owns any game-specific parsing and its typed records arrive through the component boundary, never as a Platform log projection. +Log bodies from supervised stdout/stderr and plugin-declared file tails are opaque verbatim payloads. Platform may validate the transport envelope, sequence, checksum, and source binding, then persist, relay, and display the body unchanged. It must not inspect, parse, filter, redact, normalize, correlate, or derive player, login, user, or other plugin records from a log body. A plugin-owned component owns any game-specific parsing and its typed records arrive through the component boundary, never as a Platform log projection. Do not add platform service code that hardcodes a game's executable path, Steam app ID, SteamCMD command line, process name, default launch flags, or update policy. For SCUM specifically, `SCUMServer.exe`, app `3792580`, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` must come from the SCUM plugin action assets or plugin-declared startup fields. diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index c3afe37..8680da2 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -760,7 +760,7 @@ func TestServerInstanceManagementAPI(t *testing.T) { postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ - ID: "server-management", + ID: "server-ops", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Ops", @@ -768,7 +768,7 @@ func TestServerInstanceManagementAPI(t *testing.T) { }, adminSession) newName := "SCUM Ops Renamed" - updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-management", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession) + updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-ops", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession) if updated.Name != newName || updated.PluginID != ready.PluginID || updated.RunEndpointID != ready.RunEndpointID { t.Fatalf("unexpected server update: %+v", updated) } @@ -783,16 +783,16 @@ func TestServerInstanceManagementAPI(t *testing.T) { unsafeDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession) assertErrorResponse(t, unsafeDelete, http.StatusBadRequest, errorCodeValidation) - deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession) + deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-ops", mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession) assertStatus(t, deleted, http.StatusNoContent) activeList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession) for _, item := range activeList.Items { - if item.ID == "server-management" { + if item.ID == "server-ops" { t.Fatalf("deleted server should be hidden from normal list: %+v", activeList) } } deletedList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?state=deleted", adminSession) - if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted { + if deletedList.Count != 1 || deletedList.Items[0].ID != "server-ops" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted { t.Fatalf("expected explicit deleted filter to return deleted server, got %+v", deletedList) } forcedDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local", Force: true, Confirmation: service.ServerDeletionForceConfirmation}), adminSession) diff --git a/platform/service/resources.go b/platform/service/resources.go index 4bcaf0c..f6ed8b5 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -1215,6 +1215,9 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base inputs[strings.TrimPrefix(key, "input.")] = value } } + if capability == domain.JobCapabilityRemoteRunRCONCommand && strings.TrimSpace(inputs["command"]) != "" { + return svc.executeBridgeSourceRCONCommand(sessionID, base, payload, inputs) + } if capability == domain.JobCapabilityRemoteRunDBSQLiteQuery { templateKey := strings.TrimSpace(inputs["templateKey"]) if templateKey == "" { @@ -1274,6 +1277,32 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base return base } +func (svc *CoreService) executeBridgeSourceRCONCommand(sessionID string, base domain.PluginBridgeExecuteResponse, payload map[string]string, inputs map[string]string) domain.PluginBridgeExecuteResponse { + dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, domain.SourceRCONCommandRequest{ + ServerInstanceID: base.ServerInstanceID, + Kind: domain.SourceRCONCommandKindCommand, + Command: inputs["command"], + IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), + }) + if err != nil { + return bridgeExecutionError(base, err) + } + targetKey := strings.TrimSpace(payload["targetKey"]) + if job, getErr := svc.store.Jobs().Get(dispatch.JobID); getErr == nil && strings.TrimSpace(job.TargetKey) != "" { + targetKey = job.TargetKey + } + base.Status = "queued" + base.Result = map[string]string{ + "jobId": dispatch.JobID, + "state": dispatch.Status, + "capability": domain.JobCapabilityRemoteRunRCONCommand, + "targetKey": targetKey, + "serverInstanceId": dispatch.ServerInstanceID, + "adapterKind": string(domain.RemoteAdapterRCON), + } + return base +} + func findBridgeQueryTemplate(plugin domain.GamePlugin, routeKey string, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, string) { pageFound := false pageAllowsTemplate := false diff --git a/platform/service/source_rcon_test.go b/platform/service/source_rcon_test.go index 0106e16..88ba099 100644 --- a/platform/service/source_rcon_test.go +++ b/platform/service/source_rcon_test.go @@ -11,7 +11,7 @@ import ( "browser.local/platform/repo" ) -func TestSourceRCONDispatchUsesOneTimeRedactedInput(t *testing.T) { +func TestSourceRCONDispatchUsesOneTimeOpaqueInput(t *testing.T) { svc, session, runSession, instance := newSourceRCONFixture(t) request := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindChat, ChatType: 4, Message: `Bounty "claimed"`, TargetSteamID: "76561198000000001", IdempotencyKey: "rcon-chat-1"} dispatch, err := svc.DispatchSourceRCONCommandForSession(session, request) @@ -150,14 +150,70 @@ func TestSourceRCONDispatchSelectsDeclaredProfileWithoutManualBinding(t *testing } } -func TestSourceRCONDispatchFallsBackFromCustomClientBinding(t *testing.T) { +func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) { + svc, session, runSession, instance := newSourceRCONFixture(t) + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + t.Fatal(err) + } + plugin.BridgeActions = []string{string(domain.PluginBridgeActionRemoteAccessRequest)} + plugin.Pages = []domain.GamePluginPage{{Key: "remote", Title: "Remote", Path: "/remote", Permissions: []string{"server.remote.access"}, BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)}}} + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update bridge-capable RCON plugin: %v", err) + } + + response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{ + RequestID: "bridge-rcon-1", + PluginID: plugin.ID, + RouteKey: "remote", + ServerInstanceID: instance.ID, + Action: domain.PluginBridgeActionRemoteAccessRequest, + Payload: map[string]string{ + "capability": domain.JobCapabilityRemoteRunRCONCommand, + "declarationKey": "rcon", + "targetKey": "rcon", + "idempotencyKey": "bridge-rcon-1", + "input.command": "#ListPlayers", + }, + }) + if err != nil { + t.Fatalf("execute bridge RCON command: %v", err) + } + if response.Status != "queued" || response.Result["adapterKind"] != string(domain.RemoteAdapterRCON) || response.Result["targetKey"] != "rcon" || response.Result["capability"] != domain.JobCapabilityRemoteRunRCONCommand { + t.Fatalf("expected queued source RCON bridge response, got %+v", response) + } + job, err := svc.store.Jobs().Get(response.Result["jobId"]) + if err != nil { + t.Fatalf("get bridge RCON job: %v", err) + } + if job.ExecutionInput.SourceRCON == nil || !strings.HasPrefix(job.InputRef, "input://source-rcon/") || len(job.ExecutionInput.Inputs) != 0 { + t.Fatalf("expected source RCON one-time input job, got %+v", job) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID { + t.Fatalf("claim bridge RCON job: claim=%+v err=%v", claim, err) + } + ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) + if err != nil || !ack.Accepted { + t.Fatalf("ack bridge RCON job: ack=%+v err=%v", ack, err) + } + input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}) + if err != nil { + t.Fatalf("consume bridge RCON input: %v", err) + } + if input.Command != "#ListPlayers" { + t.Fatalf("expected bridge RCON command to remain verbatim, got %q", input.Command) + } +} + +func TestSourceRCONDispatchFallsBackFromIncompatibleLegacyBinding(t *testing.T) { svc, session, _, instance := newSourceRCONFixture(t) plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { t.Fatal(err) } plugin.RuntimeProfiles.LifecycleProfiles[0].Key = "run-local" - plugin.RuntimeProfiles.LifecycleProfiles = append(plugin.RuntimeProfiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: "scum-client", Mode: "custom-client", Capabilities: []string{"client-manager.deploy", "client-manager.control", "logs.read"}, ClientManagerRef: "scum-client-manager", Platforms: []string{"windows"}}) + plugin.RuntimeProfiles.LifecycleProfiles = append(plugin.RuntimeProfiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: "legacy-local", Mode: "local-process", Capabilities: []string{"logs.read"}, Platforms: []string{"windows"}}) if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("update plugin profiles: %v", err) } @@ -165,23 +221,23 @@ func TestSourceRCONDispatchFallsBackFromCustomClientBinding(t *testing.T) { if err != nil { t.Fatalf("get runtime binding: %v", err) } - binding.ProfileKey = "scum-client" - binding.Mode = "custom-client" - binding.Bindings = map[string]string{"scum-client-manager": "runtime-client-manager"} + binding.ProfileKey = "legacy-local" + binding.Mode = "local-process" + binding.Bindings = map[string]string{"logs": "runtime-logs"} if err := svc.store.RuntimeBindings().Update(binding); err != nil { - t.Fatalf("point binding at custom client profile: %v", err) + t.Fatalf("point binding at incompatible profile: %v", err) } - dispatch, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "#ListPlayers", IdempotencyKey: "rcon-custom-client-binding"}) + dispatch, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "#ListPlayers", IdempotencyKey: "rcon-incompatible-binding"}) if err != nil { - t.Fatalf("dispatch RCON with custom-client binding fallback: %v", err) + t.Fatalf("dispatch RCON with incompatible binding fallback: %v", err) } job, err := svc.store.Jobs().Get(dispatch.JobID) if err != nil { t.Fatalf("get RCON job: %v", err) } if job.ExecutionInput.WorkspaceScope != "run-local" || job.TargetKey != "rcon" || job.ExecutionInput.RemoteAdapterKey != "rcon" { - t.Fatalf("expected source RCON to use run-local despite custom-client binding, got %+v", job) + t.Fatalf("expected source RCON to use run-local despite incompatible binding, got %+v", job) } } diff --git a/platform_web/README.md b/platform_web/README.md index 5477f68..a9b4eeb 100644 --- a/platform_web/README.md +++ b/platform_web/README.md @@ -82,12 +82,11 @@ Server list surfaces expose runtime package actions through platform APIs, while - download the latest authorized run package. - push a self-update job to an online run endpoint when the endpoint reports `run.self-update`. - reset the current run key, which invalidates older run packages until regenerated. -- generate and download plugin-declared client-manager packages, including SCUM-style companion managers. -- reset the current client-manager key separately from the run key. +- use plugin-declared remote access actions such as RCON through Platform bridge envelopes. - request dependency checks and typed dependency install jobs. - open live server log stream metadata and request historical log backfill jobs. -These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies. +These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies. Log bodies are displayed verbatim when the plugin declares them; the browser does not redact or reinterpret them. Manual UI smoke checklist: diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 262db33..e3afb70 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -465,10 +465,10 @@ describe("PlatformApiClient AI providers", () => { } if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ - profileKey: "scum-client-manager", + profileKey: "example-client-manager", targetOs: "windows", targetArch: "amd64", - repositoryUrl: "https://github.com/F88888/scum_client.git", + repositoryUrl: "https://example.test/client-manager.git", sourceRevision: "main", idempotencyKey: "idem-client-generate" }); @@ -476,33 +476,33 @@ describe("PlatformApiClient AI providers", () => { id: "client-dist-1", serverInstanceId: server.id, pluginId: plugin.id, - profileKey: "scum-client-manager", + profileKey: "example-client-manager", targetOs: "windows", targetArch: "amd64", - repositoryUrl: "https://github.com/F88888/scum_client.git", + repositoryUrl: "https://example.test/client-manager.git", sourceRevision: "main", buildJobId: "client-build-1", artifactId: "artifact-client-1", checksum: "sha256:clientchecksum", keyGeneration: 1, - secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current", + secretRef: "secret://runtime-keys/server-1/client-manager/example-client-manager/current", status: "available", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); } if (url.endsWith("/api/v1/server-instances/server-1/client-managers/download") && init?.method === "POST") { - expect(JSON.parse(String(init.body))).toEqual({ profileKey: "scum-client-manager" }); - return jsonResponse({ ...runtimeDownload, artifactId: "artifact-client-1", filename: "scum-client-manager.exe" }); + expect(JSON.parse(String(init.body))).toEqual({ profileKey: "example-client-manager" }); + return jsonResponse({ ...runtimeDownload, artifactId: "artifact-client-1", filename: "example-client-manager.exe" }); } if (url.endsWith("/api/v1/server-instances/server-1/client-managers/key/reset") && init?.method === "POST") { - expect(JSON.parse(String(init.body))).toEqual({ componentKind: "client-manager", componentKey: "scum-client-manager" }); + expect(JSON.parse(String(init.body))).toEqual({ componentKind: "client-manager", componentKey: "example-client-manager" }); return jsonResponse({ id: "runtime-key-server-1-client-2", serverInstanceId: server.id, componentKind: "client-manager", - componentKey: "scum-client-manager", - secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current", + componentKey: "example-client-manager", + secretRef: "secret://runtime-keys/server-1/client-manager/example-client-manager/current", fingerprint: "def456abc123", generation: 2, status: "active", @@ -640,16 +640,16 @@ describe("PlatformApiClient AI providers", () => { await expect(client.listRunUpdates(server.id)).resolves.toMatchObject({ count: 1, items: [{ phase: "restart-requested", rollback: false }] }); await expect( client.generateClientManager(server.id, { - profileKey: "scum-client-manager", + profileKey: "example-client-manager", targetOs: "windows", targetArch: "amd64", - repositoryUrl: "https://github.com/F88888/scum_client.git", + repositoryUrl: "https://example.test/client-manager.git", sourceRevision: "main", idempotencyKey: "idem-client-generate" }) - ).resolves.toMatchObject({ artifactId: "artifact-client-1", profileKey: "scum-client-manager" }); - await expect(client.downloadLatestClientManager(server.id, { profileKey: "scum-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" }); - await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "scum-client-manager" })).resolves.toMatchObject({ generation: 2 }); + ).resolves.toMatchObject({ artifactId: "artifact-client-1", profileKey: "example-client-manager" }); + await expect(client.downloadLatestClientManager(server.id, { profileKey: "example-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" }); + await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "example-client-manager" })).resolves.toMatchObject({ generation: 2 }); await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" }); await expect(client.getDependencyCatalog(server.id)).resolves.toMatchObject({ targetOs: "linux", plans: [{ digest: runtimeDigest }] }); await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({ diff --git a/platform_web/api/clientManagerLifecycle.test.ts b/platform_web/api/clientManagerLifecycle.test.ts index 72f38ed..51429ac 100644 --- a/platform_web/api/clientManagerLifecycle.test.ts +++ b/platform_web/api/clientManagerLifecycle.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { PlatformApiClient } from "./client"; const lifecycle = { - id: "installation-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client-manager", targetOs: "windows", targetArch: "amd64", + id: "installation-1", serverInstanceId: "server-1", pluginId: "game.example", profileKey: "example-client-manager", targetOs: "windows", targetArch: "amd64", status: "available", phase: "artifact available", desiredVersion: "1.0.0", desiredRevision: "rev-1", desiredArtifactId: "artifact-1", keyGeneration: 1, deploymentGeneration: 0, health: "unknown", healthReason: "component is not installed", retryable: false, requiresRedeploy: false, updatedAt: "2026-07-18T08:00:00Z", distribution: { id: "distribution-1", artifactId: "artifact-1", sourceRevision: "rev-1", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 1, status: "available" }, @@ -23,17 +23,17 @@ describe("PlatformApiClient Client Manager lifecycle", () => { const client = new PlatformApiClient("/api/v1", () => "session-token"); await expect(client.listClientManagerLifecycles("server-1")).resolves.toMatchObject({ count: 1 }); - await expect(client.getClientManagerLifecycle("server-1", "scum-client-manager")).resolves.toMatchObject({ profileKey: "scum-client-manager" }); - await client.deployClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-1", expectedDeploymentGeneration: 0, idempotencyKey: "deploy-1" }); - await client.controlClientManager("server-1", { profileKey: "scum-client-manager", operation: "start", expectedDeploymentGeneration: 1, idempotencyKey: "start-1" }); - await client.updateClientManager("server-1", { profileKey: "scum-client-manager", distributionId: "distribution-2", expectedDeploymentGeneration: 1, approved: true, idempotencyKey: "update-1" }); - await client.retryClientManagerLifecycle("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, idempotencyKey: "retry-1" }); - await client.revokeClientManagerSession("server-1", { profileKey: "scum-client-manager", reason: "operator revoked component session" }); - await client.uninstallClientManager("server-1", { profileKey: "scum-client-manager", expectedDeploymentGeneration: 2, confirmed: true, idempotencyKey: "uninstall-1" }); + await expect(client.getClientManagerLifecycle("server-1", "example-client-manager")).resolves.toMatchObject({ profileKey: "example-client-manager" }); + await client.deployClientManager("server-1", { profileKey: "example-client-manager", distributionId: "distribution-1", expectedDeploymentGeneration: 0, idempotencyKey: "deploy-1" }); + await client.controlClientManager("server-1", { profileKey: "example-client-manager", operation: "start", expectedDeploymentGeneration: 1, idempotencyKey: "start-1" }); + await client.updateClientManager("server-1", { profileKey: "example-client-manager", distributionId: "distribution-2", expectedDeploymentGeneration: 1, approved: true, idempotencyKey: "update-1" }); + await client.retryClientManagerLifecycle("server-1", { profileKey: "example-client-manager", expectedDeploymentGeneration: 2, idempotencyKey: "retry-1" }); + await client.revokeClientManagerSession("server-1", { profileKey: "example-client-manager", reason: "operator revoked component session" }); + await client.uninstallClientManager("server-1", { profileKey: "example-client-manager", expectedDeploymentGeneration: 2, confirmed: true, idempotencyKey: "uninstall-1" }); expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ "GET /api/v1/server-instances/server-1/client-managers", - "GET /api/v1/server-instances/server-1/client-managers/scum-client-manager", + "GET /api/v1/server-instances/server-1/client-managers/example-client-manager", "POST /api/v1/server-instances/server-1/client-managers/deploy", "POST /api/v1/server-instances/server-1/client-managers/control", "POST /api/v1/server-instances/server-1/client-managers/update", diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index be958d3..6cad529 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -72,10 +72,10 @@ const plugin: GamePluginResponse = { productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required" }, gameClientBridge: { commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }], - snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }], + snapshots: [{ type: "bridge.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }], commandRetentionSeconds: 86400, maxCommands: 1000, - pages: [{ pageKey: "players", commandTypes: ["diagnostic.ping"], snapshotTypes: ["companion.health"] }] + pages: [{ pageKey: "players", commandTypes: ["diagnostic.ping"], snapshotTypes: ["bridge.health"] }] }, status: "installed" }; @@ -193,14 +193,14 @@ describe("PluginPageHostPage", () => { { operation: "delete" as const, key: "user-2" } ]; const queueRequest = { - profileKey: "scum-client", + profileKey: "example-client", commandType: "reward.deliver", - payload: { steamId: "redacted", giftKey: "starter" }, + payload: { steamId: "steam-1", giftKey: "starter" }, idempotencyKey: "gift:user-1:starter", expiresAt: "2026-08-15T12:00:00Z" }; - const commandFilter = { profileKey: "scum-client", state: "pending" as const, commandType: "reward.deliver" }; - const snapshotQuery = { profileKey: "scum-client", type: "scum.positions", streamKey: "current", limit: 50 }; + const commandFilter = { profileKey: "example-client", state: "pending" as const, commandType: "reward.deliver" }; + const snapshotQuery = { profileKey: "example-client", type: "example.positions", streamKey: "current", limit: 50 }; const dispatchEnvelope = { requestId: "request-1", action: "server.instances.read" as const, payload: { view: "summary" } }; await actions?.pluginData?.list("scum_users", "user-1"); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 0c5511c..cecc743 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -29,7 +29,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded const [state, setState] = useState(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); const [bundle, setBundle] = useState; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null); const [bundleError, setBundleError] = useState(""); - const [availability, setAvailability] = useState({ available: false, reason: "正在验证 Companion 可用性。" }); + const [availability, setAvailability] = useState({ available: false, reason: "正在验证插件桥接可用性。" }); const readyPluginRef = useRef(undefined); const hostContextRef = useRef | undefined>(undefined); @@ -99,7 +99,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; } void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features }); - }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); }); + }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证插件桥接可用性。" }); }); return () => { active = false; }; }, [bundleLoadKey, loadableBundlePage, serverId]); @@ -148,7 +148,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded -
Bundle {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}完整性 {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}Companion {availability.available ? "可用" : "不可用"}
+
Bundle {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}完整性 {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}插件桥接 {availability.available ? "可用" : "不可用"}
{bundleError && } {!bundle && !bundleError && } diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 2ecce92..03d682f 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -7,7 +7,6 @@ import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstan import { RuntimeTaskProgressDialog, type RuntimeTaskDialogAction, - runtimeBuildStages, runtimeDependencyStages, runtimeDownloadStages, runtimeKeyResetStages, @@ -29,7 +28,6 @@ import { import { summarizeServerOperations } from "../contracts/operationsConsole"; import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace"; import { - clientManagerBuildRequest, dependencyJobRequest, runDistributionGenerateRequest, runUpdateRequest, @@ -298,35 +296,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr const intent = quickRuntimeActionLabel(action); const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName }); setRuntimeTaskActions([]); - let generatedClientProfile: string | null = null; try { await requireQuickRuntimeActionAvailable(instance.id, action); let message: string; - if (action === "generate-client-manager") { - const distribution = await runtimeTask.runTrackedTask({ - title: intent, - description: quickRuntimeTaskDescription(instance, action), - stages: runtimeBuildStages, - start: async () => { - const distribution = await platformApiClient.generateClientManager( - instance.id, - clientManagerBuildRequest({ - serverInstanceId: instance.id, - profileKey: defaults.clientProfileKey, - targetOs: defaults.clientOs, - targetArch: "amd64", - repositoryUrl: defaults.repositoryUrl, - sourceRevision: "main" - }) - ); - return { value: distribution, jobId: distribution.buildJobId }; - }, - poll: (jobId) => platformApiClient.getJob(jobId) - }); - generatedClientProfile = defaults.clientProfileKey; - message = `客户端二进制已构建并上传,artifact ${distribution.artifactId}`; - } else { - message = await runtimeTask.runTask({ + message = await runtimeTask.runTask({ title: intent, description: quickRuntimeTaskDescription(instance, action), stages: quickRuntimeStages(action), @@ -361,19 +334,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr throw new Error("该运行操作已下线"); } }); - } operations.succeed(operationId, message); runtimeTask.succeedTask(message); - if (generatedClientProfile) { - const clientProfile = generatedClientProfile; - setRuntimeTaskActions([ - { - label: "下载客户端", - kind: "primary", - onClick: () => void downloadGeneratedClient(instance, clientProfile) - } - ]); - } await refresh(); } catch (error) { const message = error instanceof Error ? error.message : "运行操作失败"; @@ -426,26 +388,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr } } - async function downloadGeneratedClient(instance: ServerInstanceResponse, profileKey: string) { - setRuntimeTaskActions([]); - try { - const message = await runtimeTask.runTask({ - title: "下载客户端", - description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`, - stages: runtimeDownloadStages, - executeStageIndex: 1, - execute: async () => { - const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey }); - await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit)); - return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`; - } - }); - runtimeTask.succeedTask(message); - } catch (error) { - runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败"); - } - } - const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器"); const runTargetPlugin = runTargetSelection ? plugins.find((plugin) => plugin.id === runTargetSelection.instance.pluginId) : undefined; const runTargetOsOptions = runPlatformOptions(runTargetPlugin, runTargetSelection?.targetOs ?? "linux"); @@ -665,7 +607,6 @@ type ServerQuickRuntimeAction = | "download-run" | "push-run-update" | "reset-run-key" - | "generate-client-manager" | "dependencies-check" | "dependencies-install"; @@ -902,7 +843,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntimeAction[] }> = [ { label: "运行分发", - actions: ["generate-run", "download-run", "push-run-update", "generate-client-manager"] + actions: ["generate-run", "download-run", "push-run-update"] }, { label: "诊断维护", @@ -920,8 +861,6 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string { return "更新 run"; case "reset-run-key": return "重置 run 密钥"; - case "generate-client-manager": - return "生成客户端"; case "dependencies-check": return "依赖检查"; case "dependencies-install": @@ -933,9 +872,6 @@ function quickRuntimeStages(action: ServerQuickRuntimeAction) { if (action === "generate-run") { return runtimeRunBuildStages; } - if (action === "generate-client-manager") { - return runtimeBuildStages; - } if (action === "download-run") { return runtimeDownloadStages; } @@ -952,7 +888,7 @@ function quickRuntimeStages(action: ServerQuickRuntimeAction) { } function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number { - if (action === "generate-run" || action === "generate-client-manager") { + if (action === "generate-run") { return 3; } if (action === "push-run-update") { @@ -973,9 +909,6 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) { const isScum = pluginId.toLowerCase().includes("scum"); return { runOs: isScum ? "windows" : "linux", - clientOs: "windows", - clientProfileKey: isScum ? "scum-client-manager" : "client-manager", - repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git", probeKey: isScum ? "steamcmd" : "java-21", installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux" }; diff --git a/plugins/README.md b/plugins/README.md index f51731e..1ffc554 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -11,7 +11,7 @@ A game management plugin defines how the platform creates and manages one type o - Lifecycle action definitions. - Required run capabilities. - Optional remote access methods and remote run capabilities. -- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and client-manager builds. +- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and plugin-owned component declarations. - Optional plugin pages hosted by platform_web. - AI/file/log permissions declared for platform authorization, including `ai.mediation=platform` and `ai.configWritePolicy=review-required`. - Production lifecycle operations, dependency policy, and disruptive approval requirements. @@ -30,29 +30,23 @@ Plugins must use the platform bridge and must not connect directly to run, log s Manifest validation is the plugin-side installability gate. The shared manifest schema defines identity, version, server type/display metadata, create form schema reference, lifecycle action references, required run capabilities, scoped permissions, remote access declarations, runtime profiles, optional pages, tags, and AI purposes. `scripts/validate-manifest.ts` also scans manifest and create-form content for unsafe raw host path, raw credential, direct run, and raw AI/provider key requests. -Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or `run`, and which `remote.*` run capabilities are enabled for that game. Runtime profiles describe how run discovers servers, checks dependencies, tails live logs, backfills historical logs, resolves transports, and builds optional client managers. Plugin pages use platform-mediated bridge actions; they never receive FTP passwords, rsync endpoints, database DSNs, RCON credentials, run/client keys, run sockets, or host paths. +Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or `run`, and which `remote.*` run capabilities are enabled for that game. Runtime profiles describe how Run discovers servers, checks dependencies, carries declared log channels verbatim, resolves transports, and executes plugin-owned lifecycle declarations. Plugin pages use platform-mediated bridge actions; they never receive FTP passwords, rsync endpoints, database DSNs, RCON credentials, run/client keys, run sockets, or host paths. Runtime profiles are declarative contracts, not executable scripts. A profile can declare: - discovery probes for logical targets such as Java, Steam app, service, file, or toolchain checks. - lifecycle modes such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`. - dependency probes and typed install plans for supported OS targets. -- log sources for stdout/stderr, file tailing, FTP polling, SQL cursors, or plugin-specific client-manager logs. +- log sources for stdout/stderr, file tailing, FTP polling, or plugin-declared typed channels. Log bodies remain opaque and verbatim to Run and Platform. - transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations. -- client-manager build profiles for games such as SCUM that need a separate companion executable. - -Client-manager profiles declare repository URL, revision policy, semantic version, supported target OS/architecture pairs, a fixed build adapter, config template keys, produced artifacts, and an optional complete lifecycle contract. The lifecycle contract names a safe relative executable, fixed arguments, required Run capabilities, bounded start/stop/restart/status/update/rollback/uninstall actions, heartbeat/process health thresholds, component capabilities, compatibility bounds, and a manual staged-update policy. It cannot contain arbitrary shell, absolute/traversing paths, direct sockets, endpoints, raw credentials, or secret/session values. - -Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a managed workspace, registered using a separate short-lived component session, health-checked, started/stopped, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy. +Plugin-owned component declarations may describe their own typed bridge records and lifecycle, while the generic Run/Platform path only transports declared envelopes and opaque log channels. Neither Run nor Platform parses, filters, normalizes, correlates, redacts, or derives plugin records from stdout, stderr, or declared file tails. Plugin pages may request these operations only through bridge helpers: - `createRunDistributionRequest`: generate/download/reset/update run packages. - `createDependencyActionRequest`: check or install declared dependency probes/plans. - `createLogBackfillRequest`: request historical log cursors for declared sources. -- `createClientManagerRequest`: generate/download/reset or request safe status/deploy/control/update/rollback/revoke/retry/uninstall operations for declared client-manager packages. - `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform operations. -- `parseClientManagerLifecycleStatus`: whitelist the plugin-visible status, version, health, artifact/job IDs, deployment generation, and allowed actions without component secrets or machine details. Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets. @@ -75,7 +69,7 @@ npm run test npm run validate:manifest ``` -Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and a SCUM-style client-manager declaration with a complete bounded lifecycle contract. Minecraft deliberately remains a no-client-manager example so action gating proves the feature is optional. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work. +Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and plugin-owned typed SCUM RCON data flows. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work. Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The durable Client Manager installation/session state, bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections. # Client Manager profile contract diff --git a/plugins/examples/scum-server-plugin/companion/README.md b/plugins/examples/scum-server-plugin/companion/README.md deleted file mode 100644 index 28ea513..0000000 --- a/plugins/examples/scum-server-plugin/companion/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# SCUM Companion One-Shot Smoke - -Run stdout/stderr and the plugin-declared SCUM log streams arrive as opaque -records. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime -boundary; this companion alone parses the SCUM login format and publishes the -typed player snapshot used by the plugin page. Platform and Run never inspect -or redact those log bodies. - -The long-running companion stores only its typed snapshot sequence state in -`snapshot-sequences.json` beside `config.yaml`. This keeps the `players/current` -stream monotonic across process restarts; it contains no raw log records. - -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. - -Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process. - -## Package - -Build the one-shot command from this directory: - -```bash -go build -o scum-companion-smoke ./cmd/scum-companion-smoke -``` - -Place the generated `config.yaml` beside the executable. The command intentionally has no `--config` flag and reads only that sidecar filename from its working directory. `config.yaml.example` documents the generated shape; deployed identity and generation values must come from the fenced Client Manager lifecycle input. - -The Platform base URL must be a trusted HTTPS origin. The client uses host system certificate roots, requires TLS 1.2 or newer, and does not follow redirects. Local acceptance therefore needs a hostname and certificate already trusted by the machine account running the package. The certificate SAN must cover the configured hostname or IP; a certificate for `localhost` does not cover `127.0.0.1` unless that IP is also present. Do not use HTTP fallback, certificate-skip flags, custom root overrides, or other verification bypasses for local testing. - -The supervisor supplies the component proof through the environment variable named by `proof.materialEnv`. Bind it from the protected component package at process start. Do not place the proof in `config.yaml`, command arguments, command-line environment assignments, shell history, documentation, or logs. - -The process environment must also set `SCUM_COMPANION_SMOKE_SCOPE` to `isolated-non-production`. This value is a non-secret safety acknowledgement; configure it in the supervisor rather than placing component proof material on a command line. - -Run the executable from the package working directory: - -```bash -./scum-companion-smoke -``` - -The JSON output contains only claimed/completed/unsupported counts, command IDs, and the accepted snapshot ID. It never prints the component proof, component session, command payloads, host paths, or transport details. The run proceeds only when exactly one live `companion.diagnostics` command with a valid bounded payload is claimed. Zero, multiple, expired, malformed, or unsupported commands stop the smoke immediately; they are not acknowledged, completed, or executed, and no snapshot is uploaded. Platform lease fencing remains authoritative. - -Each invocation uploads health to a fresh bounded `smoke-` stream with sequence `1`. This avoids reusing production stream state and remains safe across process restarts without storing a host path or local sequence file. diff --git a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md deleted file mode 100644 index 04caff8..0000000 --- a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md +++ /dev/null @@ -1,17 +0,0 @@ -# Runtime capability boundary - -UE4SS is only a possible Companion-local implementation detail. It is not a -feature gate: no SCUM game, database, UE4SS build, or source revision controls -plugin availability. - -The Companion declares availability from its server-bound typed ports and -runtime schema probes. Plugin-generated SQL, RCON, and management-program -request text is declared through the protected Platform-to-Run transport, not -executed by the Companion. Callers never receive a socket, credential, path, -or raw transport reply. A management-program request is not host OS shell -access. - -Semantic events come from bounded Run stdout/stderr console records. Unknown -records create diagnostics and never produce fabricated events. DSNs, rows, -connections, and credentials do not leave Run; request text is protected and -redacted from browser and platform projections. diff --git a/plugins/examples/scum-server-plugin/companion/adapters.go b/plugins/examples/scum-server-plugin/companion/adapters.go deleted file mode 100644 index eb59c39..0000000 --- a/plugins/examples/scum-server-plugin/companion/adapters.go +++ /dev/null @@ -1,565 +0,0 @@ -package companion - -import ( - "context" - "errors" - "fmt" - "math" - "sort" - "strings" - "unicode/utf8" -) - -var errAdapterUnsupported = errors.New("runtime capability is unavailable") - -// AuthorizedConfigPort is supplied through the platform-authorized Run channel. -// It exposes logical, declared configuration values only; it never exposes a -// path, DSN, credential, arbitrary command, or database handle. -type AuthorizedConfigPort interface { - ReadConfig(context.Context) (map[string]string, error) - ApplyConfigPatch(context.Context, string, []ConfigFieldPatch) (map[string]string, error) -} -type ConfigFieldPatch struct { - Key string - Value string -} - -// AuthorizedGameDataPort is a typed, Run-owned read/patch boundary. Implementations -// must probe their local schema, use declared fields and safe windows, and return -// bounded snapshots rather than rows or connection details. -type AuthorizedGameDataPort interface { - ReadPlayerState(context.Context, string, []string) (PlayerStateSnapshot, error) - ApplyPlayerState(context.Context, PlayerStatePatch) (PlayerStateSnapshot, error) -} -type PlayerStateSnapshot struct { - PlayerID string - StateVersion string - SafeWindow bool - Fields map[string]float64 -} -type PlayerStatePatch struct { - PlayerID string - ExpectedStateVersion string - Fields []StateFieldPatch -} -type StateFieldPatch struct { - Key string - Before float64 - After float64 -} - -// AuthorizedRewardPort accepts a frozen grant with typed items and operations. -type AuthorizedRewardPort interface { - DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error) -} -type RewardGrant struct { - GrantID string - PlayerID string - Items []RewardItem - Operations []string -} -type RewardItem struct { - CatalogCode string - Quantity int -} -type DeliveryReceipt struct { - Outcome string - DeliveryID string -} - -type AuthorizedEventPort interface { - StartEvent(context.Context, EventStartRequest) (EventStartReceipt, error) -} -type EventStartRequest struct { - EventID string - EventType string - Class int - Title string - Placard string - Percent int - NPC int - Item int - Zombie int - Animal int - Produces []EventProduceRequest - DurationSeconds int - MaxParticipants int - Announce bool -} -type EventProduceRequest struct { - TradeGoodsID string - Percent int - Value int - Radius float64 - X float64 - Y float64 - Z float64 -} -type EventStartReceipt struct { - Accepted bool - Status string - EventID string - Message string -} - -const fixedNotificationType = 4 - -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 - localCommandPreview string -} -type UE4SSVehicleSpawnPort interface { - SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) -} -type UE4SSVehicleSpawnOutcome string - -const ( - UE4SSVehicleSpawnAccepted UE4SSVehicleSpawnOutcome = "accepted" - UE4SSVehicleSpawnRejected UE4SSVehicleSpawnOutcome = "rejected" - UE4SSVehicleSpawnUnknown UE4SSVehicleSpawnOutcome = "unknown" -) - -type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome } -type ue4SSVehicleSpawn struct { - ServerID string - VehicleCode string - localCommandPreview string -} - -// RuntimeAdapter is bound to one server. Availability is discovered from its -// configured typed ports and declared capabilities. A failed probe affects -// only its operation. -type RuntimeAdapter struct { - BoundServerID string - Config AuthorizedConfigPort - GameData AuthorizedGameDataPort - Rewards AuthorizedRewardPort - Events AuthorizedEventPort - Notification UE4SSNotificationPort - VehicleSpawn UE4SSVehicleSpawnPort - DiagnosticsState map[string]string -} - -func (adapter RuntimeAdapter) ServerBinding() string { return adapter.BoundServerID } - -func (adapter RuntimeAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) { - if adapter.Config == nil { - return nil, errAdapterUnsupported - } - fields, err := adapter.Config.ReadConfig(ctx) - if err != nil { - return nil, err - } - return map[string]any{"fields": redactConfigValues(fields)}, nil -} -func (adapter RuntimeAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) { - if adapter.Config == nil { - return nil, errAdapterUnsupported - } - revision, _ := payload["revision"].(string) - raw, _ := payload["fields"].([]any) - fields := make([]ConfigFieldPatch, 0, len(raw)) - for _, value := range raw { - item, ok := value.(map[string]any) - if !ok { - return nil, fmt.Errorf("configuration patch payload is invalid") - } - key, keyOK := item["key"].(string) - fieldValue, valueOK := item["value"].(string) - if !keyOK || !valueOK || !supportedConfigKey(key) { - return nil, fmt.Errorf("configuration patch field is unsupported") - } - fields = append(fields, ConfigFieldPatch{Key: key, Value: fieldValue}) - } - applied, err := adapter.Config.ApplyConfigPatch(ctx, revision, fields) - if err != nil { - return nil, err - } - return map[string]any{"appliedFields": redactConfigValues(applied)}, nil -} -func (adapter RuntimeAdapter) Diagnostics(context.Context) (map[string]any, error) { - state := map[string]any{"adapter": "runtime-capability"} - for key, value := range adapter.DiagnosticsState { - if safeDiagnosticField(key, value) { - state[key] = value - } - } - return state, nil -} -func (adapter RuntimeAdapter) PatchGameState(ctx context.Context, payload map[string]any) (map[string]any, error) { - if adapter.GameData == nil { - return nil, errAdapterUnsupported - } - playerID, _ := payload["playerId"].(string) - expected, _ := payload["expectedStateVersion"].(string) - raw, _ := payload["changes"].([]any) - fields, err := statePatchFields(raw) - if err != nil { - return nil, err - } - before, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields)) - if err != nil { - return nil, err - } - if before.PlayerID != playerID || before.StateVersion != expected || !before.SafeWindow || !stateMatches(before.Fields, fields) { - return map[string]any{"outcome": "failed"}, nil - } - after, err := adapter.GameData.ApplyPlayerState(ctx, PlayerStatePatch{PlayerID: playerID, ExpectedStateVersion: expected, Fields: fields}) - if err != nil { - return map[string]any{"outcome": "unknown"}, nil - } - confirmed, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields)) - if err != nil || after.PlayerID != playerID || !stateApplied(confirmed.Fields, fields) { - return map[string]any{"outcome": "unknown"}, nil - } - return map[string]any{"outcome": "succeeded", "changedFields": len(fields)}, nil -} -func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[string]any) (map[string]any, error) { - if adapter.Rewards == nil { - return nil, errAdapterUnsupported - } - grant, err := rewardGrant(payload) - if err != nil { - return nil, err - } - receipt, err := adapter.Rewards.DeliverReward(ctx, grant) - if err != nil || receipt.Outcome == "unknown" { - return map[string]any{"accepted": false, "status": "rejected", "message": "reward delivery result is unknown"}, nil - } - if receipt.Outcome != "delivered" { - return map[string]any{"accepted": false, "status": "rejected"}, nil - } - result := map[string]any{"accepted": true, "status": "delivered"} - if receipt.DeliveryID != "" { - result["deliveryId"] = receipt.DeliveryID - } - return result, nil -} -func (adapter RuntimeAdapter) StartEvent(ctx context.Context, payload map[string]any) (map[string]any, error) { - if adapter.Events == nil { - return nil, errAdapterUnsupported - } - request, err := eventStartRequest(payload) - if err != nil { - return nil, err - } - receipt, err := adapter.Events.StartEvent(ctx, request) - if err != nil { - return nil, err - } - status := receipt.Status - if status == "" { - status = "rejected" - } - if status != "started" && status != "queued" && status != "rejected" { - return nil, fmt.Errorf("event start receipt is invalid") - } - eventID := receipt.EventID - if eventID == "" { - eventID = request.EventID - } - result := map[string]any{"accepted": receipt.Accepted, "status": status, "eventId": eventID} - if receipt.Message != "" { - result["message"] = receipt.Message - } - return result, nil -} -func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) { - if adapter.BoundServerID == "" || adapter.Notification == nil { - return nil, errAdapterUnsupported - } - 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") - } - return map[string]any{"accepted": receipt.Accepted}, nil -} -func (adapter RuntimeAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) { - if adapter.BoundServerID == "" || adapter.VehicleSpawn == nil { - return nil, errAdapterUnsupported - } - vehicleCode, ok := payload["vehicleCode"].(string) - spawn, err := newUE4SSVehicleSpawn(adapter.BoundServerID, vehicleCode) - if !ok || err != nil { - return nil, fmt.Errorf("vehicle spawn payload is invalid") - } - receipt, err := adapter.VehicleSpawn.SpawnVehicle(ctx, spawn) - if err != nil || receipt.Outcome == UE4SSVehicleSpawnUnknown { - return map[string]any{"outcome": "unknown"}, nil - } - if receipt.Outcome == UE4SSVehicleSpawnRejected { - return map[string]any{"outcome": "failed"}, nil - } - if receipt.Outcome != UE4SSVehicleSpawnAccepted { - return map[string]any{"outcome": "unknown"}, nil - } - return map[string]any{"outcome": "succeeded"}, nil -} -func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayerNotification, error) { - if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) { - return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed notification") - } - return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, localCommandPreview: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil -} -func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) { - if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) { - return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed vehicle spawn") - } - return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, localCommandPreview: "#spawnvehicle " + vehicleCode}, 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 supportedVehicleSpawnCode(value string) bool { - return map[string]bool{"BPC_Laika_C": true, "BPC_WolfsWagen_C": true}[value] -} -func supportedConfigKey(key string) bool { - return map[string]bool{"ServerName": true, "GamePort": true, "QueryPort": true, "MaxPlayers": true, "WelcomeMessage": true}[key] -} -func redactConfigValues(values map[string]string) map[string]string { - result := map[string]string{} - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - if supportedConfigKey(key) && safeDiagnosticField(key, values[key]) { - result[key] = values[key] - } - } - return result -} -func safeDiagnosticField(key, value string) bool { - lowered := strings.ToLower(key + "=" + value) - return !strings.Contains(lowered, "path") && !strings.Contains(lowered, "credential") && !strings.Contains(lowered, "password") && !strings.Contains(lowered, "bearer ") && !strings.Contains(lowered, "rcon") && !strings.Contains(lowered, "sql") && !strings.Contains(lowered, "://") -} -func statePatchFields(raw []any) ([]StateFieldPatch, error) { - if len(raw) == 0 || len(raw) > 8 { - return nil, fmt.Errorf("state patch payload is invalid") - } - fields := make([]StateFieldPatch, 0, len(raw)) - for _, value := range raw { - item, ok := value.(map[string]any) - if !ok { - return nil, fmt.Errorf("state patch payload is invalid") - } - key, _ := item["fieldKey"].(string) - before, beforeOK := item["before"].(float64) - after, afterOK := item["after"].(float64) - if key == "" || !beforeOK || !afterOK { - return nil, fmt.Errorf("state patch payload is invalid") - } - fields = append(fields, StateFieldPatch{Key: key, Before: before, After: after}) - } - return fields, nil -} -func statePatchKeys(fields []StateFieldPatch) []string { - keys := make([]string, 0, len(fields)) - for _, field := range fields { - keys = append(keys, field.Key) - } - return keys -} -func stateMatches(values map[string]float64, fields []StateFieldPatch) bool { - for _, field := range fields { - if values[field.Key] != field.Before { - return false - } - } - return true -} -func stateApplied(values map[string]float64, fields []StateFieldPatch) bool { - for _, field := range fields { - if values[field.Key] != field.After { - return false - } - } - return true -} -func rewardGrant(payload map[string]any) (RewardGrant, error) { - grantID, grantOK := payload["grantId"].(string) - playerID, playerOK := payload["playerId"].(string) - rawItems, itemsOK := payload["items"].([]any) - rawOperations, operationsOK := payload["operations"].([]any) - if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) { - return RewardGrant{}, fmt.Errorf("reward payload is invalid") - } - items := make([]RewardItem, 0, len(rawItems)) - for _, value := range rawItems { - item, ok := value.(map[string]any) - if !ok || len(item) != 2 { - return RewardGrant{}, fmt.Errorf("reward payload is invalid") - } - code, codeOK := item["catalogCode"].(string) - quantity, quantityOK := integerPayloadValue(item["quantity"]) - if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 { - return RewardGrant{}, fmt.Errorf("reward payload is invalid") - } - items = append(items, RewardItem{CatalogCode: code, Quantity: quantity}) - } - operations := make([]string, 0, len(rawOperations)) - for _, value := range rawOperations { - operation, ok := value.(string) - if !ok || operation == "" || !utf8.ValidString(operation) { - return RewardGrant{}, fmt.Errorf("reward payload is invalid") - } - operations = append(operations, operation) - } - return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items, Operations: operations}, nil -} - -func eventStartRequest(payload map[string]any) (EventStartRequest, error) { - eventID, eventIDOK := payload["eventId"].(string) - eventType, eventTypeOK := payload["eventType"].(string) - eventClass, classOK := integerPayloadValue(payload["class"]) - title, titleOK := payload["title"].(string) - duration, durationOK := integerPayloadValue(payload["durationSeconds"]) - percent, percentOK := integerPayloadValue(payload["percent"]) - placard, placardOK := payload["placard"].(string) - produces, producesOK := eventProduceRequests(payload["produces"]) - counts := make([]int, 4) - for index, key := range []string{"npc", "item", "zombie", "animal"} { - if value, exists := payload[key]; exists { - count, ok := integerPayloadValue(value) - if !ok || count < 0 { - return EventStartRequest{}, fmt.Errorf("event start payload is invalid") - } - counts[index] = count - } - } - participants := 0 - if value, exists := payload["maxParticipants"]; exists { - var ok bool - participants, ok = integerPayloadValue(value) - if !ok || participants < 1 { - return EventStartRequest{}, fmt.Errorf("event start payload is invalid") - } - } - announce := false - if value, exists := payload["announce"]; exists { - var ok bool - announce, ok = value.(bool) - if !ok { - return EventStartRequest{}, fmt.Errorf("event start payload is invalid") - } - } - if !eventIDOK || !eventTypeOK || !classOK || !titleOK || !durationOK || !percentOK || !placardOK || !producesOK || !supportedEventType(eventType) || eventClass < 1 || eventClass > 2 || (eventClass == 1) != (eventType == "range") || strings.TrimSpace(eventID) == "" || strings.TrimSpace(title) == "" || len(placard) > 500 || percent < 0 || percent > 100 || duration < 1 { - return EventStartRequest{}, fmt.Errorf("event start payload is invalid") - } - return EventStartRequest{EventID: eventID, EventType: eventType, Class: eventClass, Title: title, Placard: placard, Percent: percent, NPC: counts[0], Item: counts[1], Zombie: counts[2], Animal: counts[3], Produces: produces, DurationSeconds: duration, MaxParticipants: participants, Announce: announce}, nil -} - -func eventProduceRequests(value any) ([]EventProduceRequest, bool) { - raw, ok := value.([]any) - if !ok { - return nil, false - } - result := make([]EventProduceRequest, 0, len(raw)) - for _, candidate := range raw { - produce, ok := candidate.(map[string]any) - if !ok || len(produce) != 7 { - return nil, false - } - tradeGoodsID, idOK := produce["tradeGoodsId"].(string) - percent, percentOK := integerPayloadValue(produce["percent"]) - quantity, quantityOK := integerPayloadValue(produce["value"]) - radius, radiusOK := numberPayloadValue(produce["r"]) - x, xOK := numberPayloadValue(produce["x"]) - y, yOK := numberPayloadValue(produce["y"]) - z, zOK := numberPayloadValue(produce["z"]) - if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || !radiusOK || radius < 0 || !xOK || !yOK || !zOK { - return nil, false - } - result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z}) - } - return result, true -} - -func integerPayloadValue(value any) (int, bool) { - switch number := value.(type) { - case float64: - if number != float64(int(number)) { - return 0, false - } - return int(number), true - case int: - return number, true - case int32: - return int(number), true - case int64: - return int(number), true - default: - return 0, false - } -} - -func numberPayloadValue(value any) (float64, bool) { - var result float64 - switch number := value.(type) { - case float64: - result = number - case float32: - result = float64(number) - case int: - result = float64(number) - case int32: - result = float64(number) - case int64: - result = float64(number) - default: - return 0, false - } - return result, !math.IsNaN(result) && !math.IsInf(result, 0) -} - -func supportedCatalogCode(value string) bool { - if value == "" || len(value) > 128 { - return false - } - for _, character := range value { - if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_' && character != '-' && character != '.' { - return false - } - } - return true -} - -func supportedEventType(value string) bool { - return value == "range" || value == "fixed" -} diff --git a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go deleted file mode 100644 index b1c6b28..0000000 --- a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package companion - -import ( - "context" - "errors" - "os" - "path/filepath" - "regexp" - "runtime" - "strings" - "testing" - "time" -) - -// isolatedAdapterPort is a non-production typed-port fixture. It records only -// the adapter's typed inputs and never opens a connection or accepts a raw -// game command. -type isolatedAdapterPort struct { - configFields map[string]string - configErr error - patchErr error - patches []ConfigFieldPatch - notifications []ue4SSPlayerNotification - notifyErr error - notifyAccept bool - spawns []ue4SSVehicleSpawn - spawnReceipts []UE4SSVehicleSpawnReceipt - spawnErrors []error -} - -func (port *isolatedAdapterPort) ReadConfig(context.Context) (map[string]string, error) { - return port.configFields, port.configErr -} - -func (port *isolatedAdapterPort) ApplyConfigPatch(_ context.Context, _ string, fields []ConfigFieldPatch) (map[string]string, error) { - port.patches = append(port.patches, fields...) - if port.patchErr != nil { - return nil, port.patchErr - } - return map[string]string{"ServerName": "Moonlight", "hostPath": "C:/private/server.ini"}, nil -} - -func (port *isolatedAdapterPort) SendPlayerNotification(_ context.Context, notification ue4SSPlayerNotification) (UE4SSNotificationReceipt, error) { - port.notifications = append(port.notifications, notification) - return UE4SSNotificationReceipt{Accepted: port.notifyAccept}, port.notifyErr -} - -func (port *isolatedAdapterPort) SpawnVehicle(_ context.Context, spawn ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) { - port.spawns = append(port.spawns, spawn) - index := len(port.spawns) - 1 - if index >= len(port.spawnReceipts) { - return UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}, nil - } - return port.spawnReceipts[index], port.spawnErrors[index] -} - -type isolatedDispatchGateway struct { - commands []ClaimedCommand - acks []string - completed map[string][]CommandResult -} - -func (gateway *isolatedDispatchGateway) ClaimCommands(context.Context, int) ([]ClaimedCommand, error) { - return append([]ClaimedCommand(nil), gateway.commands...), nil -} - -func (gateway *isolatedDispatchGateway) AckCommand(_ context.Context, id string, _ uint64) (CommandAck, error) { - gateway.acks = append(gateway.acks, id) - return CommandAck{CommandID: id, State: "claimed", FencingToken: 1}, nil -} - -func (gateway *isolatedDispatchGateway) CompleteCommand(_ context.Context, id string, _ uint64, result CommandResult) (CompletedCommand, error) { - if gateway.completed == nil { - gateway.completed = map[string][]CommandResult{} - } - gateway.completed[id] = append(gateway.completed[id], result) - return CompletedCommand{State: "completed"}, nil -} - -func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) ClaimedCommand { - return ClaimedCommand{ID: id, ProfileKey: ProfileKey, CommandType: commandType, Payload: payload, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)} -} - -func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) { - stamp := time.Now().UTC() - port := &isolatedAdapterPort{ - configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"}, - notifyAccept: true, - spawnReceipts: []UE4SSVehicleSpawnReceipt{{Outcome: UE4SSVehicleSpawnAccepted}, {Outcome: UE4SSVehicleSpawnRejected}, {Outcome: UE4SSVehicleSpawnUnknown}}, - spawnErrors: []error{nil, nil, errors.New("receipt unavailable")}, - } - adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port, VehicleSpawn: port} - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true, "config.patch": true, "player.notify": true, "vehicle.spawn": true}}, adapter) - gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{ - e2eClaim("config-read", "config.read", map[string]any{}, stamp), - e2eClaim("config-patch", "config.patch", map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moonlight"}}}, stamp), - e2eClaim("notify", "player.notify", map[string]any{"playerId": "76561198000000001", "message": "Moonlight ready"}, stamp), - e2eClaim("notify", "player.notify", map[string]any{"playerId": "76561198000000001", "message": "Moonlight ready"}, stamp), - e2eClaim("spawn-success", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp), - e2eClaim("spawn-failed", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_WolfsWagen_C"}, stamp), - e2eClaim("spawn-unknown", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp), - e2eClaim("spawn-unknown", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp), - }} - dispatcher := Dispatcher{Client: gateway, Registry: registry, Now: func() time.Time { return stamp }} - if err := dispatcher.DispatchOnce(context.Background()); err != nil { - t.Fatalf("dispatch supported adapters: %v", err) - } - if len(port.notifications) != 1 || port.notifications[0].ServerID != "server-1" || port.notifications[0].localCommandPreview == "" { - t.Fatalf("notification did not remain server-bound and idempotent: %+v", port.notifications) - } - if len(port.patches) != 1 || gateway.completed["config-read"][0].Payload["fields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["config-patch"][0].Payload["appliedFields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["notify"][0].Payload["accepted"] != true { - t.Fatalf("supported adapters did not return their bounded successful results: patches=%+v completed=%+v", port.patches, gateway.completed) - } - if len(port.spawns) != 3 || port.spawns[0].localCommandPreview != "#spawnvehicle BPC_Laika_C" || port.spawns[1].localCommandPreview != "#spawnvehicle BPC_WolfsWagen_C" || port.spawns[2].localCommandPreview != "#spawnvehicle BPC_Laika_C" { - t.Fatalf("vehicle adapter did not use only fixed private templates: %+v", port.spawns) - } - if gateway.completed["spawn-success"][0].Payload["outcome"] != "succeeded" || gateway.completed["spawn-failed"][0].Payload["outcome"] != "failed" || gateway.completed["spawn-unknown"][0].Payload["outcome"] != "unknown" { - t.Fatalf("unexpected bounded vehicle outcomes: %+v", gateway.completed) - } - for id, results := range gateway.completed { - for _, result := range results { - serialized := result.Summary + " " + stringifyPayload(result.Payload) - for _, secret := range []string{"C:/private/server.ini", "never-return", "#spawnvehicle", "SendChat"} { - if strings.Contains(serialized, secret) { - t.Fatalf("%s exposed protected adapter output %q: %+v", id, secret, result) - } - } - } - } -} - -func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) { - stamp := time.Now().UTC() - for name, testCase := range map[string]struct { - availability HandlerAvailability - adapter RuntimeAdapter - }{ - "binding": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: RuntimeAdapter{BoundServerID: "server-2"}}, - "approval": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: false, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: RuntimeAdapter{BoundServerID: "server-1"}}, - "capability": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{}}, adapter: RuntimeAdapter{BoundServerID: "server-1"}}, - } { - t.Run(name, func(t *testing.T) { - port := &isolatedAdapterPort{} - testCase.adapter.VehicleSpawn = port - gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{e2eClaim(name, "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp)}} - dispatcher := Dispatcher{Client: gateway, Registry: NewHandlerRegistry(testCase.availability, testCase.adapter), Now: func() time.Time { return stamp }} - if err := dispatcher.DispatchOnce(context.Background()); err != nil { - t.Fatalf("dispatch fail-closed case: %v", err) - } - result := gateway.completed[name][0] - if result.Payload["result"] != "unsupported" || len(port.spawns) != 0 { - t.Fatalf("unsafe adapter reached transport: result=%+v spawns=%+v", result, port.spawns) - } - }) - } -} - -func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) { - stamp := time.Now().UTC() - port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")} - adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port} - gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{ - e2eClaim("patch-failure", "config.patch", map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moonlight"}}}, stamp), - e2eClaim("notification-failure", "player.notify", map[string]any{"playerId": "76561198000000001", "message": "Moonlight ready"}, stamp), - }} - dispatcher := Dispatcher{Client: gateway, Registry: NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.patch": true, "player.notify": true}}, adapter), Now: func() time.Time { return stamp }} - if err := dispatcher.DispatchOnce(context.Background()); err != nil { - t.Fatalf("dispatch adapter failures: %v", err) - } - for id, results := range gateway.completed { - result := results[0] - if result.Payload["result"] != "failed" || strings.Contains(stringifyPayload(result.Payload), "private") || strings.Contains(result.Summary, "private") { - t.Fatalf("%s did not redact failed typed-port output: %+v", id, result) - } - } - if len(port.notifications) != 1 || port.notifications[0].localCommandPreview == "" { - t.Fatalf("notification fixture did not receive one typed request: %+v", port.notifications) - } -} - -func stringifyPayload(payload map[string]any) string { - parts := make([]string, 0, len(payload)) - for key, value := range payload { - parts = append(parts, key+"="+strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(toText(value)), "\n", " "))) - } - return strings.Join(parts, " ") -} - -func toText(value any) string { - if text, ok := value.(string); ok { - return text - } - return "" -} - -func TestCompanionProductionSourceHasNoForbiddenAdapterPaths(t *testing.T) { - _, currentFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("resolve companion source directory") - } - forbidden := map[string]*regexp.Regexp{ - "raw SQL or direct database access": regexp.MustCompile(`(?im)"(?:database/sql|github\.com/(?:mattn/go-sqlite3|go-sql-driver/mysql)|gorm\.io/gorm)"|\b(?:sql|db|database)\.(?:Open|Exec(?:Context)?|Query(?:Context)?|Prepare(?:Context)?)\s*\(`), - "unrestricted RCON or command execution": regexp.MustCompile(`(?i)\b(?:send|execute|run|dispatch)[a-z0-9_]*(?:rcon|rawcommand|command)\s*\(`), - "desktop automation or screen capture": regexp.MustCompile(`(?i)\b(?:tesseract|gosseract|screenshot|robotgo|autogui|keybd_event|mouse_event|sendinput)\b`), - "direct socket transport": regexp.MustCompile(`\bnet\.(?:Dial|DialTimeout)\s*\(`), - } - root := filepath.Dir(currentFile) - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { - return nil - } - source, err := os.ReadFile(path) - if err != nil { - return err - } - for name, pattern := range forbidden { - if match := pattern.FindString(string(source)); match != "" { - t.Fatalf("%s contains forbidden %s: %q", filepath.Base(path), name, match) - } - } - return nil - }) - if err != nil { - t.Fatalf("scan production companion source: %v", err) - } -} diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go deleted file mode 100644 index c1de996..0000000 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package companion - -import ( - "context" - "errors" - "testing" - "time" -) - -type configPortFixture struct { - fields map[string]string - patches []ConfigFieldPatch -} - -func (fixture *configPortFixture) ReadConfig(context.Context) (map[string]string, error) { - return fixture.fields, nil -} -func (fixture *configPortFixture) ApplyConfigPatch(_ context.Context, _ string, fields []ConfigFieldPatch) (map[string]string, error) { - fixture.patches = fields - return map[string]string{"ServerName": "Moon", "hostPath": "C:/secret"}, nil -} - -type notificationPortFixture struct { - deliveries []ue4SSPlayerNotification - accepted bool -} - -type rewardPortFixture struct { - grants []RewardGrant - receipt DeliveryReceipt - err error -} - -func (fixture *rewardPortFixture) DeliverReward(_ context.Context, grant RewardGrant) (DeliveryReceipt, error) { - fixture.grants = append(fixture.grants, grant) - return fixture.receipt, fixture.err -} - -type eventPortFixture struct { - requests []EventStartRequest - receipt EventStartReceipt - err error -} - -func (fixture *eventPortFixture) StartEvent(_ context.Context, request EventStartRequest) (EventStartReceipt, error) { - fixture.requests = append(fixture.requests, request) - return fixture.receipt, fixture.err -} - -// nonProductionVehicleSpawnPortFixture is an isolated test double. It has no -// network, socket, credential, or raw-command entry point; it can observe only -// the Companion's private typed request and return a bounded receipt. -type nonProductionVehicleSpawnPortFixture struct { - requests []ue4SSVehicleSpawn - receipt UE4SSVehicleSpawnReceipt - err error -} - -func (fixture *nonProductionVehicleSpawnPortFixture) SpawnVehicle(_ context.Context, request ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) { - fixture.requests = append(fixture.requests, request) - return fixture.receipt, fixture.err -} - -func (fixture *notificationPortFixture) SendPlayerNotification(_ context.Context, notification ue4SSPlayerNotification) (UE4SSNotificationReceipt, error) { - fixture.deliveries = append(fixture.deliveries, notification) - return UE4SSNotificationReceipt{Accepted: fixture.accepted}, nil -} - -func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testing.T) { - port := &configPortFixture{fields: map[string]string{"ServerName": "Moon", "hostPath": "C:/secret", "Password": "nope"}} - adapter := RuntimeAdapter{Config: port, DiagnosticsState: map[string]string{"status": "healthy", "hostPath": "C:/secret"}} - read, err := adapter.ReadConfiguration(context.Background()) - if err != nil { - t.Fatalf("read config: %v", err) - } - fields := read["fields"].(map[string]string) - if fields["ServerName"] != "Moon" || len(fields) != 1 { - t.Fatalf("unsafe config was exposed: %+v", fields) - } - patched, err := adapter.PatchConfiguration(context.Background(), map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moon"}}}) - if err != nil { - t.Fatalf("patch config: %v", err) - } - if len(port.patches) != 1 || patched["appliedFields"].(map[string]string)["hostPath"] != "" { - t.Fatalf("patch leaked unsafe details: %+v", patched) - } - diagnostics, _ := adapter.Diagnostics(context.Background()) - if diagnostics["hostPath"] != nil || diagnostics["status"] != "healthy" { - t.Fatalf("diagnostics leaked unsafe details: %+v", diagnostics) - } -} - -func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *testing.T) { - port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered", DeliveryID: "delivery-1"}} - adapter := RuntimeAdapter{Rewards: port} - result, err := adapter.DeliverReward(context.Background(), map[string]any{ - "grantId": "grant-1", "playerId": "76561198000000001", - "items": []any{map[string]any{"catalogCode": "BPC_Improvised_Backpack.01", "quantity": float64(2)}}, - "operations": []any{"#SpawnItem BPC_Improvised_Backpack.01 2"}, - }) - if err != nil || result["accepted"] != true || result["status"] != "delivered" || result["deliveryId"] != "delivery-1" { - t.Fatalf("reward result did not match the bridge schema: result=%+v err=%v", result, err) - } - if len(port.grants) != 1 || port.grants[0].Items[0] != (RewardItem{CatalogCode: "BPC_Improvised_Backpack.01", Quantity: 2}) || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SpawnItem BPC_Improvised_Backpack.01 2" { - t.Fatalf("reward items or operations did not reach the typed reward port: %+v", port.grants) - } - manyItems := make([]any, 9) - for index := range manyItems { - manyItems[index] = map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(101 + index)} - } - if _, err := adapter.DeliverReward(context.Background(), map[string]any{"grantId": "grant-many", "playerId": "76561198000000001", "items": manyItems, "operations": []any{}}); err != nil || len(port.grants) != 2 || len(port.grants[1].Items) != 9 || port.grants[1].Items[0].Quantity != 101 { - t.Fatalf("valid reward count or quantity was rejected: grants=%+v err=%v", port.grants, err) - } - before := len(port.grants) - if _, err := adapter.DeliverReward(context.Background(), map[string]any{ - "grantId": "grant-2", "playerId": "76561198000000001", - "items": []any{map[string]any{"catalogCode": "#SpawnItem BPC_Bad", "quantity": float64(1)}}, - "operations": []any{}, - }); err == nil || len(port.grants) != before { - t.Fatal("invalid catalog text reached the typed reward port") - } -} - -func TestEventStartAcceptsPositiveCountsAndDurationWithoutInventedUpperLimits(t *testing.T) { - produces := make([]any, 101) - for index := range produces { - produces[index] = map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(10001 + index), "r": float64(2000001 + index), "x": float64(3000000 + index), "y": float64(-3000000 - index), "z": float64(index)} - } - request, err := eventStartRequest(map[string]any{"eventId": "event-large", "eventType": "range", "class": float64(1), "title": "Large Event", "placard": "", "percent": float64(100), "npc": float64(10001), "item": float64(10002), "zombie": float64(10003), "animal": float64(10004), "produces": produces, "durationSeconds": float64(86401), "maxParticipants": float64(1001)}) - if err != nil || request.DurationSeconds != 86401 || request.MaxParticipants != 1001 || request.NPC != 10001 || len(request.Produces) != 101 || request.Produces[0].Value != 10001 || request.Produces[0].Radius != 2000001 { - t.Fatalf("valid event values above old limits were rejected: request=%+v err=%v", request, err) - } -} - -func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) { - port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}} - adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port} - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"reward.deliver": true}}, adapter) - stamp := time.Now().UTC() - result, err := registry.Execute(context.Background(), ClaimedCommand{ - ID: "reward-operations", ProfileKey: ProfileKey, CommandType: "reward.deliver", FencingToken: 1, - LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute), - Payload: map[string]any{"grantId": "grant-operations", "playerId": "76561198000000001", "items": []any{}, "operations": []any{"#SetFamePoints 250"}}, - }) - if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || len(port.grants) != 1 || len(port.grants[0].Items) != 0 || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SetFamePoints 250" { - t.Fatalf("operation-only reward was not preserved: result=%+v grants=%+v err=%v", result, port.grants, err) - } - if _, err := adapter.DeliverReward(context.Background(), map[string]any{ - "grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{}, - }); err == nil || len(port.grants) != 1 { - t.Fatalf("empty reward reached the typed reward port: grants=%+v err=%v", port.grants, err) - } -} - -func TestEventStartRequiresMatchingClassAndType(t *testing.T) { - payload := map[string]any{"eventId": "event-fixed", "eventType": "fixed", "class": float64(2), "title": "Fixed Event", "placard": "Hold the point", "percent": float64(100), "produces": []any{}, "durationSeconds": float64(600)} - request, err := eventStartRequest(payload) - if err != nil || request.EventType != "fixed" || request.Class != 2 { - t.Fatalf("fixed class event was not accepted: request=%+v err=%v", request, err) - } - payload["class"] = float64(1) - if _, err := eventStartRequest(payload); err == nil { - t.Fatal("divergent event class and type was accepted") - } -} - -func TestEventStartHandlerInvokesTypedPortAndReturnsCachedCommandResult(t *testing.T) { - stamp := time.Now().UTC() - port := &eventPortFixture{receipt: EventStartReceipt{Accepted: true, Status: "started", EventID: "event-1", Message: "range event started"}} - adapter := RuntimeAdapter{BoundServerID: "server-1", Events: port} - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"event.start": true}}, adapter) - command := ClaimedCommand{ - ID: "event-command-1", ProfileKey: ProfileKey, CommandType: "event.start", FencingToken: 1, - LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute), - Payload: map[string]any{"eventId": "event-1", "eventType": "range", "class": float64(1), "title": "Friday Range", "placard": "Event starting", "percent": float64(75), "npc": float64(2), "item": float64(3), "zombie": float64(4), "animal": float64(1), "produces": []any{map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(2), "r": float64(500), "x": float64(1000), "y": float64(2000), "z": float64(300)}}, "durationSeconds": float64(1800), "maxParticipants": float64(40), "announce": true}, - } - for range 2 { - result, err := registry.Execute(context.Background(), command) - if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || result.Payload["status"] != "started" || result.Payload["eventId"] != "event-1" { - t.Fatalf("event.start did not return its executable result: result=%+v err=%v", result, err) - } - } - if len(port.requests) != 1 { - t.Fatalf("event.start handler did not execute exactly once: %+v", port.requests) - } - request := port.requests[0] - if request.EventID != "event-1" || request.EventType != "range" || request.Class != 1 || request.Title != "Friday Range" || request.Placard != "Event starting" || request.Percent != 75 || request.NPC != 2 || request.Item != 3 || request.Zombie != 4 || request.Animal != 1 || len(request.Produces) != 1 || request.Produces[0].TradeGoodsID != "cargo-drop" || request.DurationSeconds != 1800 || request.MaxParticipants != 40 || !request.Announce { - t.Fatalf("event.start payload did not reach the typed event port: %+v", request) - } -} - -func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) { - port := ¬ificationPortFixture{accepted: true} - adapter := RuntimeAdapter{BoundServerID: "server-1", 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.localCommandPreview != "SendChat 4 \"Moon \\\"gift\\\"\" 76561198000000001" { - t.Fatalf("notification did not use the fixed UE4SS contract: %+v", delivery) - } - if result["message"] == delivery.localCommandPreview || result["command"] != nil || result["rcon"] != nil { - t.Fatalf("notification leaked protected transport details: %+v", result) - } -} - -func TestRuntimeNotificationRejectsInvalidRecipient(t *testing.T) { - port := ¬ificationPortFixture{accepted: true} - adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port} - 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 := RuntimeAdapter{BoundServerID: "server-1", Notification: port} - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", 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)) - } -} - -func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivatePreviewOnly(t *testing.T) { - port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnAccepted}} - adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port} - result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"}) - if err != nil || result["outcome"] != "succeeded" || len(port.requests) != 1 { - t.Fatalf("fixed vehicle spawn was not delivered: result=%+v err=%v requests=%+v", result, err, port.requests) - } - request := port.requests[0] - if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.localCommandPreview != "#spawnvehicle BPC_Laika_C" { - t.Fatalf("vehicle spawn did not use the fixed template: %+v", request) - } - if result["command"] != nil || result["rcon"] != nil || result["outcome"] == request.localCommandPreview { - t.Fatalf("vehicle spawn leaked protected transport details: %+v", result) - } -} - -func TestVersionedVehicleSpawnFailsClosedAndClassifiesBoundedReceipts(t *testing.T) { - port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}} - adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port} - for name, testCase := range map[string]struct { - outcome string - receipt UE4SSVehicleSpawnReceipt - err error - }{ - "failed": {outcome: "failed", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}}, - "unknown": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}}, - "unknown-transport": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{}, err: errors.New("isolated transport timeout")}, - } { - port.receipt, port.err = testCase.receipt, testCase.err - result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_WolfsWagen_C"}) - if err != nil || result["outcome"] != testCase.outcome { - t.Fatalf("receipt %s was not safely classified: result=%+v err=%v", name, result, err) - } - } - before := len(port.requests) - if _, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "#spawnvehicle BPC_Laika_C"}); err == nil || len(port.requests) != before { - t.Fatal("raw command text must not reach the vehicle transport") - } -} - -func TestVehicleSpawnUnknownOutcomeIsCachedWithoutRetry(t *testing.T) { - stamp := time.Now().UTC() - port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}} - adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port} - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter) - command := ClaimedCommand{ID: "vehicle-unknown-1", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C"}, 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["outcome"] != "unknown" { - t.Fatalf("unknown vehicle outcome was not retained: result=%+v err=%v", result, err) - } - } - if len(port.requests) != 1 { - t.Fatalf("unknown vehicle outcome retried transport %d times", len(port.requests)) - } -} diff --git a/plugins/examples/scum-server-plugin/companion/client.go b/plugins/examples/scum-server-plugin/companion/client.go deleted file mode 100644 index 8adc24c..0000000 --- a/plugins/examples/scum-server-plugin/companion/client.go +++ /dev/null @@ -1,516 +0,0 @@ -package companion - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "crypto/tls" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os" - "sort" - "strconv" - "strings" - "sync" - "time" -) - -const ( - registerPath = "/api/v1/client-managers/register" - heartbeatPath = "/api/v1/client-managers/heartbeat" - claimPath = "/api/v1/game-client-bridge/companion/commands/claim" - snapshotPath = "/api/v1/game-client-bridge/companion/snapshots" - - maxRequestBytes = 128 * 1024 - maxResponseBytes = 4 * 1024 * 1024 -) - -type Options struct { - Now func() time.Time - Nonce func() (string, error) -} - -type Client struct { - config Config - proof string - httpClient *http.Client - now func() time.Time - nonce func() (string, error) - - mu sync.Mutex - sessionToken string - sessionExpiresAt time.Time - heartbeatSequence uint64 - snapshotSequences map[string]uint64 -} - -type Registration struct { - ExpiresAt time.Time - HeartbeatEverySeconds int - ServerTime time.Time -} - -type HealthReport struct { - Status string - Reason string -} - -type HeartbeatResult struct { - Status string - Health string - NextHeartbeatSeconds int - SessionExpiresAt time.Time - ServerTime time.Time -} - -type ClaimedCommand struct { - ID string `json:"id"` - ProfileKey string `json:"profileKey"` - CommandType string `json:"commandType"` - Payload map[string]any `json:"payload"` - Priority int `json:"priority"` - FencingToken uint64 `json:"fencingToken"` - ClaimedAt time.Time `json:"claimedAt"` - LeaseExpiresAt time.Time `json:"leaseExpiresAt"` - ExpiresAt time.Time `json:"expiresAt"` -} - -type CommandAck struct { - CommandID string `json:"commandId"` - State string `json:"state"` - FencingToken uint64 `json:"fencingToken"` - AcknowledgedAt time.Time `json:"acknowledgedAt"` -} - -type CommandResult struct { - Status string - Summary string - Payload map[string]any -} - -type CompletedCommand struct { - CommandID string `json:"commandId"` - State string `json:"state"` - UpdatedAt time.Time `json:"updatedAt"` - CompletedAt time.Time `json:"completedAt"` -} - -type Snapshot struct { - Type string - SchemaVersion string - StreamKey string - Sequence uint64 - ObservedAt time.Time - Payload map[string]any - KeepForSeconds int - MaxRecords int -} - -type AcceptedSnapshot struct { - SnapshotID string `json:"snapshotId"` - ProfileKey string `json:"profileKey"` - Type string `json:"type"` - SchemaVersion string `json:"schemaVersion"` - StreamKey string `json:"streamKey"` - Sequence uint64 `json:"sequence"` - AcceptedAt time.Time `json:"acceptedAt"` - ExpiresAt time.Time `json:"expiresAt"` -} - -type HTTPError struct { - StatusCode int - ExpectedStatus int -} - -func (err HTTPError) Error() string { - return fmt.Sprintf("platform request returned HTTP %d; expected %d", err.StatusCode, err.ExpectedStatus) -} - -func NewClient(config Config, options Options) (*Client, error) { - if err := config.Validate(); err != nil { - return nil, err - } - proof, configured := os.LookupEnv(config.Proof.MaterialEnv) - if !configured || proof == "" { - return nil, fmt.Errorf("component proof environment variable is not configured") - } - if len(proof) > 4096 { - return nil, fmt.Errorf("component proof environment variable is invalid") - } - - baseURL, err := canonicalPlatformOrigin(config.Platform.BaseURL) - if err != nil { - return nil, err - } - transport := secureDefaultTransport() - timeout := time.Duration(config.Timing.RequestTimeoutSeconds) * time.Second - httpClient := &http.Client{ - Transport: transport, - Timeout: timeout, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } - now := options.Now - if now == nil { - now = time.Now - } - nonce := options.Nonce - if nonce == nil { - nonce = randomNonce - } - config.Capabilities = append([]string(nil), config.Capabilities...) - config.Platform.BaseURL = baseURL - return &Client{config: config, proof: proof, httpClient: httpClient, now: now, nonce: nonce, snapshotSequences: make(map[string]uint64)}, nil -} - -func secureDefaultTransport() *http.Transport { - return &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, - ForceAttemptHTTP2: true, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: time.Second, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, - } -} - -func (client *Client) Register(ctx context.Context) (Registration, error) { - nonce, err := client.nonce() - if err != nil { - return Registration{}, fmt.Errorf("create component registration nonce: %w", err) - } - if !validNonce(nonce) { - return Registration{}, fmt.Errorf("component registration nonce is invalid") - } - component := client.config.Component - request := registerRequest{ - InstallationID: component.InstallationID, - ServerInstanceID: component.ServerInstanceID, - ProfileKey: component.ProfileKey, - ArtifactID: component.ArtifactID, - Version: component.Version, - SourceRevision: component.SourceRevision, - TargetOS: component.TargetOS, - TargetArch: component.TargetArch, - KeyGeneration: component.KeyGeneration, - DeploymentGeneration: component.DeploymentGeneration, - Capabilities: append([]string(nil), client.config.Capabilities...), - Timestamp: client.now().UTC(), - Nonce: nonce, - } - request.Signature = registrationSignature(client.proof, request) - var response registerResponse - if err := client.postJSON(ctx, registerPath, http.StatusOK, request, &response); err != nil { - return Registration{}, err - } - if !response.Accepted || response.InstallationID != component.InstallationID || response.SessionToken == "" || response.ExpiresAt.IsZero() || !client.now().Before(response.ExpiresAt) { - return Registration{}, fmt.Errorf("platform returned an invalid component registration") - } - client.mu.Lock() - client.sessionToken = response.SessionToken - client.sessionExpiresAt = response.ExpiresAt - client.heartbeatSequence = 0 - client.mu.Unlock() - return Registration{ExpiresAt: response.ExpiresAt, HeartbeatEverySeconds: response.HeartbeatEverySeconds, ServerTime: response.ServerTime}, nil -} - -func (client *Client) Heartbeat(ctx context.Context, report HealthReport) (HeartbeatResult, error) { - if report.Status != "healthy" && report.Status != "degraded" && report.Status != "unhealthy" && report.Status != "offline" { - return HeartbeatResult{}, fmt.Errorf("component health status is invalid") - } - token, sessionExpiresAt, sequence, err := client.nextHeartbeat() - if err != nil { - return HeartbeatResult{}, err - } - request := heartbeatRequest{ - InstallationID: client.config.Component.InstallationID, - SessionToken: token, - Sequence: sequence, - Health: report.Status, - HealthReason: report.Reason, - Capabilities: append([]string(nil), client.config.Capabilities...), - SentAt: client.now().UTC(), - } - var response heartbeatResponse - if err := client.postJSON(ctx, heartbeatPath, http.StatusOK, request, &response); err != nil { - return HeartbeatResult{}, err - } - if !response.Accepted || response.InstallationID != client.config.Component.InstallationID || !response.SessionExpiresAt.Equal(sessionExpiresAt) { - return HeartbeatResult{}, fmt.Errorf("platform returned an invalid component heartbeat") - } - return HeartbeatResult{Status: response.Status, Health: response.Health, NextHeartbeatSeconds: response.NextHeartbeatSeconds, SessionExpiresAt: response.SessionExpiresAt, ServerTime: response.ServerTime}, nil -} - -func (client *Client) ClaimCommands(ctx context.Context, limit int) ([]ClaimedCommand, error) { - if limit < 0 || limit > 50 { - return nil, fmt.Errorf("claim limit must be between 0 and 50") - } - token, err := client.currentSession() - if err != nil { - return nil, err - } - var response claimResponse - if err := client.postJSON(ctx, claimPath, http.StatusOK, claimRequest{SessionToken: token, Limit: limit}, &response); err != nil { - return nil, err - } - if response.Count != len(response.Items) { - return nil, fmt.Errorf("platform returned an invalid command claim batch") - } - return append([]ClaimedCommand(nil), response.Items...), nil -} - -func (client *Client) AckCommand(ctx context.Context, commandID string, fencingToken uint64) (CommandAck, error) { - if commandID == "" || fencingToken == 0 { - return CommandAck{}, fmt.Errorf("command ID and fencing token are required") - } - token, err := client.currentSession() - if err != nil { - return CommandAck{}, err - } - path := "/api/v1/game-client-bridge/companion/commands/" + url.PathEscape(commandID) + "/ack" - var response CommandAck - if err := client.postJSON(ctx, path, http.StatusOK, ackRequest{SessionToken: token, FencingToken: fencingToken}, &response); err != nil { - return CommandAck{}, err - } - return response, nil -} - -func (client *Client) CompleteCommand(ctx context.Context, commandID string, fencingToken uint64, result CommandResult) (CompletedCommand, error) { - if commandID == "" || fencingToken == 0 { - return CompletedCommand{}, fmt.Errorf("command ID and fencing token are required") - } - if result.Status != "succeeded" && result.Status != "failed" && result.Status != "cancelled" { - return CompletedCommand{}, fmt.Errorf("command result status is invalid") - } - token, err := client.currentSession() - if err != nil { - return CompletedCommand{}, err - } - path := "/api/v1/game-client-bridge/companion/commands/" + url.PathEscape(commandID) + "/result" - request := resultRequest{SessionToken: token, FencingToken: fencingToken, Status: result.Status, Summary: result.Summary, Payload: result.Payload} - var response CompletedCommand - if err := client.postJSON(ctx, path, http.StatusOK, request, &response); err != nil { - return CompletedCommand{}, err - } - return response, nil -} - -func (client *Client) UploadSnapshot(ctx context.Context, snapshot Snapshot) (AcceptedSnapshot, error) { - if snapshot.Type == "" || snapshot.SchemaVersion == "" || snapshot.StreamKey == "" || snapshot.Sequence == 0 || snapshot.ObservedAt.IsZero() || snapshot.Payload == nil || snapshot.KeepForSeconds <= 0 { - return AcceptedSnapshot{}, fmt.Errorf("typed snapshot is incomplete") - } - if err := client.reserveSnapshotSequence(snapshot); err != nil { - return AcceptedSnapshot{}, err - } - token, err := client.currentSession() - if err != nil { - return AcceptedSnapshot{}, err - } - request := snapshotRequest{ - SessionToken: token, Type: snapshot.Type, SchemaVersion: snapshot.SchemaVersion, StreamKey: snapshot.StreamKey, - Sequence: snapshot.Sequence, ObservedAt: snapshot.ObservedAt.UTC(), Payload: snapshot.Payload, - KeepForSeconds: snapshot.KeepForSeconds, MaxRecords: snapshot.MaxRecords, - } - var response AcceptedSnapshot - if err := client.postJSON(ctx, snapshotPath, http.StatusAccepted, request, &response); err != nil { - return AcceptedSnapshot{}, err - } - return response, nil -} - -func (client *Client) currentSession() (string, error) { - client.mu.Lock() - defer client.mu.Unlock() - if client.sessionToken == "" || client.sessionExpiresAt.IsZero() || !client.now().Before(client.sessionExpiresAt) { - return "", errors.New("component session is unavailable or expired") - } - return client.sessionToken, nil -} - -func (client *Client) nextHeartbeat() (string, time.Time, uint64, error) { - client.mu.Lock() - defer client.mu.Unlock() - if client.sessionToken == "" || client.sessionExpiresAt.IsZero() || !client.now().Before(client.sessionExpiresAt) { - return "", time.Time{}, 0, errors.New("component session is unavailable or expired") - } - client.heartbeatSequence++ - return client.sessionToken, client.sessionExpiresAt, client.heartbeatSequence, nil -} - -func (client *Client) reserveSnapshotSequence(snapshot Snapshot) error { - client.mu.Lock() - defer client.mu.Unlock() - key := snapshot.Type + "\x00" + snapshot.StreamKey - if snapshot.Sequence <= client.snapshotSequences[key] { - return fmt.Errorf("snapshot sequence must increase for its typed stream") - } - client.snapshotSequences[key] = snapshot.Sequence - return nil -} - -func (client *Client) postJSON(ctx context.Context, path string, expectedStatus int, payload any, target any) error { - encoded, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("encode platform request: %w", err) - } - if len(encoded) > maxRequestBytes { - return fmt.Errorf("platform request exceeds the bounded payload size") - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.config.Platform.BaseURL+path, bytes.NewReader(encoded)) - if err != nil { - return fmt.Errorf("create platform request: %w", err) - } - request.Header.Set("Accept", "application/json") - request.Header.Set("Content-Type", "application/json") - response, err := client.httpClient.Do(request) - if err != nil { - return fmt.Errorf("send platform request: %w", err) - } - defer response.Body.Close() - if response.StatusCode != expectedStatus { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) - return HTTPError{StatusCode: response.StatusCode, ExpectedStatus: expectedStatus} - } - decoder := json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes)) - if err := decoder.Decode(target); err != nil { - return fmt.Errorf("decode platform response: %w", err) - } - return nil -} - -func registrationSignature(proof string, request registerRequest) string { - capabilities := append([]string(nil), request.Capabilities...) - sort.Strings(capabilities) - canonical := strings.Join([]string{ - request.InstallationID, - request.ServerInstanceID, - request.ProfileKey, - request.ArtifactID, - request.Version, - request.SourceRevision, - request.TargetOS, - request.TargetArch, - strconv.Itoa(request.KeyGeneration), - strconv.Itoa(request.DeploymentGeneration), - request.Timestamp.UTC().Format(time.RFC3339Nano), - request.Nonce, - strings.Join(capabilities, ","), - }, "\n") - mac := hmac.New(sha256.New, []byte(proof)) - _, _ = mac.Write([]byte(canonical)) - return "sha256:" + hex.EncodeToString(mac.Sum(nil)) -} - -func randomNonce() (string, error) { - value := make([]byte, 24) - if _, err := rand.Read(value); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(value), nil -} - -func validNonce(value string) bool { - if len(value) < 16 || len(value) > 128 { - return false - } - for _, character := range value { - if character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '_' || character == '-' { - continue - } - return false - } - return true -} - -type registerRequest struct { - InstallationID string `json:"installationId"` - ServerInstanceID string `json:"serverInstanceId"` - ProfileKey string `json:"profileKey"` - ArtifactID string `json:"artifactId"` - Version string `json:"version"` - SourceRevision string `json:"sourceRevision"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - KeyGeneration int `json:"keyGeneration"` - DeploymentGeneration int `json:"deploymentGeneration"` - Capabilities []string `json:"capabilities"` - Timestamp time.Time `json:"timestamp"` - Nonce string `json:"nonce"` - Signature string `json:"signature"` -} - -type registerResponse struct { - Accepted bool `json:"accepted"` - InstallationID string `json:"installationId"` - SessionToken string `json:"sessionToken"` - ExpiresAt time.Time `json:"expiresAt"` - HeartbeatEverySeconds int `json:"heartbeatEverySeconds"` - ServerTime time.Time `json:"serverTime"` -} - -type heartbeatRequest struct { - InstallationID string `json:"installationId"` - SessionToken string `json:"sessionToken"` - Sequence uint64 `json:"sequence"` - Health string `json:"health"` - HealthReason string `json:"healthReason,omitempty"` - Capabilities []string `json:"capabilities"` - SentAt time.Time `json:"sentAt"` -} - -type heartbeatResponse struct { - Accepted bool `json:"accepted"` - InstallationID string `json:"installationId"` - Status string `json:"status"` - Health string `json:"health"` - NextHeartbeatSeconds int `json:"nextHeartbeatSeconds"` - SessionExpiresAt time.Time `json:"sessionExpiresAt"` - ServerTime time.Time `json:"serverTime"` -} - -type claimRequest struct { - SessionToken string `json:"sessionToken"` - Limit int `json:"limit,omitempty"` -} - -type claimResponse struct { - Items []ClaimedCommand `json:"items"` - Count int `json:"count"` -} - -type ackRequest struct { - SessionToken string `json:"sessionToken"` - FencingToken uint64 `json:"fencingToken"` -} - -type resultRequest struct { - SessionToken string `json:"sessionToken"` - FencingToken uint64 `json:"fencingToken"` - Status string `json:"status"` - Summary string `json:"summary,omitempty"` - Payload map[string]any `json:"payload,omitempty"` -} - -type snapshotRequest struct { - SessionToken string `json:"sessionToken"` - Type string `json:"type"` - SchemaVersion string `json:"schemaVersion"` - StreamKey string `json:"streamKey"` - Sequence uint64 `json:"sequence"` - ObservedAt time.Time `json:"observedAt"` - Payload map[string]any `json:"payload"` - KeepForSeconds int `json:"keepForSeconds"` - MaxRecords int `json:"maxRecords,omitempty"` -} diff --git a/plugins/examples/scum-server-plugin/companion/client_test.go b/plugins/examples/scum-server-plugin/companion/client_test.go deleted file mode 100644 index 8d356a2..0000000 --- a/plugins/examples/scum-server-plugin/companion/client_test.go +++ /dev/null @@ -1,519 +0,0 @@ -package companion - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "crypto/tls" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "sort" - "strconv" - "strings" - "testing" - "time" -) - -const testProof = "fixture-component-proof-material" - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { - return fn(request) -} - -func TestLoadConfigKeepsProofOutOfConfig(t *testing.T) { - file, err := os.Open("config.yaml.example") - if err != nil { - t.Fatalf("open config fixture: %v", err) - } - defer file.Close() - config, err := LoadConfig(file) - if err != nil { - t.Fatalf("load config fixture: %v", err) - } - encoded, err := json.Marshal(config) - if err != nil { - t.Fatalf("marshal config fixture: %v", err) - } - serialized := string(encoded) - for _, forbidden := range []string{testProof, "scum_client_credential", "sessionToken", "/api/v1/scum-clients/", "InsecureSkipVerify"} { - if strings.Contains(serialized, forbidden) { - t.Fatalf("config exposed forbidden content %q: %s", forbidden, serialized) - } - } - - t.Setenv(ProofEnvironment, "") - if _, err := NewClient(config, Options{}); err == nil { - t.Fatal("expected missing proof environment to be rejected") - } - t.Setenv(ProofEnvironment, testProof) - if _, err := NewClient(config, Options{}); err != nil { - t.Fatalf("create client from proof environment: %v", err) - } -} - -func TestLoadConfigRejectsLegacySharedCredentialFields(t *testing.T) { - fixture, err := os.ReadFile("config.yaml.example") - if err != nil { - t.Fatalf("read config fixture: %v", err) - } - for name, legacy := range map[string]string{ - "legacy endpoint": "server_url: https://legacy.example.test\n", - "shared credential": "scum_client_credential: legacy-shared-value\n", - } { - t.Run(name, func(t *testing.T) { - if _, err := LoadConfig(strings.NewReader(string(fixture) + legacy)); err == nil { - t.Fatalf("expected legacy config field to be rejected: %s", legacy) - } - }) - } -} - -func TestNewClientRejectsHTTPPlatformBaseURL(t *testing.T) { - config := loadTestConfig(t) - config.Platform.BaseURL = "http://platform.example.test" - t.Setenv(ProofEnvironment, testProof) - - if _, err := NewClient(config, Options{}); err == nil || !strings.Contains(err.Error(), "HTTPS") { - t.Fatalf("expected HTTP platform origin to be rejected, got %v", err) - } -} - -func TestNewClientCanonicalizesPlatformOriginAndRejectsInvalidPorts(t *testing.T) { - t.Setenv(ProofEnvironment, testProof) - config := loadTestConfig(t) - config.Platform.BaseURL = " https://platform.example.test:8443/ " - client, err := NewClient(config, Options{}) - if err != nil { - t.Fatalf("create client: %v", err) - } - if client.config.Platform.BaseURL != "https://platform.example.test:8443" { - t.Fatalf("platform origin was not canonicalized: %q", client.config.Platform.BaseURL) - } - - for _, baseURL := range []string{"https://:443", "https://platform.example.test:", "https://platform.example.test:65536"} { - config.Platform.BaseURL = baseURL - if _, err := NewClient(config, Options{}); err == nil { - t.Fatalf("expected invalid platform origin to be rejected: %q", baseURL) - } - } -} - -func TestNewClientUsesBoundedConfiguredRequestTimeout(t *testing.T) { - config := loadTestConfig(t) - config.Timing.RequestTimeoutSeconds = 7 - t.Setenv(ProofEnvironment, testProof) - - client, err := NewClient(config, Options{}) - if err != nil { - t.Fatalf("create client: %v", err) - } - if client.httpClient.Timeout != 7*time.Second { - t.Fatalf("unexpected request timeout: %s", client.httpClient.Timeout) - } -} - -func TestNewClientDefaultTransportVerifiesSystemRootsWithTLS12Minimum(t *testing.T) { - config := loadTestConfig(t) - t.Setenv(ProofEnvironment, testProof) - - client, err := NewClient(config, Options{}) - if err != nil { - t.Fatalf("create client: %v", err) - } - transport, ok := client.httpClient.Transport.(*http.Transport) - if !ok { - t.Fatalf("unexpected transport type: %T", client.httpClient.Transport) - } - if transport == http.DefaultTransport { - t.Fatal("client must own its transport") - } - if transport.TLSClientConfig == nil { - t.Fatal("client transport must declare an explicit TLS policy") - } - if transport.TLSClientConfig.InsecureSkipVerify { - t.Fatal("client transport must verify server certificates") - } - if transport.TLSClientConfig.MinVersion < tls.VersionTLS12 { - t.Fatalf("client transport allows TLS below 1.2: %x", transport.TLSClientConfig.MinVersion) - } - if transport.TLSClientConfig.RootCAs != nil { - t.Fatal("nil RootCAs must select the host system root pool") - } - if client.httpClient.CheckRedirect == nil { - t.Fatal("client must explicitly reject redirects") - } -} - -func TestNewClientRejectsUntrustedTLSServerByDefault(t *testing.T) { - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{}`) - })) - defer server.Close() - - config := loadTestConfig(t) - config.Platform.BaseURL = server.URL - t.Setenv(ProofEnvironment, testProof) - client, err := NewClient(config, Options{ - Now: func() time.Time { return time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) }, - Nonce: func() (string, error) { return "nonce-fixture-untrusted-tls", nil }, - }) - if err != nil { - t.Fatalf("create client: %v", err) - } - - _, err = client.Register(context.Background()) - var verificationError *tls.CertificateVerificationError - if !errors.As(err, &verificationError) { - t.Fatalf("expected untrusted TLS certificate verification failure, got %v", err) - } -} - -func TestClientRejectsRedirectWithoutReplayingComponentSession(t *testing.T) { - stamp := time.Date(2026, 7, 20, 9, 30, 0, 0, time.UTC) - config := loadTestConfig(t) - requests := 0 - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - requests++ - switch requests { - case 1: - assertPlatformRequest(t, request, registerPath) - return jsonHTTPResponse(http.StatusOK, registerResponse{ - Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", - ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp, - }), nil - case 2: - assertPlatformRequest(t, request, claimPath) - var body claimRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-one" { - t.Fatalf("claim did not carry the component session: %+v", body) - } - response := jsonHTTPResponse(http.StatusTemporaryRedirect, nil) - response.Header.Set("Location", "https://redirect.example.test/capture") - return response, nil - default: - t.Fatalf("redirect replayed component material to %s", request.URL.String()) - return nil, fmt.Errorf("unexpected redirected request") - } - }) - client := newTestClient(t, config, transport, stamp) - if _, err := client.Register(context.Background()); err != nil { - t.Fatalf("register client: %v", err) - } - _, err := client.ClaimCommands(context.Background(), 1) - var statusError HTTPError - if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusTemporaryRedirect { - t.Fatalf("expected redirect response to be rejected, got %v", err) - } - if requests != 2 { - t.Fatalf("redirect unexpectedly triggered %d requests", requests) - } -} - -func TestRegisterUsesExactCanonicalHMAC(t *testing.T) { - stamp := time.Date(2026, 7, 20, 5, 4, 3, 123456789, time.UTC) - config := loadTestConfig(t) - for left, right := 0, len(config.Capabilities)-1; left < right; left, right = left+1, right-1 { - config.Capabilities[left], config.Capabilities[right] = config.Capabilities[right], config.Capabilities[left] - } - var captured registerRequest - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - assertPlatformRequest(t, request, registerPath) - decodeRequest(t, request, &captured) - if strings.Contains(captured.Signature, testProof) { - t.Fatal("registration signature contains raw proof") - } - return jsonHTTPResponse(http.StatusOK, registerResponse{ - Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", - ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp, - }), nil - }) - client := newTestClient(t, config, transport, stamp) - registration, err := client.Register(context.Background()) - if err != nil { - t.Fatalf("register client: %v", err) - } - if registration.HeartbeatEverySeconds != 30 || !registration.ExpiresAt.Equal(stamp.Add(15*time.Minute)) { - t.Fatalf("unexpected registration projection: %+v", registration) - } - if captured.Timestamp.Format(time.RFC3339Nano) != "2026-07-20T05:04:03.123456789Z" { - t.Fatalf("registration timestamp was not RFC3339Nano UTC: %s", captured.Timestamp.Format(time.RFC3339Nano)) - } - expected := independentRegistrationSignature(testProof, captured) - if captured.Signature != expected { - t.Fatalf("unexpected registration signature: got %s want %s", captured.Signature, expected) - } - if captured.Nonce != "nonce-fixture-registration-0001" { - t.Fatalf("unexpected registration nonce: %s", captured.Nonce) - } -} - -func TestSnapshotSequenceContinuesAcrossComponentSessions(t *testing.T) { - stamp := time.Date(2026, 7, 20, 7, 0, 0, 0, time.UTC) - config := loadTestConfig(t) - step := 0 - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - switch step { - case 0, 2: - assertPlatformRequest(t, request, registerPath) - step++ - session := "component-session-one" - if step == 3 { - session = "component-session-two" - } - return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: session, ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil - case 1, 3: - assertPlatformRequest(t, request, snapshotPath) - var body snapshotRequest - decodeRequest(t, request, &body) - expectedSession := "component-session-one" - expectedSequence := uint64(10) - if step == 3 { - expectedSession = "component-session-two" - expectedSequence = 11 - } - if body.SessionToken != expectedSession || body.Sequence != expectedSequence { - t.Fatalf("snapshot did not continue across component sessions: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: fmt.Sprintf("snapshot-%d", body.Sequence), ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil - default: - return nil, fmt.Errorf("unexpected request %s", request.URL.Path) - } - }) - t.Setenv(ProofEnvironment, testProof) - nonce := 0 - client, err := NewClient(config, Options{ - Now: func() time.Time { return stamp }, - Nonce: func() (string, error) { - nonce++ - return fmt.Sprintf("nonce-fixture-session-%04d", nonce), nil - }, - }) - if err != nil { - t.Fatalf("create client: %v", err) - } - client.httpClient.Transport = transport - upload := func(sequence uint64) error { - _, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000}) - return err - } - if _, err := client.Register(context.Background()); err != nil { - t.Fatalf("register first session: %v", err) - } - if err := upload(10); err != nil { - t.Fatalf("upload first session snapshot: %v", err) - } - if _, err := client.Register(context.Background()); err != nil { - t.Fatalf("register replacement session: %v", err) - } - if err := upload(10); err == nil || !strings.Contains(err.Error(), "sequence") { - t.Fatalf("expected stale sequence to be rejected before transport, got %v", err) - } - if err := upload(11); err != nil { - t.Fatalf("upload replacement session snapshot: %v", err) - } - if step != 4 { - t.Fatalf("expected four transport requests, got %d", step) - } -} - -func TestSnapshotRequiresHTTPAccepted(t *testing.T) { - stamp := time.Date(2026, 7, 20, 8, 0, 0, 0, time.UTC) - config := loadTestConfig(t) - step := 0 - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - step++ - if step == 1 { - return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil - } - return jsonHTTPResponse(http.StatusOK, AcceptedSnapshot{}), nil - }) - client := newTestClient(t, config, transport, stamp) - if _, err := client.Register(context.Background()); err != nil { - t.Fatalf("register client: %v", err) - } - _, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000}) - var statusError HTTPError - if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusOK || statusError.ExpectedStatus != http.StatusAccepted { - t.Fatalf("expected exact 202 enforcement, got %v", err) - } -} - -func TestComponentSessionHeartbeatAndBridgeTransport(t *testing.T) { - stamp := time.Date(2026, 7, 20, 6, 0, 0, 0, time.UTC) - config := loadTestConfig(t) - sessionExpiry := stamp.Add(15 * time.Minute) - step := 0 - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - if strings.Contains(request.URL.Path, "/api/v1/scum-clients/") { - t.Fatalf("legacy endpoint used: %s", request.URL.Path) - } - switch step { - case 0: - assertPlatformRequest(t, request, registerPath) - var body registerRequest - decodeRequest(t, request, &body) - step++ - return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil - case 1: - assertPlatformRequest(t, request, heartbeatPath) - var body heartbeatRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-one" || body.Sequence != 1 || body.Health != "healthy" || body.HealthReason != "bridge ready" { - t.Fatalf("unexpected heartbeat body: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil - case 2: - assertPlatformRequest(t, request, claimPath) - var body claimRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-one" || body.Limit != 5 { - t.Fatalf("unexpected claim body: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "command-1", ProfileKey: ProfileKey, CommandType: "companion.diagnostics", Payload: map[string]any{"includeWindowState": true}, FencingToken: 9, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil - case 3: - assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/ack") - var body ackRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-one" || body.FencingToken != 9 { - t.Fatalf("unexpected ack body: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusOK, CommandAck{CommandID: "command-1", State: "claimed", FencingToken: 9, AcknowledgedAt: stamp}), nil - case 4: - assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/result") - var body resultRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-one" || body.FencingToken != 9 || body.Status != "succeeded" || body.Summary != "diagnostics collected" || body.Payload["entries"] != float64(3) { - t.Fatalf("unexpected result body: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusOK, map[string]any{"commandId": "command-1", "state": "succeeded", "result": map[string]any{"status": "succeeded", "summary": "diagnostics collected", "completedAt": stamp}, "updatedAt": stamp, "completedAt": stamp}), nil - case 5: - assertPlatformRequest(t, request, snapshotPath) - var body snapshotRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-one" || body.Type != "companion.health" || body.SchemaVersion != "1" || body.StreamKey != "current" || body.Sequence != 1 || body.KeepForSeconds != 604800 || body.MaxRecords != 1000 { - t.Fatalf("unexpected snapshot body: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: "snapshot-1", ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil - default: - return nil, fmt.Errorf("unexpected request %s", request.URL.Path) - } - }) - client := newTestClient(t, config, transport, stamp) - if _, err := client.Register(context.Background()); err != nil { - t.Fatalf("register client: %v", err) - } - if _, err := client.Heartbeat(context.Background(), HealthReport{Status: "healthy", Reason: "bridge ready"}); err != nil { - t.Fatalf("heartbeat: %v", err) - } - commands, err := client.ClaimCommands(context.Background(), 5) - if err != nil || len(commands) != 1 { - t.Fatalf("claim commands: commands=%+v err=%v", commands, err) - } - if _, err := client.AckCommand(context.Background(), commands[0].ID, commands[0].FencingToken); err != nil { - t.Fatalf("ack command: %v", err) - } - if _, err := client.CompleteCommand(context.Background(), commands[0].ID, commands[0].FencingToken, CommandResult{Status: "succeeded", Summary: "diagnostics collected", Payload: map[string]any{"entries": 3}}); err != nil { - t.Fatalf("complete command: %v", err) - } - snapshot := Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000} - accepted, err := client.UploadSnapshot(context.Background(), snapshot) - if err != nil || accepted.SnapshotID != "snapshot-1" { - t.Fatalf("upload snapshot: accepted=%+v err=%v", accepted, err) - } - if step != 6 { - t.Fatalf("expected six fixed API requests, got %d", step) - } -} - -func loadTestConfig(t *testing.T) Config { - t.Helper() - file, err := os.Open("config.yaml.example") - if err != nil { - t.Fatalf("open config fixture: %v", err) - } - defer file.Close() - config, err := LoadConfig(file) - if err != nil { - t.Fatalf("load config fixture: %v", err) - } - return config -} - -func newTestClient(t *testing.T, config Config, transport http.RoundTripper, stamp time.Time) *Client { - t.Helper() - t.Setenv(ProofEnvironment, testProof) - client, err := NewClient(config, Options{ - Now: func() time.Time { return stamp }, - Nonce: func() (string, error) { return "nonce-fixture-registration-0001", nil }, - }) - if err != nil { - t.Fatalf("create test client: %v", err) - } - client.httpClient.Transport = transport - return client -} - -func assertPlatformRequest(t *testing.T, request *http.Request, expectedPath string) { - t.Helper() - if request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "platform.example.test" || request.URL.Path != expectedPath { - t.Fatalf("unexpected platform request: %s %s", request.Method, request.URL.String()) - } - if request.Header.Get("Authorization") != "" { - t.Fatalf("component session must be carried in the typed JSON body, got Authorization header") - } - if request.Header.Get("Content-Type") != "application/json" || request.Header.Get("Accept") != "application/json" { - t.Fatalf("unexpected request headers: %+v", request.Header) - } -} - -func decodeRequest(t *testing.T, request *http.Request, target any) { - t.Helper() - decoder := json.NewDecoder(request.Body) - if err := decoder.Decode(target); err != nil { - t.Fatalf("decode request body: %v", err) - } -} - -func jsonHTTPResponse(status int, payload any) *http.Response { - encoded, _ := json.Marshal(payload) - return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))} -} - -func independentRegistrationSignature(proof string, request registerRequest) string { - capabilities := append([]string(nil), request.Capabilities...) - sort.Strings(capabilities) - canonical := strings.Join([]string{ - request.InstallationID, - request.ServerInstanceID, - request.ProfileKey, - request.ArtifactID, - request.Version, - request.SourceRevision, - request.TargetOS, - request.TargetArch, - strconv.Itoa(request.KeyGeneration), - strconv.Itoa(request.DeploymentGeneration), - request.Timestamp.UTC().Format(time.RFC3339Nano), - request.Nonce, - strings.Join(capabilities, ","), - }, "\n") - mac := hmac.New(sha256.New, []byte(proof)) - _, _ = mac.Write([]byte(canonical)) - return "sha256:" + hex.EncodeToString(mac.Sum(nil)) -} diff --git a/plugins/examples/scum-server-plugin/companion/cmd/scum-companion-smoke/main.go b/plugins/examples/scum-server-plugin/companion/cmd/scum-companion-smoke/main.go deleted file mode 100644 index 068ce51..0000000 --- a/plugins/examples/scum-server-plugin/companion/cmd/scum-companion-smoke/main.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "os" - "time" - - companion "browser.local/plugins/scum-server-plugin/companion" -) - -func main() { - result := companion.SmokeResult{CompletedCommandIDs: []string{}, UnsupportedCommandIDs: []string{}} - if len(os.Args) != 1 { - writeResult(result) - os.Exit(1) - } - file, err := os.Open("config.yaml") - if err != nil { - writeResult(result) - os.Exit(1) - } - config, err := companion.LoadConfig(file) - closeErr := file.Close() - if err != nil || closeErr != nil { - writeResult(result) - os.Exit(1) - } - client, err := companion.NewClient(config, companion.Options{}) - if err != nil { - writeResult(result) - os.Exit(1) - } - _ = os.Unsetenv(companion.ProofEnvironment) - isolated := os.Getenv(companion.SmokeIsolationEnvironment) == companion.SmokeIsolationValue - requestBudget := time.Duration(config.Timing.RequestTimeoutSeconds) * time.Second * time.Duration(2*companion.SmokeClaimLimit+4) - if requestBudget > 45*time.Second { - requestBudget = 45 * time.Second - } - ctx, cancel := context.WithTimeout(context.Background(), requestBudget) - defer cancel() - result, err = companion.RunOneShotSmoke(ctx, client, companion.SmokeOptions{IsolatedNonProduction: isolated}) - writeResult(result) - if err != nil { - os.Exit(1) - } -} - -func writeResult(result companion.SmokeResult) { - _ = json.NewEncoder(os.Stdout).Encode(result) -} diff --git a/plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go b/plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go deleted file mode 100644 index 6daff1e..0000000 --- a/plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "context" - "errors" - "log" - "os" - "os/signal" - "syscall" - "time" - - companion "browser.local/plugins/scum-server-plugin/companion" -) - -func main() { - log.SetFlags(log.Ldate | log.Ltime | log.LUTC) - config, err := loadConfig() - if err != nil { - log.Printf("SCUM companion configuration failed: %v", err) - os.Exit(1) - } - client, err := companion.NewClient(config, companion.Options{}) - if err != nil { - log.Printf("SCUM companion client failed: %v", err) - os.Exit(1) - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - sequenceStore, err := companion.NewFileSnapshotSequenceStore(companion.SnapshotSequenceFilename) - if err != nil { - log.Printf("SCUM companion sequence state failed: %v", err) - os.Exit(1) - } - projection := companion.NewSCUMPlayerLogProjection(client, config.Component.ServerInstanceID) - projection.SequenceStore = sequenceStore - collector := companion.NewConsoleLogCollector(client, noopLogStore{}, config.Component.ServerInstanceID, os.Getenv(config.Proof.MaterialEnv)) - collector.OnSemanticEvents = projection.Handle - runtime := companion.Runtime{ - Client: client, - Dispatcher: companion.Dispatcher{Client: client, Registry: companion.NewHandlerRegistry(companion.HandlerAvailability{BoundServerID: config.Component.ServerInstanceID, Approved: true, Capabilities: map[string]bool{"companion.diagnostics": true}}, companion.RuntimeAdapter{BoundServerID: config.Component.ServerInstanceID}), PollLimit: 10, Backoff: 2 * time.Second}, - HeartbeatEvery: time.Duration(config.Timing.HeartbeatIntervalSeconds) * time.Second, - PollEvery: time.Duration(config.Timing.CommandPollIntervalSeconds) * time.Second, - Backoff: 2 * time.Second, - } - errorsCh := make(chan error, 2) - go func() { errorsCh <- runtime.Run(ctx) }() - go func() { errorsCh <- collector.Run(ctx) }() - if err := <-errorsCh; err != nil && !errors.Is(err, context.Canceled) { - log.Printf("SCUM companion stopped: %v", err) - os.Exit(1) - } -} - -func loadConfig() (companion.Config, error) { - file, err := os.Open("config.yaml") - if err != nil { - return companion.Config{}, err - } - defer file.Close() - return companion.LoadConfig(file) -} - -type noopLogStore struct{} - -func (noopLogStore) EnsureSchema(context.Context) error { return nil } -func (noopLogStore) StoreConsoleRecords(context.Context, []companion.ConsoleRecord) (int, error) { - return 0, nil -} -func (noopLogStore) StoreSemanticEventBatch(_ context.Context, batch companion.SemanticEventBatch) (int, error) { - return len(batch.Events), nil -} diff --git a/plugins/examples/scum-server-plugin/companion/config.go b/plugins/examples/scum-server-plugin/companion/config.go deleted file mode 100644 index 06d1104..0000000 --- a/plugins/examples/scum-server-plugin/companion/config.go +++ /dev/null @@ -1,179 +0,0 @@ -package companion - -import ( - "fmt" - "io" - "net/url" - "strconv" - "strings" - - "gopkg.in/yaml.v3" -) - -const ( - ConfigSchemaVersion = 1 - PluginID = "game.scum" - ProfileKey = "scum-client-manager" - ProofEnvironment = "SCUM_COMPONENT_PROOF" -) - -var requiredCapabilities = []string{ - "component.register", - "component.heartbeat", - "component.health", - "component.control", - "game-client.bridge", - "logs.stream", -} -var optionalCapabilities = map[string]struct{}{ - "handler.vehicle.spawn": {}, -} -var requiredCapabilitySet = map[string]struct{}{ - "component.register": {}, "component.heartbeat": {}, "component.health": {}, "component.control": {}, "game-client.bridge": {}, "logs.stream": {}, -} - -type Config struct { - SchemaVersion int `json:"schemaVersion" yaml:"schemaVersion"` - Platform PlatformConfig `json:"platform" yaml:"platform"` - Component ComponentConfig `json:"component" yaml:"component"` - Proof ProofConfig `json:"proof" yaml:"proof"` - Session SessionConfig `json:"session" yaml:"session"` - Capabilities []string `json:"capabilities" yaml:"capabilities"` - Timing TimingConfig `json:"timing" yaml:"timing"` - TLS TransportTLSConfig `json:"tls" yaml:"tls"` -} - -type PlatformConfig struct { - BaseURL string `json:"baseUrl" yaml:"baseUrl"` -} - -type ComponentConfig struct { - InstallationID string `json:"installationId" yaml:"installationId"` - ServerInstanceID string `json:"serverInstanceId" yaml:"serverInstanceId"` - PluginID string `json:"pluginId" yaml:"pluginId"` - ProfileKey string `json:"profileKey" yaml:"profileKey"` - ArtifactID string `json:"artifactId" yaml:"artifactId"` - Version string `json:"version" yaml:"version"` - SourceRevision string `json:"sourceRevision" yaml:"sourceRevision"` - TargetOS string `json:"targetOs" yaml:"targetOs"` - TargetArch string `json:"targetArch" yaml:"targetArch"` - KeyGeneration int `json:"keyGeneration" yaml:"keyGeneration"` - DeploymentGeneration int `json:"deploymentGeneration" yaml:"deploymentGeneration"` -} - -type ProofConfig struct { - Mode string `json:"mode" yaml:"mode"` - MaterialEnv string `json:"materialEnv" yaml:"materialEnv"` -} - -type SessionConfig struct { - Mode string `json:"mode" yaml:"mode"` -} - -type TimingConfig struct { - HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds" yaml:"heartbeatIntervalSeconds"` - CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds" yaml:"commandPollIntervalSeconds"` - RequestTimeoutSeconds int `json:"requestTimeoutSeconds" yaml:"requestTimeoutSeconds"` -} - -type TransportTLSConfig struct { - Policy string `json:"policy" yaml:"policy"` -} - -func LoadConfig(reader io.Reader) (Config, error) { - decoder := yaml.NewDecoder(reader) - decoder.KnownFields(true) - var config Config - if err := decoder.Decode(&config); err != nil { - return Config{}, fmt.Errorf("decode companion config: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - if err == nil { - return Config{}, fmt.Errorf("decode companion config: multiple documents are not allowed") - } - return Config{}, fmt.Errorf("decode companion config: %w", err) - } - if err := config.Validate(); err != nil { - return Config{}, err - } - config.Platform.BaseURL, _ = canonicalPlatformOrigin(config.Platform.BaseURL) - config.Capabilities = append([]string(nil), config.Capabilities...) - return config, nil -} - -func (config Config) Validate() error { - if config.SchemaVersion != ConfigSchemaVersion { - return fmt.Errorf("companion config schema version is unsupported") - } - if _, err := canonicalPlatformOrigin(config.Platform.BaseURL); err != nil { - return err - } - component := config.Component - if component.InstallationID == "" || component.ServerInstanceID == "" || component.ArtifactID == "" || component.Version == "" || component.SourceRevision == "" { - return fmt.Errorf("component identity is incomplete") - } - if component.PluginID != PluginID || component.ProfileKey != ProfileKey || component.TargetOS != "windows" || component.TargetArch != "amd64" { - return fmt.Errorf("component identity does not match the SCUM companion profile") - } - if component.KeyGeneration <= 0 || component.DeploymentGeneration <= 0 { - return fmt.Errorf("component generations must be positive") - } - if config.Proof.Mode != "hmac-sha256" || config.Proof.MaterialEnv != ProofEnvironment { - return fmt.Errorf("component proof policy is unsupported") - } - if config.Session.Mode != "component-session" { - return fmt.Errorf("component session policy is unsupported") - } - if config.TLS.Policy != "verify-system-roots" { - return fmt.Errorf("TLS policy must verify system roots") - } - if err := validateCapabilities(config.Capabilities); err != nil { - return err - } - if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 { - return fmt.Errorf("companion timing policy is invalid") - } - return nil -} - -func canonicalPlatformOrigin(value string) (string, error) { - parsed, err := url.Parse(strings.TrimSpace(value)) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" { - return "", fmt.Errorf("platform base URL must be a credential-free HTTPS origin") - } - port := parsed.Port() - if strings.HasSuffix(parsed.Host, ":") || port != "" { - value, err := strconv.Atoi(port) - if err != nil || value < 1 || value > 65535 { - return "", fmt.Errorf("platform base URL must use a valid HTTPS port") - } - } - return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String(), nil -} - -func validateCapabilities(capabilities []string) error { - if len(capabilities) < len(requiredCapabilities) || len(capabilities) > len(requiredCapabilities)+len(optionalCapabilities) { - return fmt.Errorf("component capabilities do not match the SCUM companion profile") - } - actual := make(map[string]struct{}, len(capabilities)) - for _, capability := range capabilities { - if _, exists := actual[capability]; exists { - return fmt.Errorf("component capabilities must be unique") - } - actual[capability] = struct{}{} - } - for _, capability := range requiredCapabilities { - if _, exists := actual[capability]; !exists { - return fmt.Errorf("component capabilities do not match the SCUM companion profile") - } - } - for capability := range actual { - if _, required := requiredCapabilitySet[capability]; !required { - if _, optional := optionalCapabilities[capability]; !optional { - return fmt.Errorf("component capabilities do not match the SCUM companion profile") - } - } - } - return nil -} diff --git a/plugins/examples/scum-server-plugin/companion/config.yaml.example b/plugins/examples/scum-server-plugin/companion/config.yaml.example deleted file mode 100644 index 81c2718..0000000 --- a/plugins/examples/scum-server-plugin/companion/config.yaml.example +++ /dev/null @@ -1,33 +0,0 @@ -schemaVersion: 1 -platform: - baseUrl: https://platform.example.test -component: - installationId: client-manager-installation-example - serverInstanceId: server-example - pluginId: game.scum - profileKey: scum-client-manager - artifactId: artifact-example - version: 1.0.0 - sourceRevision: example-revision - targetOs: windows - targetArch: amd64 - keyGeneration: 1 - deploymentGeneration: 1 -proof: - mode: hmac-sha256 - materialEnv: SCUM_COMPONENT_PROOF -session: - mode: component-session -capabilities: - - component.register - - component.heartbeat - - component.health - - component.control - - game-client.bridge - - logs.stream -timing: - heartbeatIntervalSeconds: 30 - commandPollIntervalSeconds: 5 - requestTimeoutSeconds: 15 -tls: - policy: verify-system-roots diff --git a/plugins/examples/scum-server-plugin/companion/config_test.go b/plugins/examples/scum-server-plugin/companion/config_test.go deleted file mode 100644 index 8aea965..0000000 --- a/plugins/examples/scum-server-plugin/companion/config_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package companion - -import "testing" - -func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) { - base := append([]string(nil), requiredCapabilities...) - if err := validateCapabilities(base); err != nil { - t.Fatalf("base companion profile must remain valid: %v", err) - } - if err := validateCapabilities(append(base, "handler.vehicle.spawn")); err != nil { - t.Fatalf("explicit vehicle handler declaration must be valid: %v", err) - } - if err := validateCapabilities(append(base, "handler.raw.rcon")); err == nil { - t.Fatal("undeclared raw command handler capability must be rejected") - } -} diff --git a/plugins/examples/scum-server-plugin/companion/console_collector.go b/plugins/examples/scum-server-plugin/companion/console_collector.go deleted file mode 100644 index 88a469f..0000000 --- a/plugins/examples/scum-server-plugin/companion/console_collector.go +++ /dev/null @@ -1,118 +0,0 @@ -package companion - -import ( - "context" - "fmt" - "strings" - "time" -) - -type ConsoleLogStreamClient interface { - StreamLogEvents(context.Context, func(LogStreamEvent) error) error -} - -type ConsoleLogStore interface { - EnsureSchema(context.Context) error - StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error) - StoreSemanticEventBatch(context.Context, SemanticEventBatch) (int, error) -} - -type ConsoleLogCollector struct { - Client ConsoleLogStreamClient - Store ConsoleLogStore - ServerInstanceID string - CorrelationSecret string - Backoff time.Duration - OnSemanticEvents func(context.Context, SemanticEventBatch) error -} - -func NewConsoleLogCollector(client ConsoleLogStreamClient, store ConsoleLogStore, serverInstanceID string, correlationSecret string) *ConsoleLogCollector { - return &ConsoleLogCollector{Client: client, Store: store, ServerInstanceID: serverInstanceID, CorrelationSecret: correlationSecret, Backoff: 2 * time.Second} -} - -func (collector *ConsoleLogCollector) Run(ctx context.Context) error { - if collector == nil || collector.Client == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" { - return fmt.Errorf("console log collector is not configured") - } - if err := collector.Store.EnsureSchema(ctx); err != nil { - return err - } - backoff := collector.Backoff - if backoff <= 0 { - backoff = 2 * time.Second - } - for { - err := collector.Client.StreamLogEvents(ctx, collector.handleEvent(ctx)) - if ctx.Err() != nil { - return ctx.Err() - } - if err != nil { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(backoff): - } - continue - } - } -} - -func (collector *ConsoleLogCollector) handleEvent(ctx context.Context) func(LogStreamEvent) error { - return func(event LogStreamEvent) error { - record, ok := consoleRecordFromLogEvent(collector.ServerInstanceID, event) - if !ok { - return nil - } - if _, err := collector.Store.StoreConsoleRecords(ctx, []ConsoleRecord{record}); err != nil { - return err - } - batch := ParseConsoleRecords(collector.ServerInstanceID, []ConsoleRecord{record}, collector.CorrelationSecret) - if _, err := collector.Store.StoreSemanticEventBatch(ctx, batch); err != nil { - return err - } - if collector.OnSemanticEvents != nil { - if err := collector.OnSemanticEvents(ctx, batch); err != nil { - return err - } - } - return nil - } -} - -func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (ConsoleRecord, bool) { - if event.ServerInstanceID != serverInstanceID || event.Entry.Seq == 0 || strings.TrimSpace(event.Entry.Line) == "" { - return ConsoleRecord{}, false - } - stream := consoleStreamName(event.StreamKey) - if stream == "" { - if !knownPluginLogStream(event.StreamKey) { - return ConsoleRecord{}, false - } - stream = strings.ToLower(strings.TrimSpace(event.StreamKey)) - } - occurredAt := event.Entry.Timestamp - if occurredAt.IsZero() { - occurredAt = time.Now().UTC() - } - return ConsoleRecord{ServerID: serverInstanceID, Stream: stream, Sequence: event.Entry.Seq, OccurredAt: occurredAt.UTC(), Text: event.Entry.Line}, true -} - -func knownPluginLogStream(streamKey string) bool { - switch strings.ToLower(strings.TrimSpace(streamKey)) { - case "scum.login", "scum.chat", "scum.server", "scum.kill", "scum.trade", "scum.admin", "scum.performance": - return true - default: - return false - } -} - -func consoleStreamName(streamKey string) string { - key := strings.ToLower(strings.TrimSpace(streamKey)) - if strings.Contains(key, "stderr") || strings.HasSuffix(key, ".err") || strings.HasSuffix(key, "-err") { - return "stderr" - } - if strings.Contains(key, "stdout") || strings.Contains(key, "console") || strings.HasSuffix(key, ".out") || strings.HasSuffix(key, "-out") { - return "stdout" - } - return "" -} diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go deleted file mode 100644 index e0f9a8c..0000000 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ /dev/null @@ -1,408 +0,0 @@ -package companion - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "time" -) - -// SafeAdapter is intentionally narrow: it receives typed values only and has -// no direct transport, host-path, credential, or shell access. Protected SQL, -// RCON, and management-program text is forwarded to Run by Platform, not here. -type SafeAdapter interface { - ReadConfiguration(context.Context) (map[string]any, error) - PatchConfiguration(context.Context, map[string]any) (map[string]any, error) - Diagnostics(context.Context) (map[string]any, error) - PatchGameState(context.Context, map[string]any) (map[string]any, error) - DeliverReward(context.Context, map[string]any) (map[string]any, error) - StartEvent(context.Context, map[string]any) (map[string]any, error) - NotifyPlayer(context.Context, map[string]any) (map[string]any, error) - SpawnVehicle(context.Context, map[string]any) (map[string]any, error) -} - -// ServerBoundAdapter lets a runtime adapter prove that it is configured for -// the same server as the registration which declared handler availability. -// Generic test adapters do not need this optional assertion. -type ServerBoundAdapter interface{ ServerBinding() string } - -type HandlerAvailability struct { - BoundServerID string - Capabilities map[string]bool - Approved bool -} -type CommandHandler func(context.Context, map[string]any) (map[string]any, error) -type HandlerRegistry struct { - availability HandlerAvailability - handlers map[string]CommandHandler - adapterServer string - mu sync.Mutex - completed map[string]CommandResult -} - -func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *HandlerRegistry { - registry := &HandlerRegistry{availability: availability, handlers: map[string]CommandHandler{}, completed: map[string]CommandResult{}} - if adapter == nil { - return registry - } - if bound, ok := adapter.(ServerBoundAdapter); ok { - registry.adapterServer = bound.ServerBinding() - } - registry.handlers["config.read"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { - return adapter.ReadConfiguration(ctx) - } - registry.handlers["config.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { - return adapter.PatchConfiguration(ctx, payload) - } - registry.handlers["companion.diagnostics"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.Diagnostics(ctx) } - registry.handlers["game-state.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { - return adapter.PatchGameState(ctx, payload) - } - registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { - return adapter.DeliverReward(ctx, payload) - } - registry.handlers["event.start"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { - return adapter.StartEvent(ctx, payload) - } - registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { - return adapter.NotifyPlayer(ctx, payload) - } - registry.handlers["vehicle.spawn"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { - return adapter.SpawnVehicle(ctx, payload) - } - return registry -} - -func (registry *HandlerRegistry) Execute(ctx context.Context, command ClaimedCommand) (CommandResult, error) { - registry.mu.Lock() - cached, done := registry.completed[command.ID] - registry.mu.Unlock() - if done { - return cached, nil - } - if err := validateDeclaredCommandAt(command, time.Now); err != nil { - return unsupportedResult("validation-failed"), nil - } - if strings.TrimSpace(registry.availability.BoundServerID) == "" || !registry.adapterBindingMatches() || !registry.availability.Approved || !registry.availability.Capabilities[command.CommandType] { - return unsupportedResult("unsupported"), nil - } - handler, exists := registry.handlers[command.CommandType] - if !exists { - return unsupportedResult("unsupported"), nil - } - payload, err := handler(ctx, command.Payload) - if err != nil { - if errors.Is(err, errAdapterUnsupported) { - return unsupportedResult("unsupported"), nil - } - return CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}}, nil - } - result := CommandResult{Status: "succeeded", Summary: "typed adapter completed", Payload: redactTypedPayload(payload)} - registry.mu.Lock() - registry.completed[command.ID] = result - registry.mu.Unlock() - return result, nil -} - -func (registry *HandlerRegistry) adapterBindingMatches() bool { - return registry.adapterServer == "" || registry.adapterServer == registry.availability.BoundServerID -} - -func validateDeclaredCommandAt(command ClaimedCommand, now func() time.Time) error { - if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 || command.Payload == nil || command.LeaseExpiresAt.IsZero() || command.ExpiresAt.IsZero() || !now().Before(command.LeaseExpiresAt) || !now().Before(command.ExpiresAt) { - return fmt.Errorf("invalid command") - } - for key, value := range command.Payload { - if !safeCommandField(key, value) { - return fmt.Errorf("unsafe payload") - } - } - return validateCommandPayload(command.CommandType, command.Payload) -} - -func validateCommandPayload(commandType string, payload map[string]any) error { - require := func(keys ...string) error { - for _, key := range keys { - if _, ok := payload[key]; !ok { - return fmt.Errorf("payload is incomplete") - } - } - return nil - } - noUnknown := func(keys ...string) error { - allowed := map[string]bool{} - for _, key := range keys { - allowed[key] = true - } - for key := range payload { - if !allowed[key] { - return fmt.Errorf("payload has unsupported field") - } - } - return nil - } - switch commandType { - case "config.read": - return noUnknown() - case "config.patch": - if err := require("revision", "fields"); err != nil { - return err - } - if err := noUnknown("revision", "fields"); err != nil { - return err - } - _, revisionOK := payload["revision"].(string) - fields, fieldsOK := payload["fields"].([]any) - if !revisionOK || !fieldsOK || len(fields) == 0 || len(fields) > 32 { - return fmt.Errorf("config patch payload is invalid") - } - return nil - case "companion.diagnostics": - if err := noUnknown("includeWindowState", "maxEntries"); err != nil { - return err - } - if value, ok := payload["includeWindowState"]; ok { - if _, valid := value.(bool); !valid { - return fmt.Errorf("diagnostics payload is invalid") - } - } - if value, ok := payload["maxEntries"]; ok { - if !boundedDiagnosticsEntries(value) { - return fmt.Errorf("diagnostics payload is invalid") - } - } - return nil - case "game-state.patch": - if err := require("playerId", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil { - return err - } - if err := noUnknown("playerId", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil { - return err - } - changes, changesOK := payload["changes"].([]any) - if !changesOK || len(changes) == 0 || len(changes) > 8 { - return fmt.Errorf("state patch payload is invalid") - } - return nil - case "reward.deliver": - if err := require("grantId", "playerId", "items", "operations"); err != nil { - return err - } - if err := noUnknown("grantId", "playerId", "items", "operations"); err != nil { - return err - } - _, err := rewardGrant(payload) - return err - case "event.start": - if err := require("eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"); err != nil { - return err - } - if err := noUnknown("eventId", "eventType", "class", "title", "placard", "percent", "npc", "item", "zombie", "animal", "produces", "durationSeconds", "maxParticipants", "announce"); err != nil { - return err - } - _, err := eventStartRequest(payload) - return err - case "player.notify": - if err := require("playerId", "message"); err != nil { - return err - } - if err := noUnknown("playerId", "message"); err != nil { - return err - } - _, playerOK := payload["playerId"].(string) - message, messageOK := payload["message"].(string) - if !playerOK || !messageOK || strings.TrimSpace(message) == "" || len(message) > 200 { - return fmt.Errorf("notification payload is invalid") - } - return nil - case "vehicle.spawn": - if err := require("vehicleCode"); err != nil { - return err - } - if err := noUnknown("vehicleCode"); err != nil { - return err - } - vehicleCode, vehicleOK := payload["vehicleCode"].(string) - if !vehicleOK || !supportedVehicleSpawnCode(vehicleCode) { - return fmt.Errorf("vehicle spawn payload is invalid") - } - return nil - default: - return fmt.Errorf("command type is not declared") - } -} - -func safeCommandField(key string, value any) bool { - lower := strings.ToLower(strings.TrimSpace(key)) - if lower == "" || strings.Contains(lower, "path") || strings.Contains(lower, "credential") || strings.Contains(lower, "password") || strings.Contains(lower, "sql") || strings.Contains(lower, "rcon") || strings.Contains(lower, "command") { - return false - } - if text, ok := value.(string); ok { - compact := strings.ToLower(text) - return !strings.Contains(compact, "bearer ") && !strings.Contains(compact, "password=") && !strings.Contains(compact, "select ") && !strings.Contains(compact, "/users/") - } - return true -} - -func unsupportedResult(code string) CommandResult { - return CommandResult{Status: "failed", Summary: "typed operation unavailable", Payload: map[string]any{"result": code}} -} -func redactTypedPayload(payload map[string]any) map[string]any { - result := map[string]any{} - for key, value := range payload { - if safeCommandField(key, value) { - result[key] = value - } - } - return result -} - -type CommandGateway interface { - ClaimCommands(context.Context, int) ([]ClaimedCommand, error) - AckCommand(context.Context, string, uint64) (CommandAck, error) - CompleteCommand(context.Context, string, uint64, CommandResult) (CompletedCommand, error) -} -type Dispatcher struct { - Client CommandGateway - Registry *HandlerRegistry - PollLimit int - Backoff time.Duration - Now func() time.Time -} - -func (dispatcher Dispatcher) DispatchOnce(ctx context.Context) error { - if dispatcher.Client == nil || dispatcher.Registry == nil { - return fmt.Errorf("dispatcher is not configured") - } - limit := dispatcher.PollLimit - if limit == 0 { - limit = 10 - } - if limit < 1 || limit > 50 { - return fmt.Errorf("dispatcher poll limit is invalid") - } - commands, err := dispatcher.Client.ClaimCommands(ctx, limit) - if err != nil { - return err - } - for _, command := range commands { - if err := validateDeclaredCommandAt(command, dispatcher.clock()); err != nil { - if completeErr := dispatcher.completeValidationFailure(ctx, command); completeErr != nil { - return completeErr - } - continue - } - if _, err = dispatcher.Client.AckCommand(ctx, command.ID, command.FencingToken); err != nil { - return err - } - result, executionErr := dispatcher.Registry.Execute(ctx, command) - if executionErr != nil { - result = CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}} - } - if _, err = dispatcher.Client.CompleteCommand(ctx, command.ID, command.FencingToken, result); err != nil { - return err - } - } - return nil -} -func (dispatcher Dispatcher) completeValidationFailure(ctx context.Context, command ClaimedCommand) error { - if command.ID == "" || command.FencingToken == 0 { - return fmt.Errorf("claimed command is invalid") - } - _, err := dispatcher.Client.CompleteCommand(ctx, command.ID, command.FencingToken, unsupportedResult("validation-failed")) - return err -} -func (dispatcher Dispatcher) clock() func() time.Time { - if dispatcher.Now != nil { - return dispatcher.Now - } - return time.Now -} -func (dispatcher Dispatcher) Run(ctx context.Context) error { - backoff := dispatcher.Backoff - if backoff <= 0 { - backoff = 2 * time.Second - } - for { - if err := dispatcher.DispatchOnce(ctx); err != nil { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(backoff): - continue - } - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(backoff): - } - } -} - -// Runtime keeps the registered companion alive with bounded heartbeat and -// polling intervals. It owns no host connection or game credential; handlers -// are the only route to runtime capability adapters. -type RuntimeGateway interface { - CommandGateway - Register(context.Context) (Registration, error) - Heartbeat(context.Context, HealthReport) (HeartbeatResult, error) -} -type Runtime struct { - Client RuntimeGateway - Dispatcher Dispatcher - HeartbeatEvery time.Duration - PollEvery time.Duration - Backoff time.Duration - Health func() HealthReport -} - -func (runtime Runtime) Run(ctx context.Context) error { - if runtime.Client == nil || runtime.Dispatcher.Registry == nil { - return fmt.Errorf("runtime is not configured") - } - if _, err := runtime.Client.Register(ctx); err != nil { - return err - } - heartbeatEvery, pollEvery := runtime.HeartbeatEvery, runtime.PollEvery - if heartbeatEvery < 5*time.Second { - heartbeatEvery = 30 * time.Second - } - if pollEvery < time.Second { - pollEvery = 5 * time.Second - } - heartbeat := time.NewTicker(heartbeatEvery) - defer heartbeat.Stop() - poll := time.NewTicker(pollEvery) - defer poll.Stop() - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-heartbeat.C: - report := HealthReport{Status: "healthy", Reason: "typed companion dispatcher ready"} - if runtime.Health != nil { - report = runtime.Health() - } - if _, err := runtime.Client.Heartbeat(ctx, report); err != nil { - return err - } - case <-poll.C: - if err := runtime.Dispatcher.DispatchOnce(ctx); err != nil { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(runtime.backoff()): - } - } - } - } -} -func (runtime Runtime) backoff() time.Duration { - if runtime.Backoff > 0 { - return runtime.Backoff - } - return 2 * time.Second -} diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go b/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go deleted file mode 100644 index d6ab2ff..0000000 --- a/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go +++ /dev/null @@ -1,46 +0,0 @@ -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", 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/dispatcher_test.go b/plugins/examples/scum-server-plugin/companion/dispatcher_test.go deleted file mode 100644 index f20af69..0000000 --- a/plugins/examples/scum-server-plugin/companion/dispatcher_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package companion - -import ( - "context" - "testing" - "time" -) - -type dispatchFixture struct { - commands []ClaimedCommand - acks []string - completed []CommandResult -} - -func (fixture *dispatchFixture) ClaimCommands(_ context.Context, _ int) ([]ClaimedCommand, error) { - return append([]ClaimedCommand(nil), fixture.commands...), nil -} -func (fixture *dispatchFixture) AckCommand(_ context.Context, id string, _ uint64) (CommandAck, error) { - fixture.acks = append(fixture.acks, id) - return CommandAck{CommandID: id, State: "claimed", FencingToken: 7}, nil -} -func (fixture *dispatchFixture) CompleteCommand(_ context.Context, _ string, _ uint64, result CommandResult) (CompletedCommand, error) { - fixture.completed = append(fixture.completed, result) - return CompletedCommand{State: "succeeded"}, nil -} - -type adapterFixture struct{ reads int } - -func (adapter *adapterFixture) ReadConfiguration(context.Context) (map[string]any, error) { - adapter.reads++ - return map[string]any{"version": "0.9.700.90357", "hostPath": "C:/must-redact"}, nil -} -func (*adapterFixture) PatchConfiguration(context.Context, map[string]any) (map[string]any, error) { - return nil, nil -} -func (*adapterFixture) Diagnostics(context.Context) (map[string]any, error) { - return map[string]any{"status": "healthy"}, nil -} -func (*adapterFixture) PatchGameState(context.Context, map[string]any) (map[string]any, error) { - return nil, nil -} -func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[string]any, error) { - return nil, nil -} -func (*adapterFixture) StartEvent(context.Context, map[string]any) (map[string]any, error) { - return nil, nil -} -func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) { - return nil, nil -} -func (*adapterFixture) SpawnVehicle(context.Context, map[string]any) (map[string]any, error) { - return nil, nil -} - -func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) { - stamp := time.Now().UTC() - adapter := &adapterFixture{} - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter) - fixture := &dispatchFixture{commands: []ClaimedCommand{{ID: "read-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "expired-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 8, LeaseExpiresAt: stamp.Add(-time.Second), ExpiresAt: stamp.Add(-time.Second)}}} - dispatcher := Dispatcher{Client: fixture, Registry: registry, Now: func() time.Time { return stamp }} - if err := dispatcher.DispatchOnce(context.Background()); err != nil { - t.Fatalf("dispatch: %v", err) - } - if len(fixture.acks) != 1 || fixture.acks[0] != "read-1" || len(fixture.completed) != 2 || adapter.reads != 1 { - t.Fatalf("unexpected bounded dispatch: acks=%v completed=%+v reads=%d", fixture.acks, fixture.completed, adapter.reads) - } - if fixture.completed[0].Payload["hostPath"] != nil || fixture.completed[1].Payload["result"] != "validation-failed" { - t.Fatalf("unsafe or malformed result: %+v", fixture.completed) - } -} - -func TestRegistryReturnsCachedResultForDuplicateDelivery(t *testing.T) { - stamp := time.Now().UTC() - adapter := &adapterFixture{} - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter) - command := ClaimedCommand{ID: "duplicate-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)} - if _, err := registry.Execute(context.Background(), command); err != nil { - t.Fatalf("first execute: %v", err) - } - if _, err := registry.Execute(context.Background(), command); err != nil { - t.Fatalf("second execute: %v", err) - } - if adapter.reads != 1 { - t.Fatalf("duplicate delivery invoked adapter %d times", adapter.reads) - } -} - -func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) { - stamp := time.Now().UTC() - registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.patch": true}}, &adapterFixture{}) - for _, command := range []ClaimedCommand{{ID: "bad-type", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "bad-payload", ProfileKey: ProfileKey, CommandType: "config.patch", Payload: map[string]any{"revision": "r1"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "unsafe-vehicle", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C", "command": "#spawnvehicle BPC_Laika_C"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}} { - result, err := registry.Execute(context.Background(), command) - if err != nil || result.Payload["result"] != "validation-failed" { - t.Fatalf("unsafe command was not rejected: result=%+v err=%v", result, err) - } - } -} - -func TestRewardPayloadValidationRequiresItemsOrOperations(t *testing.T) { - validBase := map[string]any{"grantId": "grant-1", "playerId": "76561198000000001"} - for name, payload := range map[string]map[string]any{ - "items": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{}}, - "operations": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{}, "operations": []any{"#SetFamePoints 250"}}, - "both": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{"#SetFamePoints 250"}}, - } { - t.Run(name, func(t *testing.T) { - if err := validateCommandPayload("reward.deliver", payload); err != nil { - t.Fatalf("valid reward payload was rejected: %v", err) - } - }) - } - if err := validateCommandPayload("reward.deliver", map[string]any{"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{}}); err == nil { - t.Fatal("reward payload with no items or operations was accepted") - } -} diff --git a/plugins/examples/scum-server-plugin/companion/events.go b/plugins/examples/scum-server-plugin/companion/events.go deleted file mode 100644 index c2d39df..0000000 --- a/plugins/examples/scum-server-plugin/companion/events.go +++ /dev/null @@ -1,120 +0,0 @@ -package companion - -import ( - "crypto/sha256" - "encoding/hex" - "regexp" - "strings" - "time" -) - -var scumLoginLogLine = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: '([0-9.]+) (\d{1,50}):([^']{1,80})\(\d+\)' logged (in|out)(?: .*)?$`) - -// ConsoleRecord is supplied by Run's stdout/stderr stream, not by the server -// execution log. The channel never accepts a file path or a raw log archive. -type ConsoleRecord struct { - ServerID string - Stream string - Sequence uint64 - OccurredAt time.Time - Text string -} -type SemanticEvent struct { - ServerID string - Sequence uint64 - Type string - PlayerID string - DisplayName string - OccurredAt time.Time - NetworkCorrelation string -} -type EventDiagnostic struct { - ServerID string - Sequence uint64 - Code string -} -type SemanticEventBatch struct { - ServerID string - FirstSequence uint64 - Events []SemanticEvent - Diagnostics []EventDiagnostic -} - -// ParseConsoleRecords accepts only bounded stdout/stderr records. Unknown -// formats produce a bounded diagnostic and are skipped; they never fabricate -// events or carry raw console text across the plugin boundary. -func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSecret string) SemanticEventBatch { - batch := SemanticEventBatch{ServerID: serverID} - if len(records) > 100 { - records = records[:100] - } - for _, record := range records { - if record.ServerID != serverID || strings.TrimSpace(record.Stream) == "" || len(record.Stream) > 64 || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 { - batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "invalid-console-record"}) - continue - } - if batch.FirstSequence == 0 { - batch.FirstSequence = record.Sequence - } - event, ok := parseConsoleRecord(record, correlationSecret) - if !ok { - batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "unknown-console-format"}) - continue - } - batch.Events = append(batch.Events, event) - } - return batch -} -func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) { - if event, ok := parseLoginLogRecord(record, secret); ok { - return event, true - } - fields := strings.Fields(record.Text) - if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) { - return SemanticEvent{}, false - } - eventType := "scum.login" - if fields[1] == "LOGOUT" { - eventType = "scum.logout" - } - event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: fields[2], OccurredAt: record.OccurredAt} - if len(fields) == 4 && secret != "" { - event.NetworkCorrelation = networkCorrelation(record.ServerID, fields[3], secret) - } - return event, true -} - -func parseLoginLogRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) { - match := scumLoginLogLine.FindStringSubmatch(record.Text) - if match == nil || !steamID64(match[2]) { - return SemanticEvent{}, false - } - eventType := "scum.login" - if match[4] == "out" { - eventType = "scum.logout" - } - event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: match[2], DisplayName: match[3], OccurredAt: record.OccurredAt} - if secret != "" { - event.NetworkCorrelation = networkCorrelation(record.ServerID, match[1], secret) - } - return event, true -} - -func networkCorrelation(serverID, value, secret string) string { - digest := sha256.Sum256([]byte(serverID + "\x00" + secret + "\x00" + value)) - return hex.EncodeToString(digest[:]) -} -func appendDiagnostic(existing []EventDiagnostic, diagnostic EventDiagnostic) []EventDiagnostic { - if len(existing) >= 32 { - return existing - } - return append(existing, diagnostic) -} -func VerifiedSemanticEventProducer() SemanticEventProducerAvailability { - return SemanticEventProducerAvailability{Available: true, Reason: "Run stdout/stderr semantic parser is available"} -} - -type SemanticEventProducerAvailability struct { - Available bool - Reason string -} diff --git a/plugins/examples/scum-server-plugin/companion/go.mod b/plugins/examples/scum-server-plugin/companion/go.mod deleted file mode 100644 index 9f76220..0000000 --- a/plugins/examples/scum-server-plugin/companion/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module browser.local/plugins/scum-server-plugin/companion - -go 1.25.1 - -require gopkg.in/yaml.v3 v3.0.1 diff --git a/plugins/examples/scum-server-plugin/companion/go.sum b/plugins/examples/scum-server-plugin/companion/go.sum deleted file mode 100644 index a62c313..0000000 --- a/plugins/examples/scum-server-plugin/companion/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/examples/scum-server-plugin/companion/log_stream.go b/plugins/examples/scum-server-plugin/companion/log_stream.go deleted file mode 100644 index d7312b3..0000000 --- a/plugins/examples/scum-server-plugin/companion/log_stream.go +++ /dev/null @@ -1,124 +0,0 @@ -package companion - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -const logEventsPath = "/api/v1/game-client-bridge/companion/logs/events" - -type LogEntry struct { - Seq uint64 `json:"seq"` - Timestamp time.Time `json:"timestamp"` - Level string `json:"level,omitempty"` - Line string `json:"line"` - Fields map[string]string `json:"fields,omitempty"` -} - -type LogStreamEvent struct { - ServerInstanceID string `json:"serverInstanceId"` - StreamID string `json:"streamId"` - Source string `json:"source"` - StreamKey string `json:"streamKey"` - LogSessionID string `json:"logSessionId,omitempty"` - SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"` - LatestSeq uint64 `json:"latestSeq"` - Entry LogEntry `json:"entry"` -} - -type logStreamRequest struct { - SessionToken string `json:"sessionToken"` -} - -func (client *Client) StreamLogEvents(ctx context.Context, handle func(LogStreamEvent) error) error { - if handle == nil { - return fmt.Errorf("log event handler is required") - } - token, err := client.currentSession() - if err != nil { - return err - } - encoded, err := json.Marshal(logStreamRequest{SessionToken: token}) - if err != nil { - return fmt.Errorf("encode log stream request: %w", err) - } - if len(encoded) > maxRequestBytes { - return fmt.Errorf("platform request exceeds the bounded payload size") - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.config.Platform.BaseURL+logEventsPath, bytes.NewReader(encoded)) - if err != nil { - return fmt.Errorf("create log stream request: %w", err) - } - request.Header.Set("Accept", "text/event-stream") - request.Header.Set("Content-Type", "application/json") - response, err := client.httpClient.Do(request) - if err != nil { - return fmt.Errorf("open log stream: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) - return HTTPError{StatusCode: response.StatusCode, ExpectedStatus: http.StatusOK} - } - return readLogEventStream(ctx, response.Body, handle) -} - -func readLogEventStream(ctx context.Context, body io.Reader, handle func(LogStreamEvent) error) error { - reader := bufio.NewReader(body) - var eventName string - var dataLines []string - flush := func() error { - if len(dataLines) == 0 { - eventName = "" - return nil - } - name := eventName - if name == "" { - name = "message" - } - payload := strings.Join(dataLines, "\n") - eventName = "" - dataLines = nil - if name != "log" { - return nil - } - var event LogStreamEvent - if err := json.Unmarshal([]byte(payload), &event); err != nil { - return fmt.Errorf("decode log event: %w", err) - } - return handle(event) - } - for { - line, err := reader.ReadString('\n') - if len(line) > 0 { - line = strings.TrimRight(line, "\r\n") - switch { - case line == "": - if flushErr := flush(); flushErr != nil { - return flushErr - } - case strings.HasPrefix(line, ":"): - case strings.HasPrefix(line, "event:"): - eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - case strings.HasPrefix(line, "data:"): - dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) - } - } - if err != nil { - if ctx != nil && ctx.Err() != nil { - return ctx.Err() - } - if err == io.EOF { - return flush() - } - return fmt.Errorf("read log stream: %w", err) - } - } -} diff --git a/plugins/examples/scum-server-plugin/companion/orchestration.go b/plugins/examples/scum-server-plugin/companion/orchestration.go deleted file mode 100644 index ce22e74..0000000 --- a/plugins/examples/scum-server-plugin/companion/orchestration.go +++ /dev/null @@ -1,166 +0,0 @@ -package companion - -import ( - "context" - "fmt" - "time" -) - -const ( - SmokeClaimLimit = 1 - SmokeIsolationEnvironment = "SCUM_COMPANION_SMOKE_SCOPE" - SmokeIsolationValue = "isolated-non-production" - companionDiagnosticsCommandType = "companion.diagnostics" - companionHealthSnapshotType = "companion.health" - companionHealthSchemaVersion = "1" - companionHealthKeepForSeconds = 7 * 24 * 60 * 60 - companionHealthMaxRecords = 1000 -) - -type SmokeOptions struct { - IsolatedNonProduction bool - StreamSuffix func() (string, error) -} - -type SmokeResult struct { - ClaimedCount int `json:"claimedCount"` - CompletedCount int `json:"completedCount"` - UnsupportedCount int `json:"unsupportedCount"` - CompletedCommandIDs []string `json:"completedCommandIds"` - UnsupportedCommandIDs []string `json:"unsupportedCommandIds"` - SnapshotID string `json:"snapshotId,omitempty"` -} - -func RunOneShotSmoke(ctx context.Context, client *Client, options SmokeOptions) (SmokeResult, error) { - result := SmokeResult{CompletedCommandIDs: []string{}, UnsupportedCommandIDs: []string{}} - if client == nil { - return result, fmt.Errorf("companion client is required") - } - if !options.IsolatedNonProduction { - return result, fmt.Errorf("one-shot smoke requires an isolated non-production queue") - } - streamSuffix := options.StreamSuffix - if streamSuffix == nil { - streamSuffix = randomNonce - } - suffix, err := streamSuffix() - if err != nil || !validNonce(suffix) { - return result, fmt.Errorf("create isolated smoke stream") - } - streamKey := "smoke-" + suffix - if len(streamKey) > 80 { - return result, fmt.Errorf("isolated smoke stream is too long") - } - if _, err := client.Register(ctx); err != nil { - return result, err - } - observedAt := client.now().UTC() - if _, err := client.Heartbeat(ctx, HealthReport{Status: "healthy", Reason: "one-shot smoke ready"}); err != nil { - return result, err - } - commands, err := client.ClaimCommands(ctx, SmokeClaimLimit) - if err != nil { - return result, err - } - result.ClaimedCount = len(commands) - if len(commands) != 1 { - return result, fmt.Errorf("one-shot smoke requires exactly one isolated command") - } - command := commands[0] - if err := validateSmokeDiagnosticsCommand(command, observedAt); err != nil { - result.UnsupportedCount = 1 - if command.ID != "" { - result.UnsupportedCommandIDs = append(result.UnsupportedCommandIDs, command.ID) - } - return result, err - } - ack, err := client.AckCommand(ctx, command.ID, command.FencingToken) - if err != nil { - return result, err - } - if ack.CommandID != command.ID || ack.FencingToken != command.FencingToken || ack.State != "claimed" { - return result, fmt.Errorf("platform returned an invalid smoke acknowledgement") - } - diagnostics := map[string]any{ - "status": "online", - "version": client.config.Component.Version, - "lastHeartbeatAt": observedAt.Format(time.RFC3339Nano), - } - completed, err := client.CompleteCommand(ctx, command.ID, command.FencingToken, CommandResult{Status: "succeeded", Summary: "companion diagnostics available", Payload: diagnostics}) - if err != nil { - return result, err - } - if completed.CommandID != command.ID || completed.State != "succeeded" { - return result, fmt.Errorf("platform returned an invalid smoke command result") - } - result.CompletedCount = 1 - result.CompletedCommandIDs = append(result.CompletedCommandIDs, command.ID) - - health := map[string]any{ - "status": "online", - "version": client.config.Component.Version, - "observedAt": observedAt.Format(time.RFC3339Nano), - "capabilities": append([]string(nil), client.config.Capabilities...), - } - accepted, err := client.UploadSnapshot(ctx, Snapshot{ - Type: companionHealthSnapshotType, - SchemaVersion: companionHealthSchemaVersion, - StreamKey: streamKey, - Sequence: 1, - ObservedAt: observedAt, - Payload: health, - KeepForSeconds: companionHealthKeepForSeconds, - MaxRecords: companionHealthMaxRecords, - }) - if err != nil { - return result, err - } - if accepted.SnapshotID == "" || accepted.ProfileKey != ProfileKey || accepted.Type != companionHealthSnapshotType || accepted.SchemaVersion != companionHealthSchemaVersion || accepted.StreamKey != streamKey || accepted.Sequence != 1 { - return result, fmt.Errorf("platform returned an invalid smoke snapshot acknowledgement") - } - result.SnapshotID = accepted.SnapshotID - return result, nil -} - -func validateSmokeDiagnosticsCommand(command ClaimedCommand, stamp time.Time) error { - if command.CommandType != companionDiagnosticsCommandType { - return fmt.Errorf("isolated smoke claimed an unsupported command") - } - if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 { - return fmt.Errorf("isolated smoke command identity is invalid") - } - if command.LeaseExpiresAt.IsZero() || command.ExpiresAt.IsZero() || !stamp.Before(command.LeaseExpiresAt) || !stamp.Before(command.ExpiresAt) { - return fmt.Errorf("isolated smoke command lease is not live") - } - if command.Payload == nil { - return fmt.Errorf("isolated smoke diagnostics payload is invalid") - } - for key, value := range command.Payload { - switch key { - case "includeWindowState": - if _, ok := value.(bool); !ok { - return fmt.Errorf("isolated smoke diagnostics payload is invalid") - } - case "maxEntries": - if !boundedDiagnosticsEntries(value) { - return fmt.Errorf("isolated smoke diagnostics payload is invalid") - } - default: - return fmt.Errorf("isolated smoke diagnostics payload is invalid") - } - } - return nil -} - -func boundedDiagnosticsEntries(value any) bool { - switch typed := value.(type) { - case int: - return typed >= 1 && typed <= 20 - case int64: - return typed >= 1 && typed <= 20 - case float64: - return typed >= 1 && typed <= 20 && typed == float64(int(typed)) - default: - return false - } -} diff --git a/plugins/examples/scum-server-plugin/companion/orchestration_test.go b/plugins/examples/scum-server-plugin/companion/orchestration_test.go deleted file mode 100644 index 9579a21..0000000 --- a/plugins/examples/scum-server-plugin/companion/orchestration_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package companion - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" - "time" -) - -func TestRunOneShotSmokeProcessesOnlySafeDiagnosticsAndUploadsIsolatedHealth(t *testing.T) { - stamp := time.Date(2026, 7, 20, 9, 10, 11, 123456789, time.UTC) - config := loadTestConfig(t) - sessionExpiry := stamp.Add(15 * time.Minute) - step := 0 - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - switch step { - case 0: - assertPlatformRequest(t, request, registerPath) - step++ - return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-smoke", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil - case 1: - assertPlatformRequest(t, request, heartbeatPath) - var body heartbeatRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-smoke" || body.HealthReason != "one-shot smoke ready" { - t.Fatalf("unexpected smoke heartbeat: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil - case 2: - assertPlatformRequest(t, request, claimPath) - var body claimRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-smoke" || body.Limit != 1 { - t.Fatalf("unexpected smoke claim: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "diagnostics-1", ProfileKey: ProfileKey, CommandType: companionDiagnosticsCommandType, Payload: map[string]any{"includeWindowState": true, "maxEntries": 3}, FencingToken: 17, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil - case 3: - assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/diagnostics-1/ack") - var body ackRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-smoke" || body.FencingToken != 17 { - t.Fatalf("unexpected smoke ack: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusOK, CommandAck{CommandID: "diagnostics-1", State: "claimed", FencingToken: 17, AcknowledgedAt: stamp}), nil - case 4: - assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/diagnostics-1/result") - var body resultRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-smoke" || body.FencingToken != 17 || body.Status != "succeeded" || body.Summary != "companion diagnostics available" { - t.Fatalf("unexpected smoke result: %+v", body) - } - if body.Payload["status"] != "online" || body.Payload["version"] != config.Component.Version || body.Payload["lastHeartbeatAt"] != stamp.Format(time.RFC3339Nano) { - t.Fatalf("unexpected bounded diagnostics payload: %+v", body.Payload) - } - serialized, _ := json.Marshal(body) - if strings.Contains(string(serialized), testProof) { - t.Fatalf("result exposed proof: %s", serialized) - } - step++ - return jsonHTTPResponse(http.StatusOK, map[string]any{"commandId": "diagnostics-1", "state": "succeeded", "result": map[string]any{"status": "succeeded", "summary": "companion diagnostics available", "completedAt": stamp}, "updatedAt": stamp, "completedAt": stamp}), nil - case 5: - assertPlatformRequest(t, request, snapshotPath) - var body snapshotRequest - decodeRequest(t, request, &body) - if body.SessionToken != "component-session-smoke" || body.Type != companionHealthSnapshotType || body.SchemaVersion != companionHealthSchemaVersion || body.StreamKey != "smoke-fixture-stream-nonce-0001" || body.Sequence != 1 { - t.Fatalf("unexpected isolated smoke snapshot identity: %+v", body) - } - if !body.ObservedAt.Equal(stamp) || body.KeepForSeconds != 604800 || body.MaxRecords != 1000 || body.Payload["status"] != "online" || body.Payload["observedAt"] != stamp.Format(time.RFC3339Nano) { - t.Fatalf("unexpected typed health snapshot: %+v", body) - } - step++ - return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: "health-snapshot-1", ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil - default: - return nil, fmt.Errorf("unexpected smoke transport: %s", request.URL.Path) - } - }) - client := newTestClient(t, config, transport, stamp) - result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-stream-nonce-0001", nil }}) - if err != nil { - t.Fatalf("run one-shot smoke: %v", err) - } - if result.ClaimedCount != 1 || result.CompletedCount != 1 || result.UnsupportedCount != 0 || result.SnapshotID != "health-snapshot-1" { - t.Fatalf("unexpected safe smoke result: %+v", result) - } - if len(result.CompletedCommandIDs) != 1 || result.CompletedCommandIDs[0] != "diagnostics-1" || len(result.UnsupportedCommandIDs) != 0 { - t.Fatalf("unexpected smoke IDs: %+v", result) - } - assertSafeSmokeOutput(t, result) - if step != 6 { - t.Fatalf("expected diagnostics ack/result plus snapshot, got %d requests", step) - } -} - -func TestRunOneShotSmokeLeavesUnsupportedCommandUnackedAndUnexecuted(t *testing.T) { - stamp := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) - config := loadTestConfig(t) - sessionExpiry := stamp.Add(15 * time.Minute) - step := 0 - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - switch step { - case 0: - step++ - return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-unsupported", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil - case 1: - step++ - return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil - case 2: - step++ - return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "unsupported-1", ProfileKey: ProfileKey, CommandType: "diagnostic.unsupported", Payload: map[string]any{"message": "must not execute"}, FencingToken: 18, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil - default: - return nil, fmt.Errorf("unsupported command triggered transport: %s", request.URL.Path) - } - }) - client := newTestClient(t, config, transport, stamp) - result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-stream-nonce-0002", nil }}) - if err == nil || !strings.Contains(err.Error(), "unsupported") { - t.Fatalf("expected unsupported smoke to stop, result=%+v err=%v", result, err) - } - if result.ClaimedCount != 1 || result.CompletedCount != 0 || result.UnsupportedCount != 1 || len(result.CompletedCommandIDs) != 0 || len(result.UnsupportedCommandIDs) != 1 || result.UnsupportedCommandIDs[0] != "unsupported-1" { - t.Fatalf("unsupported command did not fail safely: %+v", result) - } - assertSafeSmokeOutput(t, result) - if step != 3 { - t.Fatalf("expected no unsupported ack/result requests, got %d steps", step) - } -} - -func TestRunOneShotSmokeRejectsNonSingletonClaimBeforeExecution(t *testing.T) { - stamp := time.Date(2026, 7, 20, 10, 30, 0, 0, time.UTC) - for _, test := range []struct { - name string - items []ClaimedCommand - }{ - {name: "zero"}, - {name: "multiple", items: []ClaimedCommand{{ID: "diagnostics-1"}, {ID: "diagnostics-2"}}}, - } { - t.Run(test.name, func(t *testing.T) { - config := loadTestConfig(t) - step := 0 - transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { - step++ - switch step { - case 1: - return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-singleton", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil - case 2: - return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: stamp.Add(15 * time.Minute), ServerTime: stamp}), nil - case 3: - return jsonHTTPResponse(http.StatusOK, claimResponse{Items: test.items, Count: len(test.items)}), nil - default: - return nil, fmt.Errorf("non-singleton claim triggered execution: %s", request.URL.Path) - } - }) - client := newTestClient(t, config, transport, stamp) - result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-singleton-nonce", nil }}) - if err == nil || !strings.Contains(err.Error(), "exactly one") || result.ClaimedCount != len(test.items) || step != 3 { - t.Fatalf("non-singleton claim was not rejected: result=%+v step=%d err=%v", result, step, err) - } - }) - } -} - -func TestValidateSmokeDiagnosticsCommandRejectsUnsafeClaims(t *testing.T) { - stamp := time.Date(2026, 7, 20, 11, 0, 0, 0, time.UTC) - valid := ClaimedCommand{ID: "diagnostics-1", ProfileKey: ProfileKey, CommandType: companionDiagnosticsCommandType, Payload: map[string]any{"includeWindowState": false, "maxEntries": float64(5)}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(2 * time.Minute)} - tests := []struct { - name string - mutate func(*ClaimedCommand) - }{ - {name: "profile", mutate: func(command *ClaimedCommand) { command.ProfileKey = "other" }}, - {name: "fence", mutate: func(command *ClaimedCommand) { command.FencingToken = 0 }}, - {name: "lease", mutate: func(command *ClaimedCommand) { command.LeaseExpiresAt = stamp }}, - {name: "expiry", mutate: func(command *ClaimedCommand) { command.ExpiresAt = stamp.Add(-time.Second) }}, - {name: "nil payload", mutate: func(command *ClaimedCommand) { command.Payload = nil }}, - {name: "unknown payload", mutate: func(command *ClaimedCommand) { command.Payload["message"] = "not allowed" }}, - {name: "payload type", mutate: func(command *ClaimedCommand) { command.Payload["maxEntries"] = 2.5 }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - candidate := valid - candidate.Payload = map[string]any{"includeWindowState": false, "maxEntries": float64(5)} - test.mutate(&candidate) - if err := validateSmokeDiagnosticsCommand(candidate, stamp); err == nil { - t.Fatalf("expected unsafe diagnostics claim to be rejected: %+v", candidate) - } - }) - } -} - -func TestRunOneShotSmokeRequiresIsolatedNonProductionQueue(t *testing.T) { - result, err := RunOneShotSmoke(context.Background(), &Client{}, SmokeOptions{}) - if err == nil || !strings.Contains(err.Error(), "isolated non-production") { - t.Fatalf("expected isolation gate, result=%+v err=%v", result, err) - } -} - -func assertSafeSmokeOutput(t *testing.T, result SmokeResult) { - t.Helper() - serialized, err := json.Marshal(result) - if err != nil { - t.Fatalf("marshal smoke result: %v", err) - } - for _, forbidden := range []string{testProof, "component-session", "includeWindowState", "must not execute", "payload"} { - if strings.Contains(string(serialized), forbidden) { - t.Fatalf("smoke output exposed %q: %s", forbidden, serialized) - } - } -} diff --git a/plugins/examples/scum-server-plugin/companion/player_projection.go b/plugins/examples/scum-server-plugin/companion/player_projection.go deleted file mode 100644 index f3ebfae..0000000 --- a/plugins/examples/scum-server-plugin/companion/player_projection.go +++ /dev/null @@ -1,122 +0,0 @@ -package companion - -import ( - "context" - "fmt" - "sort" - "strings" - "sync" - "time" -) - -// SCUMPlayerLogProjection is plugin-owned business logic. It consumes the -// opaque log channel after the platform has forwarded it and publishes a typed -// players snapshot; Run and Platform never inspect the source text. -type SCUMPlayerLogProjection struct { - Client *Client - ServerInstanceID string - KeepForSeconds int - MaxRecords int - Now func() time.Time - SequenceStore SnapshotSequenceStore - - mu sync.Mutex - players map[string]scumPlayerProjection -} - -type scumPlayerProjection struct { - PlayerID string - PlayerName string - Status string - LastSeenAt time.Time -} - -func NewSCUMPlayerLogProjection(client *Client, serverInstanceID string) *SCUMPlayerLogProjection { - return &SCUMPlayerLogProjection{Client: client, ServerInstanceID: serverInstanceID, KeepForSeconds: 86400, MaxRecords: 1000, Now: time.Now, SequenceStore: NewMemorySnapshotSequenceStore(), players: map[string]scumPlayerProjection{}} -} - -func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch SemanticEventBatch) error { - if projection == nil || projection.Client == nil || strings.TrimSpace(projection.ServerInstanceID) == "" { - return fmt.Errorf("SCUM player log projection is not configured") - } - if batch.ServerID != projection.ServerInstanceID { - return fmt.Errorf("SCUM player log projection server scope mismatch") - } - now := time.Now - if projection.Now != nil { - now = projection.Now - } - observedAt := now().UTC() - projection.mu.Lock() - for _, event := range batch.Events { - playerID := strings.TrimSpace(event.PlayerID) - if playerID == "" { - continue - } - name := strings.TrimSpace(event.DisplayName) - if name == "" { - name = playerID - } - player := projection.players[playerID] - player.PlayerID = playerID - player.PlayerName = name - player.LastSeenAt = event.OccurredAt.UTC() - if player.LastSeenAt.IsZero() { - player.LastSeenAt = now().UTC() - } - switch event.Type { - case "scum.login": - player.Status = "online" - case "scum.logout": - player.Status = "offline" - default: - continue - } - projection.players[playerID] = player - } - if len(batch.Events) == 0 { - projection.mu.Unlock() - return nil - } - players := make([]scumPlayerProjection, 0, len(projection.players)) - for _, player := range projection.players { - players = append(players, player) - } - store := projection.SequenceStore - if store == nil { - store = NewMemorySnapshotSequenceStore() - projection.SequenceStore = store - } - sequence, err := store.Next("players", "current", snapshotSequenceFloor(observedAt)) - if err != nil { - projection.mu.Unlock() - return err - } - projection.mu.Unlock() - sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID }) - payloadPlayers := make([]map[string]any, 0, len(players)) - for _, player := range players { - payloadPlayers = append(payloadPlayers, map[string]any{ - "playerId": player.PlayerID, - "playerName": player.PlayerName, - "status": player.Status, - "lastSeenAt": player.LastSeenAt.Format(time.RFC3339Nano), - }) - } - _, err = projection.Client.UploadSnapshot(ctx, Snapshot{ - Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, - ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers}, - KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords, - }) - return err -} - -type noopConsoleLogStore struct{} - -func (noopConsoleLogStore) EnsureSchema(context.Context) error { return nil } -func (noopConsoleLogStore) StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error) { - return 0, nil -} -func (noopConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) { - return len(batch.Events), nil -} diff --git a/plugins/examples/scum-server-plugin/companion/player_projection_test.go b/plugins/examples/scum-server-plugin/companion/player_projection_test.go deleted file mode 100644 index 322ad7d..0000000 --- a/plugins/examples/scum-server-plugin/companion/player_projection_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package companion - -import ( - "context" - "encoding/json" - "io" - "net/http" - "os" - "strings" - "testing" - "time" -) - -func TestSCUMPlayerLogProjectionCreatesTypedUserSnapshot(t *testing.T) { - stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC) - config := loadTestConfig(t) - var snapshot Snapshot - client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) { - if request.URL.Path != snapshotPath { - t.Fatalf("unexpected projection request path: %s", request.URL.Path) - } - var body snapshotRequest - if err := json.NewDecoder(request.Body).Decode(&body); err != nil { - t.Fatalf("decode snapshot request: %v", err) - } - snapshot = Snapshot{Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, ObservedAt: body.ObservedAt, Payload: body.Payload, KeepForSeconds: body.KeepForSeconds, MaxRecords: body.MaxRecords} - return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot-1","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current","sequence":1}`))}, nil - }), stamp) - client.mu.Lock() - client.sessionToken = "component-session" - client.sessionExpiresAt = stamp.Add(time.Hour) - client.mu.Unlock() - - projection := NewSCUMPlayerLogProjection(client, "server-example") - projection.Now = func() time.Time { return stamp } - if err := projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 7, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: "Ada", OccurredAt: stamp}}}); err != nil { - t.Fatalf("project login event: %v", err) - } - players, ok := snapshot.Payload["players"].([]any) - if !ok || len(players) != 1 { - t.Fatalf("expected one projected player, payload=%#v", snapshot.Payload) - } - player, ok := players[0].(map[string]any) - if !ok || player["playerId"] != "76561198000000001" || player["playerName"] != "Ada" || player["status"] != "online" { - t.Fatalf("unexpected projected player: %#v", players[0]) - } -} - -func TestSCUMPlayerLogProjectionKeepsSequenceAcrossRestart(t *testing.T) { - stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC) - config := loadTestConfig(t) - sequences := make([]uint64, 0, 2) - client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) { - if request.URL.Path != snapshotPath { - t.Fatalf("unexpected projection request path: %s", request.URL.Path) - } - var body snapshotRequest - if err := json.NewDecoder(request.Body).Decode(&body); err != nil { - t.Fatalf("decode snapshot request: %v", err) - } - sequences = append(sequences, body.Sequence) - return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current"}`))}, nil - }), stamp) - client.mu.Lock() - client.sessionToken = "component-session" - client.sessionExpiresAt = stamp.Add(time.Hour) - client.mu.Unlock() - - originalDirectory, err := os.Getwd() - if err != nil { - t.Fatalf("get working directory: %v", err) - } - t.Chdir(t.TempDir()) - t.Cleanup(func() { _ = os.Chdir(originalDirectory) }) - store, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename) - if err != nil { - t.Fatalf("create sequence store: %v", err) - } - project := func(name string) error { - projection := NewSCUMPlayerLogProjection(client, "server-example") - projection.Now = func() time.Time { return stamp } - projection.SequenceStore = store - return projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 1, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: name, OccurredAt: stamp}}}) - } - if err := project("Ada"); err != nil { - t.Fatalf("project before restart: %v", err) - } - restarted, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename) - if err != nil { - t.Fatalf("restart sequence store: %v", err) - } - store = restarted - if err := project("Ada Lovelace"); err != nil { - t.Fatalf("project after restart: %v", err) - } - if len(sequences) != 2 || sequences[1] <= sequences[0] { - t.Fatalf("expected restart-safe monotonic snapshots, got %v", sequences) - } -} diff --git a/plugins/examples/scum-server-plugin/companion/snapshot_sequences.go b/plugins/examples/scum-server-plugin/companion/snapshot_sequences.go deleted file mode 100644 index ad04540..0000000 --- a/plugins/examples/scum-server-plugin/companion/snapshot_sequences.go +++ /dev/null @@ -1,166 +0,0 @@ -package companion - -import ( - "encoding/json" - "fmt" - "math" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -const SnapshotSequenceFilename = "snapshot-sequences.json" - -// SnapshotSequenceStore is plugin-local state used only to keep the plugin's -// typed snapshot stream monotonic. It never stores or examines raw log text. -type SnapshotSequenceStore interface { - Next(snapshotType string, streamKey string, floor uint64) (uint64, error) -} - -type MemorySnapshotSequenceStore struct { - mu sync.Mutex - sequences map[string]uint64 -} - -func NewMemorySnapshotSequenceStore() *MemorySnapshotSequenceStore { - return &MemorySnapshotSequenceStore{sequences: map[string]uint64{}} -} - -func (store *MemorySnapshotSequenceStore) Next(snapshotType string, streamKey string, floor uint64) (uint64, error) { - if store == nil { - return 0, fmt.Errorf("snapshot sequence store is not configured") - } - key, err := snapshotSequenceKey(snapshotType, streamKey) - if err != nil { - return 0, err - } - store.mu.Lock() - defer store.mu.Unlock() - next, err := nextSnapshotSequence(store.sequences[key], floor) - if err != nil { - return 0, err - } - store.sequences[key] = next - return next, nil -} - -type FileSnapshotSequenceStore struct { - filename string - mu sync.Mutex -} - -type snapshotSequenceDocument struct { - SchemaVersion int `json:"schemaVersion"` - Sequences map[string]uint64 `json:"sequences"` -} - -func NewFileSnapshotSequenceStore(filename string) (*FileSnapshotSequenceStore, error) { - filename = strings.TrimSpace(filename) - if filename == "" || filepath.Base(filename) != filename || filename == "." { - return nil, fmt.Errorf("snapshot sequence filename is invalid") - } - return &FileSnapshotSequenceStore{filename: filename}, nil -} - -func (store *FileSnapshotSequenceStore) Next(snapshotType string, streamKey string, floor uint64) (uint64, error) { - if store == nil { - return 0, fmt.Errorf("snapshot sequence store is not configured") - } - key, err := snapshotSequenceKey(snapshotType, streamKey) - if err != nil { - return 0, err - } - store.mu.Lock() - defer store.mu.Unlock() - document, err := store.read() - if err != nil { - return 0, err - } - next, err := nextSnapshotSequence(document.Sequences[key], floor) - if err != nil { - return 0, err - } - document.Sequences[key] = next - if err := store.write(document); err != nil { - return 0, err - } - return next, nil -} - -func (store *FileSnapshotSequenceStore) read() (snapshotSequenceDocument, error) { - document := snapshotSequenceDocument{SchemaVersion: 1, Sequences: map[string]uint64{}} - body, err := os.ReadFile(store.filename) - if os.IsNotExist(err) { - return document, nil - } - if err != nil { - return snapshotSequenceDocument{}, fmt.Errorf("read snapshot sequences: %w", err) - } - if err := json.Unmarshal(body, &document); err != nil { - return snapshotSequenceDocument{}, fmt.Errorf("decode snapshot sequences: %w", err) - } - if document.SchemaVersion != 1 || document.Sequences == nil { - return snapshotSequenceDocument{}, fmt.Errorf("snapshot sequence state is invalid") - } - return document, nil -} - -func (store *FileSnapshotSequenceStore) write(document snapshotSequenceDocument) error { - body, err := json.Marshal(document) - if err != nil { - return err - } - temporary, err := os.CreateTemp(".", ".snapshot-sequences-") - if err != nil { - return fmt.Errorf("create snapshot sequence state: %w", err) - } - temporaryName := temporary.Name() - defer os.Remove(temporaryName) - if _, err := temporary.Write(body); err != nil { - temporary.Close() - return fmt.Errorf("write snapshot sequence state: %w", err) - } - if err := temporary.Chmod(0o600); err != nil { - temporary.Close() - return fmt.Errorf("protect snapshot sequence state: %w", err) - } - if err := temporary.Close(); err != nil { - return fmt.Errorf("close snapshot sequence state: %w", err) - } - if err := os.Rename(temporaryName, store.filename); err != nil { - return fmt.Errorf("commit snapshot sequence state: %w", err) - } - return nil -} - -func snapshotSequenceKey(snapshotType string, streamKey string) (string, error) { - snapshotType = strings.TrimSpace(snapshotType) - streamKey = strings.TrimSpace(streamKey) - if snapshotType == "" || streamKey == "" || len(snapshotType) > 80 || len(streamKey) > 80 { - return "", fmt.Errorf("snapshot stream identity is invalid") - } - return snapshotType + "\x00" + streamKey, nil -} - -func nextSnapshotSequence(current uint64, floor uint64) (uint64, error) { - if current == math.MaxUint64 { - return 0, fmt.Errorf("snapshot sequence is exhausted") - } - next := current + 1 - if floor > next { - next = floor - } - if next == 0 { - return 0, fmt.Errorf("snapshot sequence is exhausted") - } - return next, nil -} - -func snapshotSequenceFloor(observedAt time.Time) uint64 { - if observedAt.IsZero() || observedAt.UnixNano() <= 0 { - return 1 - } - return uint64(observedAt.UnixNano()) -} diff --git a/plugins/examples/scum-server-plugin/companion/snapshot_sequences_test.go b/plugins/examples/scum-server-plugin/companion/snapshot_sequences_test.go deleted file mode 100644 index ad37378..0000000 --- a/plugins/examples/scum-server-plugin/companion/snapshot_sequences_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package companion - -import "testing" - -func TestFileSnapshotSequenceStorePersistsMonotonicSequence(t *testing.T) { - t.Chdir(t.TempDir()) - store, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename) - if err != nil { - t.Fatalf("create sequence store: %v", err) - } - first, err := store.Next("players", "current", 100) - if err != nil || first != 100 { - t.Fatalf("reserve first sequence: sequence=%d err=%v", first, err) - } - restarted, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename) - if err != nil { - t.Fatalf("restart sequence store: %v", err) - } - second, err := restarted.Next("players", "current", 1) - if err != nil || second != 101 { - t.Fatalf("reserve persisted sequence: sequence=%d err=%v", second, err) - } -} - -func TestFileSnapshotSequenceStoreRejectsHostPaths(t *testing.T) { - for _, filename := range []string{"../snapshot-sequences.json", "/tmp/snapshot-sequences.json", ""} { - if _, err := NewFileSnapshotSequenceStore(filename); err == nil { - t.Fatalf("expected invalid filename rejection: %q", filename) - } - } -} diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts deleted file mode 100644 index 73a6add..0000000 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMStatePatch, SCUMStateSnapshot, SCUMVehicleSpawn } from "./contracts.js"; -import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js"; - -export type PluginFeatureBridge = { dispatch(action: "game-client.command", payload: Record): Promise<{ status: string; result?: Record; error?: { message: string } }> }; -export type SCUMFeatureAPI = { - availability(feature: SCUMFeatureKey): Promise; readConfig(): Promise; patchConfig(patch: SCUMConfigPatch): Promise; - stateSnapshot(playerId: string): Promise; requestStatePatch(patch: SCUMStatePatch): Promise; requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise; -}; - -export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI { - const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。" }; - return { - availability, - async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode(result.result) : null; }, - async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); }, - async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode(result.result) : null; }, - async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); }, - async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); } - }; -} - -function commandResult(result: { status: string; result?: Record; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入任务队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "操作未被接受。" }; } -function decode(result: Record | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } } diff --git a/plugins/examples/scum-server-plugin/features/migration.ts b/plugins/examples/scum-server-plugin/features/migration.ts index f6ab7b8..9aa324d 100644 --- a/plugins/examples/scum-server-plugin/features/migration.ts +++ b/plugins/examples/scum-server-plugin/features/migration.ts @@ -6,8 +6,8 @@ export function pluginOwned>(payload: T, recor // The authority flag is exact-server. Missing, duplicate, or // transitional flags fail closed: history remains readable, but plugin writes -// are not enabled. Execution still additionally requires Companion feature -// availability; this flag never authorizes a command by itself. +// are not enabled. Execution still additionally requires the plugin-declared +// feature surface to be available; this flag never authorizes a command by itself. export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus { const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.feature === feature); if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器尚未启用插件权威记录。" }; diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index bd20929..1c0e8d5 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -8,15 +8,6 @@ export type PluginDataActions = { transact: (collection: string, mutations: PluginDataMutation[]) => Promise; }; -export type PluginGameClientQueueRequest = { - profileKey: string; - commandType: string; - payload: RecordMap; - idempotencyKey: string; - priority?: number; - expiresAt: string; -}; - export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record }; export type PluginBridgeExecutionResult = { status?: string; result?: Record; error?: { message?: string } }; @@ -82,12 +73,6 @@ export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, p export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; - gameClient?: { - queue: (request: PluginGameClientQueueRequest) => Promise; - get: (commandId: string) => Promise; - list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise; - snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise; - }; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; }; @@ -163,14 +148,6 @@ export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: st const keys = pageCollections[canonicalPageKey(pageKey)]; const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); for (const [key, response] of records) data[key] = collectionRecords(response); - if (keys.includes("players") && actions.gameClient) { - const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined); - data.players = mergePlayerSnapshots(data.players, playersSnapshot); - } - if (keys.includes("vehicles") && actions.gameClient) { - const vehiclesSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "vehicles", streamKey: "current", limit: 1 }).catch(() => undefined); - data.vehicles = mergeVehicleSnapshots(data.vehicles, vehiclesSnapshot); - } return data; } @@ -238,7 +215,7 @@ export async function createGiftDelivery(actions: SCUMWorkspaceActions, delivery } export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: RecordMap, player: RecordMap): Promise { - if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。"); + if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 发放礼包。"); const giftCode = requiredKey(gift, "code", "礼包编号"); const playerId = firstText(player, "gamePlayerId", "playerId", "steamId", "id"); if (!playerId) throw new Error("用户编号不能为空。"); @@ -247,16 +224,15 @@ export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: Rec if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。"); const now = Date.now(); const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`); - const command = await actions.gameClient.queue({ - profileKey: "scum-client-manager", - commandType: "reward.deliver", - payload: { grantId, playerId, items, operations }, - idempotencyKey: grantId, - expiresAt: new Date(now + 5 * 60_000).toISOString() - }); - const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", createdAt: new Date(now).toISOString() }; + const commands = [...items.map((item) => `#SpawnItem ${item.catalogCode} ${item.quantity}`), ...operations].map(normalizeRCONCommand); + const results = []; + for (const [index, command] of commands.entries()) { + results.push(await dispatchSCUMRCONCommand(actions, command, `${grantId}:${index + 1}`)); + } + const commandIds = results.map((result) => textValue(result.result?.jobId)).filter(Boolean); + const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: commandIds[0] ?? "", commandIds, createdAt: new Date(now).toISOString() }; await createGiftDelivery(actions, record); - return command; + return { status: "queued", result: { jobIds: commandIds.join(",") } }; } export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise { @@ -282,28 +258,15 @@ export async function deleteEventProduce(actions: SCUMWorkspaceActions, produce: } export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap, produces: RecordMap[] = []): Promise { - if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。"); + if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 启动活动。"); const eventId = requiredKey(event, "id", "活动编号"); - const eventClass = Number(event.class) === 2 || firstText(event, "eventType") === "fixed" ? 2 : 1; - const eventType = eventClass === 2 ? "fixed" : "range"; - const queuedProduces = normalizeEventProduces(produces); + const command = firstText(event, "rconCommand", "command"); + if (!command) throw new Error("活动未声明可执行的 SCUM RCON 命令。"); const now = Date.now(); const runId = safeCommandId(`event:${eventId}:${now}`); - const command = await actions.gameClient.queue({ - profileKey: "scum-client-manager", - commandType: "event.start", - payload: { - eventId, eventType, class: eventClass, title: textValue(event.name) || eventId, - placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100), - npc: minimumInteger(event.npc, 0, 0), item: minimumInteger(event.item, 0, 0), zombie: minimumInteger(event.zombie, 0, 0), animal: minimumInteger(event.animal, 0, 0), - produces: queuedProduces, - durationSeconds: minimumInteger(event.durationSeconds, 1, 1800), announce: event.announce !== false - }, - idempotencyKey: runId, - expiresAt: new Date(now + 5 * 60_000).toISOString() - }); - await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", definition: event, produces, startedAt: new Date(now).toISOString() }); - return command; + const dispatched = await dispatchSCUMRCONCommand(actions, normalizeRCONCommand(command), runId); + await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: textValue(dispatched.result?.jobId), definition: event, produces, startedAt: new Date(now).toISOString() }); + return dispatched; } export function parseGiftItems(input: string): Array<{ catalogCode: string; quantity: number }> { @@ -414,13 +377,6 @@ function requiredKey(value: RecordMap, key: string, label: string): string { function requiredRecordKey(value: RecordMap, label: string): string { const key = firstText(value, "_recordKey", "id", "produceId"); if (!key) throw new Error(`${label}编号不能为空。`); return key.includes(":") ? key : `${firstText(value, "eventId", "event")}:${key}`; } -function boundedInteger(value: unknown, min: number, max: number, fallback: number): number { - const number = Number(value); - return Number.isSafeInteger(number) && number >= min && number <= max ? number : fallback; -} - -function minimumInteger(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isSafeInteger(number) && number >= min ? number : fallback; } - function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包物品格式无效。"); @@ -435,20 +391,30 @@ function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quanti function normalizeGiftOperations(value: unknown): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包命令格式无效。"); return value.map((item) => { const command = isRecord(item) ? firstText(item, "command", "value") : textValue(item); if (!command.trim()) throw new Error("礼包命令不能为空。"); return command.trim(); }); } -function normalizeEventProduces(produces: RecordMap[]): RecordMap[] { - return produces.map((produce) => ({ - tradeGoodsId: firstText(produce, "tradeGoodsId"), - percent: boundedInteger(produce.percent, 0, 100, 100), - value: minimumInteger(produce.value, 1, 1), - r: minimumNumber(produce.r, 0, 0), - x: finiteNumber(produce.x, 0), - y: finiteNumber(produce.y, 0), - z: finiteNumber(produce.z, 0) - })); +function normalizeRCONCommand(command: string): string { + const normalized = command.trim(); + if (!normalized || /[\r\n]/.test(normalized)) throw new Error("SCUM RCON 命令必须是单行文本。"); + return normalized; } -function minimumNumber(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min ? number : fallback; } -function finiteNumber(value: unknown, fallback: number): number { const number = Number(value); return Number.isFinite(number) ? number : fallback; } +async function dispatchSCUMRCONCommand(actions: SCUMWorkspaceActions, command: string, idempotencyKey: string): Promise { + if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用。"); + const result = await actions.dispatch({ + requestId: idempotencyKey, + action: "remote.access.request", + payload: { + capability: "remote.run.rcon.command", + declarationKey: "scum-management", + targetKey: "scum-management", + idempotencyKey, + timeoutSeconds: "30", + maxAttempts: "1", + "input.command": command + } + }); + if (result.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SCUM RCON 命令未进入 Run 队列。"); + return result; +} function safeCommandId(value: string): string { return value.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 96); } function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 0182faf..cdae665 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -84,6 +84,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [eventName, setEventName] = usePluginState(react, ""); const [eventType, setEventType] = usePluginState(react, "range"); const [eventSchedule, setEventSchedule] = usePluginState(react, ""); + const [eventCommand, setEventCommand] = usePluginState(react, ""); const [eventClass, setEventClass] = usePluginState(react, "1"); const [eventPlacard, setEventPlacard] = usePluginState(react, ""); const [eventPercent, setEventPercent] = usePluginState(react, "100"); @@ -152,7 +153,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, - eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, + eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint, @@ -170,6 +171,7 @@ type ViewState = { activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; eventType: string; setEventType: StateSetter; eventSchedule: string; setEventSchedule: StateSetter; + eventCommand: string; setEventCommand: StateSetter; eventClass: string; setEventClass: StateSetter; eventPlacard: string; setEventPlacard: StateSetter; eventPercent: string; setEventPercent: StateSetter; eventDuration: string; setEventDuration: StateSetter; eventNpc: string; setEventNpc: StateSetter; eventItem: string; setEventItem: StateSetter; eventZombie: string; setEventZombie: StateSetter; eventAnimal: string; setEventAnimal: StateSetter; @@ -368,6 +370,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, if (!id || !name) throw new Error("活动编号和名称不能为空。"); await saveEventDefinition(actions ?? {}, { id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(), + rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(), placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100), npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"), status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString() @@ -395,6 +398,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, labeledField(e, "活动名称", e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })), labeledField(e, "生成类型", e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成"))), labeledField(e, "活动计划", e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron(留空为手动)", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) })), + labeledField(e, "RCON 命令", e("input", { value: view.eventCommand, "aria-label": "RCON 命令", placeholder: "例如 #start_event event_supply_drop", onChange: (event: InputEvent) => view.setEventCommand(inputValue(event)) })), labeledField(e, "活动公告", e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始时显示的公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) })), labeledField(e, "触发概率 (%)", e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) })), labeledField(e, "持续秒数", e("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "1800", onChange: (event: InputEvent) => view.setEventDuration(inputValue(event)) })), @@ -438,8 +442,8 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)), e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")), e("div", { className: "console-row-actions" }, - e("button", { type: "button", className: "primary-command", disabled: !actions?.gameClient || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"), - e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"), + e("button", { type: "button", className: "primary-command", disabled: !actions?.dispatch || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"), + e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventCommand(textField(event, "rconCommand", "command")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除") ) ); @@ -520,7 +524,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu e("article", { className: "console-module" }, e("h2", null, "创建发放记录"), labeledField(e, "礼包", e("select", { value: view.deliveryGift, "aria-label": "选择礼包", onChange: (event: InputEvent) => view.setDeliveryGift(inputValue(event)) }, e("option", { value: "" }, "选择礼包"), data.gifts.map((gift, index) => { const key = textField(gift, "code", "id"); return e("option", { key: idOf(gift, `gift-option-${index}`), value: key }, textField(gift, "name") || key); }))), labeledField(e, "用户", e("select", { value: view.deliveryPlayer, "aria-label": "选择用户", onChange: (event: InputEvent) => view.setDeliveryPlayer(inputValue(event)) }, e("option", { value: "" }, "选择用户"), data.players.map((player, index) => { const key = textField(player, "gamePlayerId", "steamId", "id"); return e("option", { key: idOf(player, `player-option-${index}`), value: key }, textField(player, "displayName") || key); }))), - e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.gameClient, onClick: queueDelivery }, "立即发放") + e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.dispatch, onClick: queueDelivery }, "立即发放") ), tablePanel(e, "发放记录", data.giftDeliveries, (delivery) => [textField(delivery, "playerName", "playerId") || "unknown", textField(delivery, "giftName", "giftCode") || "unknown", textField(delivery, "status") || "unknown", dateField(delivery, "deliveredAt", "createdAt")]) ) : null, diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index 8c0e5e1..e405eb3 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -1,7 +1,7 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js"; -// These are safe fallback plugin catalogs. A Companion schema probe may narrow them -// per server, but a game version never enables or disables a feature. +// These are safe fallback plugin catalogs. Plugin-owned declarations may narrow +// them per server, but a game version never enables or disables a feature. export const configurationCatalog: readonly SCUMConfigField[] = [ { key: "server-name", fileKey: "scum-server-settings", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, { key: "game-port", fileKey: "scum-server-settings", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" }, diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index b6cd88a..d97f40f 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -2,7 +2,7 @@ "$schema": "../../manifests/game-plugin.manifest.schema.json", "id": "game.scum", "name": "SCUM Server", - "description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.", + "description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.", "version": "0.1.15", "kind": "game-plugin", "tags": [ @@ -10,7 +10,7 @@ "survival", "dedicated-server", "game-operations", - "companion-client" + "rcon" ], "server": { "type": "scum", @@ -77,11 +77,6 @@ "remote.run.logs.transfer", "remote.run.rcon.command", "remote.run.program.command", - "client-manager.deploy", - "client-manager.control", - "client-manager.update", - "client-manager.rollback", - "client-manager.uninstall", "artifacts.read", "artifacts.write", "ai.invoke" @@ -118,21 +113,11 @@ "ai.invoke", "run.distribution.request", "dependencies.request", - "client-manager.request", "plugin-lifecycle.request" ] }, "gameClientBridge": { "commands": [ - { - "type": "companion.diagnostics", - "title": "Collect companion diagnostics", - "permission": "server.game-client.read", - "payloadSchemaRef": "schemas/bridge/diagnostics.payload.schema.json", - "resultSchemaRef": "schemas/bridge/diagnostics.result.schema.json", - "timeoutSeconds": 30, - "maxPayloadBytes": 2048 - }, { "type": "player.lookup", "title": "Look up SCUM player", @@ -198,13 +183,6 @@ } ], "snapshots": [ - { - "type": "companion.health", - "schemaVersion": "1", - "schemaRef": "schemas/bridge/companion-health.snapshot.schema.json", - "keepForSeconds": 604800, - "maxRecords": 1000 - }, { "type": "online.sessions", "schemaVersion": "1", @@ -755,22 +733,7 @@ "player.intelligence" ] } - ], - "companion": { - "profileKey": "scum-client-manager", - "configTemplateKey": "client-config", - "configSchemaRef": "schemas/companion/config.schema.json", - "configFormat": "yaml", - "platformBaseUrlSource": "run-control", - "registrationProof": "hmac-sha256", - "proofMaterialSource": "component-package", - "proofMaterialEnv": "SCUM_COMPONENT_PROOF", - "sessionMode": "component-session", - "tlsPolicy": "verify-system-roots", - "heartbeatIntervalSeconds": 30, - "commandPollIntervalSeconds": 5, - "requestTimeoutSeconds": 15 - } + ] }, "permissions": [ "server.create", @@ -785,7 +748,6 @@ "ai.invoke", "server.run.distribution", "server.dependencies.manage", - "server.client-manager.manage", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance" @@ -1255,19 +1217,6 @@ "platforms": [ "windows" ] - }, - { - "key": "scum-client", - "mode": "custom-client", - "capabilities": [ - "client-manager.deploy", - "client-manager.control", - "logs.read" - ], - "clientManagerRef": "scum-client-manager", - "platforms": [ - "windows" - ] } ], "dependencyProbes": [ @@ -1396,14 +1345,6 @@ "streamKey": "scum.performance", "cursorKind": "fingerprint", "retentionDays": 30 - }, - { - "key": "scum-client-events", - "kind": "client-manager", - "targetKey": "scum-client-manager", - "streamKey": "scum.client", - "cursorKind": "sequence", - "retentionDays": 30 } ], "transportProfiles": [ @@ -1460,21 +1401,6 @@ ] } ], - "dataTargets": [ - { - "key": "scum-database", - "kind": "sqlite.snapshot", - "transportKey": "scum-database", - "sourceRootKey": "server-root", - "sourcePath": "SCUM/Saved/SaveFiles/SCUM.db", - "workspaceKey": "databases/scum-database", - "refreshPolicy": "on-demand-snapshot", - "maxBytes": 1073741824, - "platforms": [ - "windows" - ] - } - ], "dllExtensions": [ { "key": "scum-simple-rcon", @@ -1500,83 +1426,6 @@ "updateOnStart": true, "rconPort": 27015 } - ], - "clientManagers": [ - { - "key": "scum-client-manager", - "displayName": "SCUM Client Manager", - "version": "1.0.0", - "repository": { - "url": "https://github.com/F88888/scum_client.git", - "revisionPolicy": "branch", - "branch": "main" - }, - "supportedTargets": [ - { - "os": "windows", - "arch": "amd64" - } - ], - "build": { - "system": "go", - "entryRef": "main.go" - }, - "configTemplates": [ - { - "key": "client-config", - "templateRef": "config.yaml.example", - "outputRef": "config.yaml" - } - ], - "outputArtifacts": [ - "scum_client.exe" - ], - "deployment": { - "mode": "run-supervised", - "executableRef": "scum_client.exe", - "autoStart": true, - "requiredRunCapabilities": [ - "client-manager.deploy", - "client-manager.control", - "client-manager.update", - "client-manager.rollback", - "client-manager.uninstall" - ] - }, - "lifecycle": { - "actions": [ - "start", - "stop", - "restart", - "status", - "update", - "rollback", - "uninstall" - ], - "startupTimeoutSeconds": 60, - "stopTimeoutSeconds": 30 - }, - "health": { - "mode": "component-heartbeat", - "intervalSeconds": 30, - "degradedAfterSeconds": 90, - "offlineAfterSeconds": 120, - "requiredCapabilities": [ - "component.register", - "component.heartbeat", - "component.health", - "component.control", - "game-client.bridge", - "logs.stream" - ] - }, - "updatePolicy": { - "strategy": "manual-staged", - "requireApproval": true, - "healthConfirmationSeconds": 60, - "retainPrevious": true - } - } ] } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/companion-health.snapshot.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/companion-health.snapshot.schema.json deleted file mode 100644 index 485fdda..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/companion-health.snapshot.schema.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMCompanionHealthSnapshot", - "type": "object", - "additionalProperties": false, - "required": ["status", "observedAt"], - "properties": { - "status": { - "type": "string", - "enum": ["online", "degraded", "offline"] - }, - "version": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "observedAt": { - "type": "string", - "maxLength": 64, - "format": "date-time" - }, - "latencyMs": { - "type": "integer", - "minimum": 0, - "maximum": 30000 - }, - "capabilities": { - "type": "array", - "maxItems": 16, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9.-]{0,79}$" - }, - "uniqueItems": true - } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.payload.schema.json deleted file mode 100644 index d33730c..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.payload.schema.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMCompanionDiagnosticsPayload", - "type": "object", - "additionalProperties": false, - "properties": { - "includeWindowState": { - "type": "boolean" - }, - "maxEntries": { - "type": "integer", - "minimum": 1, - "maximum": 20 - } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.result.schema.json deleted file mode 100644 index bbd66f9..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/diagnostics.result.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMCompanionDiagnosticsResult", - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": { - "type": "string", - "maxLength": 16, - "enum": ["online", "degraded", "offline"] - }, - "version": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "lastHeartbeatAt": { - "type": "string", - "maxLength": 64, - "format": "date-time" - } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/maintenance-prepare.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/maintenance-prepare.payload.schema.json index 9ed43ff..974b3b9 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/maintenance-prepare.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/maintenance-prepare.payload.schema.json @@ -8,7 +8,7 @@ "scope": { "type": "string", "maxLength": 16, - "enum": ["server", "database", "companion"] + "enum": ["server", "database", "bridge"] }, "noticeSeconds": { "type": "integer", diff --git a/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json b/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json deleted file mode 100644 index 00057cb..0000000 --- a/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "schemaVersion": 1, - "platform": { - "baseUrl": "https://platform.example.test" - }, - "component": { - "installationId": "client-manager-installation-example", - "serverInstanceId": "server-example", - "pluginId": "game.scum", - "profileKey": "scum-client-manager", - "artifactId": "artifact-example", - "version": "1.0.0", - "sourceRevision": "example-revision", - "targetOs": "windows", - "targetArch": "amd64", - "keyGeneration": 1, - "deploymentGeneration": 1 - }, - "proof": { - "mode": "hmac-sha256", - "materialEnv": "SCUM_COMPONENT_PROOF" - }, - "session": { - "mode": "component-session" - }, - "capabilities": [ - "component.register", - "component.heartbeat", - "component.health", - "component.control", - "game-client.bridge", - "logs.stream" - ], - "timing": { - "heartbeatIntervalSeconds": 30, - "commandPollIntervalSeconds": 5, - "requestTimeoutSeconds": 15 - }, - "tls": { - "policy": "verify-system-roots" - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json b/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json deleted file mode 100644 index 450ec8d..0000000 --- a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPlatformCompanionConfig", - "type": "object", - "additionalProperties": false, - "required": ["schemaVersion", "platform", "component", "proof", "session", "capabilities", "timing", "tls"], - "properties": { - "schemaVersion": { "const": 1 }, - "platform": { - "type": "object", - "additionalProperties": false, - "required": ["baseUrl"], - "properties": { - "baseUrl": { - "type": "string", - "format": "uri", - "pattern": "^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\\[[0-9A-Fa-f:.]+\\])(?::[1-9][0-9]{0,4})?/?$", - "maxLength": 228 - } - } - }, - "component": { - "type": "object", - "additionalProperties": false, - "required": ["installationId", "serverInstanceId", "pluginId", "profileKey", "artifactId", "version", "sourceRevision", "targetOs", "targetArch", "keyGeneration", "deploymentGeneration"], - "properties": { - "installationId": { "$ref": "#/$defs/identifier" }, - "serverInstanceId": { "$ref": "#/$defs/identifier" }, - "pluginId": { "const": "game.scum" }, - "profileKey": { "const": "scum-client-manager" }, - "artifactId": { "$ref": "#/$defs/identifier" }, - "version": { "type": "string", "minLength": 1, "maxLength": 40 }, - "sourceRevision": { "type": "string", "minLength": 1, "maxLength": 120 }, - "targetOs": { "const": "windows" }, - "targetArch": { "const": "amd64" }, - "keyGeneration": { "type": "integer", "minimum": 1, "maximum": 2147483647 }, - "deploymentGeneration": { "type": "integer", "minimum": 1, "maximum": 2147483647 } - } - }, - "proof": { - "type": "object", - "additionalProperties": false, - "required": ["mode", "materialEnv"], - "properties": { - "mode": { "const": "hmac-sha256" }, - "materialEnv": { "const": "SCUM_COMPONENT_PROOF" } - } - }, - "session": { - "type": "object", - "additionalProperties": false, - "required": ["mode"], - "properties": { - "mode": { "const": "component-session" } - } - }, - "capabilities": { - "type": "array", - "minItems": 6, - "maxItems": 7, - "uniqueItems": true, - "items": { "enum": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream", "handler.vehicle.spawn"] }, - "allOf": [ - { "contains": { "const": "component.register" } }, - { "contains": { "const": "component.heartbeat" } }, - { "contains": { "const": "component.health" } }, - { "contains": { "const": "component.control" } }, - { "contains": { "const": "game-client.bridge" } }, - { "contains": { "const": "logs.stream" } } - ] - }, - "timing": { - "type": "object", - "additionalProperties": false, - "required": ["heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"], - "properties": { - "heartbeatIntervalSeconds": { "const": 30 }, - "commandPollIntervalSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }, - "requestTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 } - } - }, - "tls": { - "type": "object", - "additionalProperties": false, - "required": ["policy"], - "properties": { - "policy": { "const": "verify-system-roots" } - } - } - }, - "$defs": { - "identifier": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$" - } - } -} diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index b8c8e83..f35f673 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -26,9 +26,7 @@ import { parseBridgeExecutionResponse, parseAIInvocationResponse, type GameClientBridgeQueryTemplateDeclaration, - type GameClientBridgeCompanionDeclaration, type GamePluginManifest, - type RuntimeClientManagerProfile, type PluginLifecycleActionDeclaration, type PluginBridgeContext } from "../sdk/index.js"; @@ -74,16 +72,6 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } -function listProductionGoFiles(directory: string): string[] { - return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const target = path.join(directory, entry.name); - if (entry.isDirectory()) { - return listProductionGoFiles(target); - } - return entry.isFile() && entry.name.endsWith(".go") && !entry.name.endsWith("_test.go") ? [target] : []; - }); -} - function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManifest, fixtureDir: string) => void): string[] { const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-bridge-manifest-")); try { @@ -129,8 +117,8 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife } } -function validateTemporaryScumCompanionManifest(mutate: (manifest: Record, fixtureDir: string) => void): string[] { - const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-companion-manifest-")); +function validateTemporaryScumManifest(mutate: (manifest: Record, fixtureDir: string) => void): string[] { + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-manifest-")); try { fs.cpSync(path.join(pluginsRoot, "examples/scum-server-plugin"), fixtureDir, { recursive: true }); const manifestPath = path.join(fixtureDir, "manifest.json"); @@ -209,18 +197,18 @@ describe("plugin manifest validation", () => { }); it("rejects lifecycle executable assets missing from the manifest seed declaration", () => { - const errors = validateTemporaryScumCompanionManifest((manifest) => { + const errors = validateTemporaryScumManifest((manifest) => { manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd"); }); expect(errors.some((error) => error.includes("lifecycleAction.start.executableKey") && error.includes("manifest.assetFiles"))).toBe(true); }); it("rejects unsupported or unsafe inline create-field declarations", () => { - const malformed = validateTemporaryScumCompanionManifest((manifest) => { + const malformed = validateTemporaryScumManifest((manifest) => { manifest.server.createFields[0].type = "path"; }); expect(malformed.some((error) => error.includes("createFields") && error.includes("type"))).toBe(true); - const unsafe = validateTemporaryScumCompanionManifest((manifest) => { + const unsafe = validateTemporaryScumManifest((manifest) => { manifest.server.createFields[0].defaultValue = "/srv/hidden-server"; }); expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true); @@ -244,167 +232,6 @@ describe("plugin manifest validation", () => { ])); }); - it("defines a generated SCUM companion config without inline proof or session material", () => { - const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); - const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { - gameClientBridge: { companion?: GameClientBridgeCompanionDeclaration }; - }; - const companion = manifest.gameClientBridge.companion; - expect(companion).toMatchObject({ - profileKey: "scum-client-manager", - configTemplateKey: "client-config", - configFormat: "yaml", - platformBaseUrlSource: "run-control", - registrationProof: "hmac-sha256", - proofMaterialSource: "component-package", - proofMaterialEnv: "SCUM_COMPONENT_PROOF", - sessionMode: "component-session", - tlsPolicy: "verify-system-roots", - heartbeatIntervalSeconds: 30, - requestTimeoutSeconds: 15 - }); - const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, companion!.configSchemaRef), "utf8")); - const example = JSON.parse(fs.readFileSync(path.join(pluginDir, "schemas/companion/config.generated.example.json"), "utf8")); - const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(schema); - expect(validate(example), JSON.stringify(validate.errors)).toBe(true); - expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i); - expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } }); - }); - - it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => { - const policyErrors = validateTemporaryScumCompanionManifest((manifest) => { - manifest.gameClientBridge.companion.tlsPolicy = "skip-verification"; - }); - expect(policyErrors.some((error) => error.includes("tlsPolicy") || error.includes("secure component registration/session/TLS policy"))).toBe(true); - - const materialErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => { - const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json"); - const example = JSON.parse(fs.readFileSync(examplePath, "utf8")); - example.proof.sessionToken = "inline-session-material"; - writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example); - }); - expect(materialErrors.some((error) => error.includes("inline proof/session material") || error.includes("additional properties"))).toBe(true); - - const environmentErrors = validateTemporaryScumCompanionManifest((manifest) => { - manifest.gameClientBridge.companion.proofMaterialEnv = "LD_PRELOAD"; - }); - expect(environmentErrors.some((error) => error.includes("proofMaterialEnv"))).toBe(true); - }); - - it("rejects generated SCUM companion configs with incomplete capabilities or unsafe Platform URLs", () => { - const capabilityErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => { - const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json"); - const example = JSON.parse(fs.readFileSync(examplePath, "utf8")); - example.capabilities = ["component.register", "component.heartbeat", "component.health", "game-client.bridge"]; - writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example); - }); - expect(capabilityErrors.some((error) => error.includes("capabilities"))).toBe(true); - - for (const unsafeURL of ["https://user:raw-token@example.test?session=raw-token#fragment", "https://?missing-host"]) { - const urlErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => { - const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json"); - const example = JSON.parse(fs.readFileSync(examplePath, "utf8")); - example.platform.baseUrl = unsafeURL; - writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example); - }); - expect(urlErrors.some((error) => error.includes("platform.baseUrl"))).toBe(true); - } - }); - - it("cross-validates generated SCUM companion config against its declaration and runtime profile", () => { - const proofErrors = validateTemporaryScumCompanionManifest((manifest) => { - manifest.gameClientBridge.companion.proofMaterialEnv = "OTHER_COMPONENT_PROOF"; - }); - expect(proofErrors.some((error) => error.includes("proof.materialEnv") && error.includes("proofMaterialEnv"))).toBe(true); - - const profileErrors = validateTemporaryScumCompanionManifest((manifest) => { - const manager = manifest.runtimeProfiles.clientManagers.find((candidate: Record) => candidate.key === "scum-client-manager"); - manager.health.requiredCapabilities = manager.health.requiredCapabilities.filter((capability: string) => capability !== "logs.stream"); - }); - expect(profileErrors.some((error) => error.includes("capabilities") && error.includes("requiredCapabilities"))).toBe(true); - - const sessionErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => { - const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json"); - const example = JSON.parse(fs.readFileSync(examplePath, "utf8")); - example.session.mode = "legacy-shared-token"; - writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example); - }); - expect(sessionErrors.some((error) => error.includes("session"))).toBe(true); - }); - - it("rejects unsafe optional fields declared only by the SCUM companion config schema", () => { - const schemaErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => { - const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json"); - const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); - schema.properties.hostPath = { type: "string", minLength: 1, maxLength: 200 }; - writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema); - }); - expect(schemaErrors.some((error) => error.includes("hostPath") && error.includes("raw host path"))).toBe(true); - }); - - it("rejects legacy companion endpoints, insecure TLS flags, and credential keys", () => { - const cases = [ - { - name: "legacy shared-token endpoint", - mutate(schema: Record, _example: Record): void { - schema.description = "legacy /api/v1/scum-clients/commands endpoint"; - } - }, - { - name: "insecure TLS flag", - mutate(schema: Record, _example: Record): void { - schema.description = "InsecureSkipVerify"; - } - }, - { - name: "inline credential key", - mutate(schema: Record, example: Record): void { - schema.properties.credential = { type: "string", minLength: 1, maxLength: 200 }; - example.credential = "legacy-shared-value"; - } - } - ]; - - for (const testCase of cases) { - const errors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => { - const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json"); - const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json"); - const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")) as Record; - const example = JSON.parse(fs.readFileSync(examplePath, "utf8")) as Record; - testCase.mutate(schema, example); - writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema); - writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example); - }); - - expect(errors, testCase.name).toContain( - "manifest.gameClientBridge.companion.configSchemaRef: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material" - ); - } - }); - - it("keeps the production SCUM companion free of unsafe legacy transport and updater patterns", () => { - const companionDir = path.join(pluginsRoot, "examples/scum-server-plugin/companion"); - const forbiddenPatterns = [ - { name: "legacy shared-token endpoint", pattern: /\/api\/v1\/scum-clients\//i }, - { name: "legacy shared credential", pattern: /\b(?:SCUMClientCredential|scum_client_credential)\b/i }, - { name: "disabled TLS verification", pattern: /\bInsecureSkipVerify\s*:\s*true\b/ }, - { name: "arbitrary process or shell execution", pattern: /(?:\b(?:os\/exec|exec\.Command(?:Context)?|os\.StartProcess|syscall\.Exec)\b|\b(?:bash|zsh|powershell|pwsh|cmd(?:\.exe)?)\s+-[a-z/])/i }, - { name: "direct socket transport", pattern: /(?:\bnet\.(?:Dial|DialTimeout)\s*\(|\b(?:tcp|unix|ws):\/\/)/i }, - { name: "arbitrary URL self-update or download", pattern: /(?:\b(?:self_?update|update_?url|download_?url|updater)\b|\bhttp\.(?:Get|DefaultClient\.Get)\s*\(|\b(?:curl|wget)\b)/i } - ]; - - const productionFiles = listProductionGoFiles(companionDir); - expect(productionFiles.length).toBeGreaterThan(0); - for (const file of productionFiles) { - const source = fs.readFileSync(file, "utf8"); - const relativeFile = path.relative(companionDir, file); - for (const forbidden of forbiddenPatterns) { - const match = source.match(forbidden.pattern); - expect(match ? `${relativeFile}: ${match[0]}` : null, forbidden.name).toBeNull(); - } - } - }); - it("covers the SCUM 4.1 bridge and lifecycle declarations", () => { const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { @@ -450,7 +277,6 @@ describe("plugin manifest validation", () => { expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server"); expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([ - "companion.diagnostics", "player.lookup", "reward.deliver", "player.notify", @@ -459,7 +285,8 @@ describe("plugin manifest validation", () => { "restart.prepare", "maintenance.prepare" ])); - expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"])); + expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"])); + expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined(); expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([ expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) }) ])); @@ -473,8 +300,8 @@ describe("plugin manifest validation", () => { expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"])); expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"])); expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "game-port", "query-port", "max-players", "welcome-message"])); - expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command"); - expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events", "scum-client-events"])); + expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")).toBeUndefined(); + expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events"])); }); it("declares bounded and permissioned SCUM bridge commands", () => { @@ -492,7 +319,6 @@ describe("plugin manifest validation", () => { }; }; const expected = { - "companion.diagnostics": { permission: "server.game-client.read" }, "player.lookup": { permission: "server.game-client.read" }, "reward.deliver": { permission: "server.game-client.command" }, "player.notify": { permission: "server.game-client.command" }, @@ -559,7 +385,7 @@ describe("plugin manifest validation", () => { pages: Array<{ pageKey: string; snapshotTypes?: string[] }>; }; }; - const expectedTypes = ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]; + const expectedTypes = ["online.sessions", "players", "squads", "vehicles", "flags"]; const snapshotsByType = new Map(manifest.gameClientBridge.snapshots.map((snapshot) => [snapshot.type, snapshot])); expect([...snapshotsByType.keys()]).toEqual(expect.arrayContaining(expectedTypes)); @@ -639,7 +465,7 @@ describe("plugin manifest validation", () => { const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database"); expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" }); expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); - expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database" })])); + expect(manifest.runtimeProfiles?.dataTargets).toBeUndefined(); for (const key of expectedKeys) { const template = templatesByKey.get(key)!; expect(template.engine).toBe("sqlite"); @@ -703,22 +529,11 @@ describe("plugin manifest validation", () => { }); - it("aligns the SCUM Client Manager declaration with the real Go bootstrap", () => { + it("does not declare a SCUM client-manager profile", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as { - runtimeProfiles?: { clientManagers?: Array<{ - key: string; - build?: { workspaceRef?: string; entryRef?: string }; - configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>; - deployment?: { arguments?: string[] }; - health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number }; - }> }; + runtimeProfiles?: { clientManagers?: unknown[] }; }; - const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager"); - expect(manager?.build).toMatchObject({ entryRef: "main.go" }); - expect(manager?.build).not.toHaveProperty("workspaceRef"); - expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]); - expect(manager?.deployment?.arguments).toBeUndefined(); - expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 }); + expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined(); }); it("accepts the Minecraft server plugin manifest", () => { @@ -953,7 +768,7 @@ describe("plugin SDK", () => { it("builds safe game-client bridge requests without component transport material", () => { const request = createGameClientBridgeQueueRequest({ - profileKey: "scum-client", + profileKey: "example-client", commandType: "diagnostic.ping", payload: { message: "hello" }, idempotencyKey: "diagnostic-1", @@ -1302,18 +1117,18 @@ describe("plugin SDK", () => { action: "logs.backfill.request", payload: { sourceKey: "chat-log", limit: "500" } }); - expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "scum-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({ + expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "example-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({ action: "client-manager.request", - payload: { operation: "generate", profileKey: "scum-client-manager" } + payload: { operation: "generate", profileKey: "example-client-manager" } }); - expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "scum-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret"); - expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "scum-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({ + expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "example-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret"); + expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "example-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({ action: "client-manager.request", payload: { operation: "deploy", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: "2" } }); expect(parseClientManagerLifecycleStatus({ installationId: "cm-install-1", - profileKey: "scum-client-manager", + profileKey: "example-client-manager", status: "online", phase: "healthy", targetOS: "windows", @@ -1326,7 +1141,7 @@ describe("plugin SDK", () => { })).toMatchObject({ installationId: "cm-install-1", deploymentGeneration: 2, actions: ["stop", "restart", "update", "uninstall"] }); expect(parseClientManagerLifecycleStatus({ installationId: "cm-install-1", - profileKey: "scum-client-manager", + profileKey: "example-client-manager", status: "online", deploymentGeneration: "2", actions: "stop", diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index f701c17..9190089 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -18,7 +18,7 @@ const surfaceData: SCUMSurfaceData = { players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, lastLoginIp: "203.0.113.7", position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }], squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }], members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }], - events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }], + events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", rconCommand: "#start_event event-1", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }], eventProduces: [{ _recordKey: "event-1:produce-1", id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }], eventRuns: [{ id: "run-1", eventId: "event-1", status: "running", startedAt: "2026-08-10T00:00:00Z", summary: "Round 1" }], nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }], @@ -93,17 +93,14 @@ describe("SCUM plugin feature module", () => { expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` }); }); - it("merges player snapshots only by stable identifiers and ignores name-only online sessions", async () => { + it("reads player records only from plugin-owned collections", async () => { const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } }); - const gameClient = gameClientActions(); - gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] }); - const data = await loadSCUMSurface({ pluginData, gameClient }, "players"); - expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "vehicles"]); - expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" }); + const data = await loadSCUMSurface({ pluginData }, "players"); + expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", displayName: "Mira", online: false }); const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] }); expect(sameName).toHaveLength(2); expect(sameName.find((player) => player.steamId === "steam-2")).toMatchObject({ online: false }); - expect(dataClientSource).not.toContain('type: "online.sessions"'); + expect(dataClientSource).not.toContain("scum-client-manager"); }); it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { @@ -131,14 +128,13 @@ describe("SCUM plugin feature module", () => { it("persists event produces, event runs, and gift resets in plugin-owned collections", async () => { const pluginData = pluginDataActions(); - const gameClient = gameClientActions(); - const actions: SCUMWorkspaceActions = { pluginData, gameClient }; + const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); + const actions: SCUMWorkspaceActions = { pluginData, dispatch }; await saveEventProduce(actions, { id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventProduces, "event-1:produce-1", expect.objectContaining({ eventId: "event-1", tradeGoodsId: "goods-1" })); - await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces); - expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({ - eventType: "range", class: 1, placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, - produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] + await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ action: "remote.access.request", payload: expect.objectContaining({ + capability: "remote.run.rcon.command", "input.command": "#start_event event-1" }) })); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventRuns, expect.any(String), expect.objectContaining({ eventId: "event-1", status: "queued", produces: surfaceData.eventProduces })); await resetGiftClaim(actions, { _recordKey: "claim-1" }); @@ -147,38 +143,31 @@ describe("SCUM plugin feature module", () => { expect(pluginData.put).toHaveBeenCalledWith(scumCollections.pendingGifts, "pending-1", expect.objectContaining({ status: "pending", receivedAt: null })); }); - it("queues gift and event commands through the host-compatible generic gameClient bridge", async () => { + it("queues gift and event commands through platform-mediated SCUM RCON", async () => { const pluginData = pluginDataActions(); - const gameClient = gameClientActions(); - const actions: SCUMWorkspaceActions = { pluginData, gameClient }; + const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); + const actions: SCUMWorkspaceActions = { pluginData, dispatch }; await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]); - expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "reward.deliver", payload: expect.objectContaining({ playerId: "steam-1", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], operations: ["#announce Starter pack", "#SetFamePoints 250"] }) })); + expect(dispatch.mock.calls.map(([request]) => request.payload?.["input.command"])).toEqual(["#SpawnItem BP_Cash_01 2", "#announce Starter pack", "#SetFamePoints 250"]); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, expect.any(String), expect.objectContaining({ giftCode: "starter-pack", playerId: "steam-1", status: "queued" })); - await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces); - expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "event.start", payload: expect.objectContaining({ eventId: "event-1", eventType: "range", class: 1, placard: "Event starting", percent: 75, produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] }) })); - expect(Object.keys(gameClient).sort()).toEqual(["get", "list", "queue", "snapshots"]); + await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-1" }) })); }); it("defaults activity class to range and strips collection metadata from queued produces", async () => { const pluginData = pluginDataActions(); - const gameClient = gameClientActions(); - await startEvent({ pluginData, gameClient }, { id: "event-default", name: "Default Event" }, [{ + const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); + await startEvent({ pluginData, dispatch }, { id: "event-default", name: "Default Event", rconCommand: "#start_event event-default" }, [{ _recordKey: "event-default:produce-1", id: "produce-1", eventId: "event-default", updatedAt: "2026-08-10T00:00:00Z", tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 }]); - expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({ - eventType: "range", class: 1, npc: 0, item: 0, zombie: 0, animal: 0, - produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 }] - }) })); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-default" }) })); }); it("keeps positive event duration and counts above the removed arbitrary limits", async () => { - const gameClient = gameClientActions(); - await startEvent({ pluginData: pluginDataActions(), gameClient }, { id: "event-large", name: "Large Event", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]); - expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ - durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004, - produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }] - }) })); + const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); + await startEvent({ pluginData: pluginDataActions(), dispatch }, { id: "event-large", name: "Large Event", rconCommand: "#start_event event-large", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-large" }) })); }); it("renders searchable user management from real collection values", () => { @@ -241,7 +230,7 @@ describe("SCUM plugin feature module", () => { it("renders activity definitions, status filters, runs, and records", () => { const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" }); expect(view.inputs.map((input) => input.label)).toContain("活动状态"); - expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"])); + expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "RCON 命令", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"])); expect(view.texts).toContain("Friday Range"); expect(view.texts).toContain("running"); expect(view.texts).toContain("最近活动记录"); @@ -318,16 +307,6 @@ function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: }; } -function gameClientActions() { - const queue = vi.fn["queue"]>(async () => ({ id: "command-1", state: "pending" })); - const get = vi.fn["get"]>(async () => ({ id: "command-1", state: "pending" })); - const list = vi.fn["list"]>(async () => ({ items: [], count: 0 })); - const snapshots = vi.fn["snapshots"]>(async () => ({ items: [], count: 0 })); - return { - queue, get, list, snapshots - }; -} - function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string } = {}) { const nodes: string[] = []; const texts: string[] = []; @@ -355,7 +334,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin return [value, () => undefined]; } }; - const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) }; + const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) }; renderPluginPage(react, { page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },