diff --git a/platform/api/log_events_handlers.go b/platform/api/log_events_handlers.go index 5af082b..eb39ac3 100644 --- a/platform/api/log_events_handlers.go +++ b/platform/api/log_events_handlers.go @@ -17,7 +17,6 @@ type componentLogServerContextKey struct{} const ( logEventHeartbeatInterval = 15 * time.Second - managedLogSessionIDPrefix = "log-session:" ) // serverLogEvents streams platform-accepted live append events for the terminal drawer. @@ -26,7 +25,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { writeMethodNotAllowed(w, http.MethodGet) return } - instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r) + instance, streams, subscription, err := h.openLogEventSubscription(r) if err != nil { writeServiceError(w, err) return @@ -46,12 +45,10 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) active := supervisedLogSession{} - if liveEligible { - if isComponentLogRequest(r) { - active = activeComponentLogSession(streams) - } else { - active = activeSupervisedLogSession(streams) - } + if isComponentLogRequest(r) { + active = activeComponentLogSession(streams) + } else { + active = activeSupervisedLogSession(streams) } emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active) if err != nil { @@ -82,7 +79,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { continue } if subscriptionEvent.ProcessState != domain.ServerInstanceStateRunning { - liveEligible = false if active.sessionID == "" { continue } @@ -94,14 +90,11 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { flusher.Flush() continue } - streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r)) + streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r)) if err != nil { return } - next := supervisedLogSession{} - if liveEligible { - next = activeSupervisedLogSession(streams) - } + next := activeSupervisedLogSession(streams) if sameSupervisedLogSession(active, next) { continue } @@ -113,7 +106,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { flusher.Flush() continue } - if subscriptionEvent.Kind != service.LogEventSubscriptionEventLog || !liveEligible { + if subscriptionEvent.Kind != service.LogEventSubscriptionEventLog { continue } event := subscriptionEvent.LogEvent @@ -122,20 +115,16 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { candidate = supervisedLogSession{} } if candidate.sessionID != "" && newerLogSession(candidate, active) { - streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r)) - if err != nil { - return - } - next := supervisedLogSession{} - if liveEligible { - next = activeSupervisedLogSession(streams) - } + next := candidate if !sameSupervisedLogSession(active, next) { active = next emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active) if err != nil { return } + if event.Entry.Seq > 0 && emittedThrough[event.Stream.ID] >= event.Entry.Seq { + emittedThrough[event.Stream.ID] = event.Entry.Seq - 1 + } flusher.Flush() } } @@ -251,10 +240,9 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta return emittedThrough, nil } -func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, bool, service.LogEventSubscription, error) { +func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) { var instance domain.ServerInstance var streams []domain.LogStream - var liveEligible bool var subscription service.LogEventSubscription var err error if serverInstanceID, ok := r.Context().Value(componentLogServerContextKey{}).(string); ok && strings.TrimSpace(serverInstanceID) != "" { @@ -266,29 +254,29 @@ func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerI sessionID := bearerToken(r) instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id")) if err != nil { - return domain.ServerInstance{}, nil, false, subscription, err + return domain.ServerInstance{}, nil, subscription, err } subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID) if err != nil { - return domain.ServerInstance{}, nil, false, subscription, err + return domain.ServerInstance{}, nil, subscription, err } } else { instance, err = h.core.GetServerInstance(r.PathValue("id")) if err != nil { - return domain.ServerInstance{}, nil, false, subscription, err + return domain.ServerInstance{}, nil, subscription, err } subscription, err = h.core.SubscribeLogEvents(instance.ID) if err != nil { - return domain.ServerInstance{}, nil, false, subscription, err + return domain.ServerInstance{}, nil, subscription, err } } if err == nil { - streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r)) + streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r)) } if err != nil && subscription.Close != nil { subscription.Close() } - return instance, streams, liveEligible, subscription, err + return instance, streams, subscription, err } func withComponentLogServer(r *http.Request, serverInstanceID string) *http.Request { @@ -300,46 +288,42 @@ func isComponentLogRequest(r *http.Request) bool { return ok } -func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool) ([]domain.LogStream, bool, error) { +func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool) ([]domain.LogStream, error) { instance, err := h.core.GetServerInstance(serverInstanceID) if err != nil { - return nil, false, err + return nil, err } - if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.RunEndpointID) == "" { - return nil, false, nil + if strings.TrimSpace(instance.RunEndpointID) == "" { + return nil, nil + } + if !includeDeclaredStreams && instance.State != domain.ServerInstanceStateRunning { + return nil, nil } endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID) if err != nil { - return nil, false, err + return nil, err } if endpoint.Status != domain.RunEndpointStatusOnline { - return nil, false, nil - } - logSessionID := strings.TrimPrefix(instance.LifecycleProcessID, managedLogSessionIDPrefix) - if logSessionID == instance.LifecycleProcessID || strings.TrimSpace(logSessionID) == "" { - return nil, false, nil + return nil, nil } streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID}) if err != nil { - return nil, false, err + return nil, err } current := make([]domain.LogStream, 0, len(streams)) for _, stream := range streams { if includeDeclaredStreams { - if stream.LogSessionID != "" && stream.LogSessionID != logSessionID { - continue - } if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram { continue } current = append(current, stream) continue } - if stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == logSessionID { + if stream.Source == domain.LogStreamSourceProcess && strings.TrimSpace(stream.LogSessionID) != "" && !stream.SessionStartedAt.IsZero() { current = append(current, stream) } } - return current, true, nil + return current, nil } func writeSSEJSON(w http.ResponseWriter, eventName string, id string, value any) error { diff --git a/platform/api/log_ingest_handlers_test.go b/platform/api/log_ingest_handlers_test.go index 7de197c..c85b469 100644 --- a/platform/api/log_ingest_handlers_test.go +++ b/platform/api/log_ingest_handlers_test.go @@ -207,7 +207,7 @@ func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) { } } -func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.T) { +func TestLogEventsSSEInitialSnapshotUsesRunStreamEnvelopeWhenOnline(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) @@ -222,7 +222,9 @@ func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing. running.ObservedAt = time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", running), http.StatusOK) - assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")) + if body := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events").Body.String(); !strings.Contains(body, `"logSessionId":"session-current"`) || strings.Contains(body, `"logSessionId":"session-missing"`) { + t.Fatalf("expected initial session to come from Run log stream envelope, body=%s", body) + } assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.0", Status: domain.RunEndpointStatusOffline, CapabilityFingerprint: "cap-logs", Capacity: dto.RunCapacityResponse{MaxJobs: 1}}), http.StatusOK) assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")) } @@ -266,14 +268,15 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne next.LogSessionID = "session-next" next.SessionStartedAt = nextStartedAt assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", next), http.StatusOK) + assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) + assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) + assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`) statusReport.ManagedProcessID = "log-session:session-next" statusReport.ObservationSeq = 1 statusReport.ObservedAt = nextStartedAt statusReport.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK) - assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) - assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21) nextLive.LogSessionID = "session-next" nextLive.SessionStartedAt = nextStartedAt @@ -327,11 +330,12 @@ func TestLogEventsSSEOrdersSessionSwitchAndAdditionalStreamWithoutDuplicates(t * if _, err := core.IngestLogBatch(next.ToDomain()); err != nil { t.Fatalf("ingest next session stdout: %v", err) } + assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) + assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) + assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`) if _, err := core.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, ManagedProcessID: "log-session:session-next", ObservationSeq: 1, ObservedAt: nextStartedAt, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}}); err != nil { t.Fatalf("report next managed process: %v", err) } - assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) - assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21) stderr.LogSessionID = "session-next" diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index bea08f3..e7cc1b5 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -1398,7 +1398,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) { t.Fatalf("expected permission denied safe envelope, got %+v", denied) } - unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + unsafe := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-unsafe-1", PluginID: "game.example", RouteKey: "logs", @@ -1406,9 +1406,11 @@ func TestPluginBridgeExecuteAPI(t *testing.T) { Action: string(domain.PluginBridgeActionFilesRequest), Payload: map[string]string{"key": "/Users/tasia/.ssh/id_rsa", "idempotencyKey": "idem-unsafe"}, }, ownerSession) - assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation) + if unsafe.Status != "error" || unsafe.Error == nil || unsafe.Error.Code != "execution_failed" { + t.Fatalf("expected scoped file validation envelope, got %+v", unsafe) + } - for _, body := range []string{mustJSON(t, serverRead), mustJSON(t, logs), mustJSON(t, lifecycle), mustJSON(t, lifecycleMismatch), mustJSON(t, fileDispatch), mustJSON(t, aiResponse), mustJSON(t, denied)} { + for _, body := range []string{mustJSON(t, serverRead), mustJSON(t, logs), mustJSON(t, lifecycle), mustJSON(t, lifecycleMismatch), mustJSON(t, fileDispatch), mustJSON(t, aiResponse), mustJSON(t, denied), mustJSON(t, unsafe)} { for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} { if strings.Contains(body, forbidden) { t.Fatalf("bridge response exposed forbidden fragment %q: %s", forbidden, body) diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 51681f3..63f769e 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -106,22 +106,6 @@ type GameClientBridgeFeatureDeclaration struct { RequiredEventProducers []string } -type GameClientBridgeCompanionDeclaration struct { - ProfileKey string - ConfigTemplateKey string - ConfigSchemaRef string - ConfigFormat string - PlatformBaseURLSource string - RegistrationProof string - ProofMaterialSource string - ProofMaterialEnv string - SessionMode string - TLSPolicy string - HeartbeatIntervalSeconds int - CommandPollIntervalSeconds int - RequestTimeoutSeconds int -} - type GameClientBridgeManifest struct { Commands []GameClientBridgeCommandDeclaration Snapshots []GameClientBridgeSnapshotDeclaration @@ -131,7 +115,6 @@ type GameClientBridgeManifest struct { Retention GameClientBridgeRetention Pages []GameClientBridgePageContract Features []GameClientBridgeFeatureDeclaration - Companion GameClientBridgeCompanionDeclaration } type GameClientBridgeResultStatus string diff --git a/platform/dto/game_client_bridge_companion_test.go b/platform/dto/game_client_bridge_companion_test.go deleted file mode 100644 index 10fd1d3..0000000 --- a/platform/dto/game_client_bridge_companion_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package dto - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestGameClientBridgeCompanionDeclarationRoundTripsWithoutMaterial(t *testing.T) { - body := GameClientBridgeManifestBody{ - CommandRetentionSeconds: 86400, - MaxCommands: 1000, - Companion: &GameClientBridgeCompanionDeclarationBody{ - 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, - }, - } - domainValue := body.ToDomain() - if domainValue.Companion.ProfileKey != "scum-client-manager" || domainValue.Companion.TLSPolicy != "verify-system-roots" { - t.Fatalf("companion declaration conversion lost fields: %+v", domainValue.Companion) - } - projection := gameClientBridgeManifestFromDomain(domainValue) - encoded, err := json.Marshal(projection) - if err != nil { - t.Fatalf("marshal companion declaration: %v", err) - } - serialized := string(encoded) - for _, forbidden := range []string{"authKey", "componentKey", "sessionToken", "credential", "secretRef", "hostPath", "runSocket"} { - if strings.Contains(serialized, forbidden) { - t.Fatalf("companion declaration exposed %q: %s", forbidden, serialized) - } - } -} diff --git a/platform/dto/game_client_bridge_test.go b/platform/dto/game_client_bridge_test.go index 8a80c9f..95e94be 100644 --- a/platform/dto/game_client_bridge_test.go +++ b/platform/dto/game_client_bridge_test.go @@ -72,7 +72,7 @@ func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *tes } } -func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) { +func TestGameClientBridgeListResponseConversions(t *testing.T) { now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) command := domain.GameClientBridgeCommand{ ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, Priority: 3, State: domain.GameClientBridgeCommandSucceeded, @@ -95,7 +95,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) { claim.Items[0].Payload["scope"] = "changed" result.Result.Payload["healthy"] = false if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true { - t.Fatal("companion response aliases domain payload") + t.Fatal("bridge response aliases domain payload") } for name, value := range map[string]any{"claim": claim, "ack": ack, "result": result, "cancel": cancel, "ingest": ingest, "commands": commands, "snapshots": snapshots} { encoded, err := json.Marshal(value) diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 119770e..7366a61 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -353,22 +353,6 @@ type GameClientBridgeFeatureDeclarationBody struct { RequiredEventProducers []string `json:"requiredEventProducers,omitempty"` } -type GameClientBridgeCompanionDeclarationBody struct { - ProfileKey string `json:"profileKey"` - ConfigTemplateKey string `json:"configTemplateKey"` - ConfigSchemaRef string `json:"configSchemaRef"` - ConfigFormat string `json:"configFormat"` - PlatformBaseURLSource string `json:"platformBaseUrlSource"` - RegistrationProof string `json:"registrationProof"` - ProofMaterialSource string `json:"proofMaterialSource"` - ProofMaterialEnv string `json:"proofMaterialEnv"` - SessionMode string `json:"sessionMode"` - TLSPolicy string `json:"tlsPolicy"` - HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"` - CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"` - RequestTimeoutSeconds int `json:"requestTimeoutSeconds"` -} - type GameClientBridgeManifestBody struct { Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` @@ -379,7 +363,6 @@ type GameClientBridgeManifestBody struct { MaxCommands int `json:"maxCommands"` Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` - Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` } type GamePluginManifestBody struct { ID string `json:"id"` @@ -1268,11 +1251,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif for index, feature := range body.Features { features[index] = domain.GameClientBridgeFeatureDeclaration{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: domain.CopyStringSlice(feature.RequiredHandlers), RequiredEventProducers: domain.CopyStringSlice(feature.RequiredEventProducers)} } - companion := domain.GameClientBridgeCompanionDeclaration{} - if body.Companion != nil { - companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds} - } - return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} + return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features} } func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration { @@ -1727,11 +1706,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G for index, feature := range value.Features { features[index] = GameClientBridgeFeatureDeclarationBody{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: feature.RequiredHandlers, RequiredEventProducers: feature.RequiredEventProducers} } - var companion *GameClientBridgeCompanionDeclarationBody - if value.Companion.ProfileKey != "" { - companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds} - } - return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} + return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features} } func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody { diff --git a/platform/service/artifact_body_store.go b/platform/service/artifact_body_store.go index a9e6f43..6d21726 100644 --- a/platform/service/artifact_body_store.go +++ b/platform/service/artifact_body_store.go @@ -23,6 +23,7 @@ type ArtifactBodyStore interface { SaveTransfer(domain.ArtifactTransferSession) error LoadTransfers() ([]domain.ArtifactTransferSession, error) PutPayload(string, []byte) error + PutPayloadFromFile(string, string) error GetPayload(string) ([]byte, error) ReadPayloadRange(string, int64, int) ([]byte, error) OpenPayloadRange(string, int64, int64) (io.ReadCloser, error) @@ -68,6 +69,14 @@ func (store *MemoryArtifactBodyStore) PutPayload(artifactID string, payload []by return nil } +func (store *MemoryArtifactBodyStore) PutPayloadFromFile(artifactID string, sourcePath string) error { + payload, err := os.ReadFile(sourcePath) + if err != nil { + return fmt.Errorf("read artifact source file: %w", err) + } + return store.PutPayload(artifactID, payload) +} + func (store *MemoryArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) { store.mu.Lock() defer store.mu.Unlock() @@ -218,6 +227,12 @@ func (store *FileArtifactBodyStore) PutPayload(artifactID string, payload []byte return writeAtomicFile(store.payloadPath(artifactID), payload, 0o600) } +func (store *FileArtifactBodyStore) PutPayloadFromFile(artifactID string, sourcePath string) error { + store.mu.Lock() + defer store.mu.Unlock() + return copyAtomicFile(store.payloadPath(artifactID), sourcePath, 0o600) +} + func (store *FileArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) { store.mu.Lock() defer store.mu.Unlock() @@ -379,6 +394,67 @@ func stableStorageKey(value string) string { return hex.EncodeToString(sum[:]) } +func fileSizeAndChecksum(path string) (int64, string, error) { + file, err := os.Open(path) + if err != nil { + return 0, "", err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return 0, "", err + } + if !info.Mode().IsRegular() { + return 0, "", fmt.Errorf("artifact source is not a regular file") + } + hash := sha256.New() + size, err := io.Copy(hash, file) + if err != nil { + return 0, "", err + } + return size, "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil +} + +func copyAtomicFile(destinationPath string, sourcePath string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(destinationPath), 0o700); err != nil { + return fmt.Errorf("create durable body directory: %w", err) + } + source, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("open durable body source file: %w", err) + } + defer source.Close() + tmp := destinationPath + ".tmp" + file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return fmt.Errorf("open durable body temporary file: %w", err) + } + if err := file.Chmod(mode); err != nil { + _ = file.Close() + _ = os.Remove(tmp) + return fmt.Errorf("chmod durable body temporary file: %w", err) + } + if _, err := io.Copy(file, source); err != nil { + _ = file.Close() + _ = os.Remove(tmp) + return fmt.Errorf("copy durable body: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + _ = os.Remove(tmp) + return fmt.Errorf("sync durable body: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("close durable body: %w", err) + } + if err := os.Rename(tmp, destinationPath); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("replace durable body: %w", err) + } + return nil +} + func writeAtomicFile(path string, payload []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("create durable body directory: %w", err) diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index 7d0fbb0..397ee5f 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -99,6 +99,22 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error { return nil } builder := svc.configuredDistributionBuilder() + if fileBuilder, ok := builder.(distributionBuilderWithFileOutput); ok { + output, buildErr := fileBuilder.BuildFile(input, func(progress DistributionBuildProgress) { + _ = svc.updateDistributionBuildProgress(job, progress) + }) + if buildErr != nil { + return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr, input.AuthKey)) + } + defer func() { _ = output.Cleanup() }() + if _, err := svc.platformDistributionBuildInput(job); err != nil { + return svc.failDistributionBuildJob(job, "platform builder discarded output because the component key is no longer current") + } + if err := svc.storeDistributionBuildArtifactFile(input.ArtifactID, job.ID, output); err != nil { + return svc.failDistributionBuildJob(job, "platform builder could not record the distribution artifact") + } + return svc.succeedDistributionBuildJob(job, input.ArtifactID) + } var payload []byte var buildErr error if progressBuilder, ok := builder.(distributionBuilderWithProgress); ok { @@ -422,6 +438,49 @@ func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID return nil } +func (svc *CoreService) storeDistributionBuildArtifactFile(artifactID string, jobID string, output DistributionBuildOutput) error { + if strings.TrimSpace(artifactID) == "" { + return validationError("distribution build artifact id is required") + } + if strings.TrimSpace(output.Path) == "" || output.SizeBytes <= 0 || output.Checksum == "" { + return validationError("distribution build produced no package file") + } + stamp := svc.now() + svc.artifactMu.Lock() + defer svc.artifactMu.Unlock() + + artifact, err := svc.store.Artifacts().Get(artifactID) + if err != nil && !errors.Is(err, repo.ErrNotFound) { + return err + } + create := errors.Is(err, repo.ErrNotFound) + if create { + artifact = domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: jobID, CreatedAt: stamp} + } + if artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != jobID { + return validationError("distribution build artifact is outside the job scope") + } + artifact.SizeBytes = output.SizeBytes + artifact.Checksum = output.Checksum + artifact.State = domain.ArtifactStateAvailable + artifact.UpdatedAt = stamp + if err := validator.ValidateArtifact(artifact); err != nil { + return err + } + if err := svc.artifactStore.PutPayloadFromFile(artifact.ID, output.Path); err != nil { + return err + } + if create { + if err := svc.store.Artifacts().Create(artifact); err != nil { + return err + } + } else if err := svc.store.Artifacts().Update(artifact); err != nil { + return err + } + delete(svc.artifactPayloads, artifact.ID) + return nil +} + func (svc *CoreService) markDistributionBuildRunning(job *domain.Job) error { stamp := svc.now() svc.jobMu.Lock() diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index cb75f1b..68ee4ba 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -1,9 +1,12 @@ package service import ( + "bytes" "encoding/base64" "encoding/json" "errors" + "os" + "path/filepath" "strings" "sync" "testing" @@ -11,6 +14,7 @@ import ( "browser.local/platform/domain" "browser.local/platform/repo" + "browser.local/platform/validator" ) type captureDistributionBuilder struct { @@ -25,6 +29,11 @@ type progressDistributionBuilder struct { payload []byte } +type fileDistributionBuilder struct { + inputs chan domain.DistributionBuildInput + outputPath string +} + func (builder captureDistributionBuilder) Readiness() (bool, string) { return true, "" } @@ -58,6 +67,28 @@ func (builder progressDistributionBuilder) BuildWithProgress(_ domain.Distributi return domain.CopyBytes(builder.payload), nil } +func (builder fileDistributionBuilder) Readiness() (bool, string) { + return true, "" +} + +func (builder fileDistributionBuilder) Build(domain.DistributionBuildInput) ([]byte, error) { + return nil, errors.New("byte build path should not be used for file output builders") +} + +func (builder fileDistributionBuilder) BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error) { + if progress != nil { + progress(DistributionBuildProgress{Percent: 88, Message: "package_finalize: output file ready"}) + } + if builder.inputs != nil { + builder.inputs <- input + } + sizeBytes, checksum, err := fileSizeAndChecksum(builder.outputPath) + if err != nil { + return DistributionBuildOutput{}, err + } + return DistributionBuildOutput{Path: builder.outputPath, SizeBytes: sizeBytes, Checksum: checksum}, nil +} + func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) plugin, err := svc.store.GamePlugins().Get(instance.PluginID) @@ -393,6 +424,47 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T completeDistributionBuild(t, svc, distribution, nil) } +func TestCoreServiceStoresPlatformBuildFileOutput(t *testing.T) { + svc, session, instance := newDistributionTestFixture(t) + outputPath := filepath.Join(t.TempDir(), "run") + payload := bytes.Repeat([]byte("platform-build-file-output"), 8192) + if err := os.WriteFile(outputPath, payload, 0o600); err != nil { + t.Fatalf("write builder file output: %v", err) + } + inputs := make(chan domain.DistributionBuildInput, 1) + svc.ConfigureDistributionBuilder(fileDistributionBuilder{inputs: inputs, outputPath: outputPath}) + + distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ + ServerInstanceID: instance.ID, + TargetOS: "linux", + TargetArch: "amd64", + IdempotencyKey: "platform-builder-file-output", + }) + if err != nil { + t.Fatalf("generate platform distribution: %v", err) + } + select { + case <-inputs: + case <-time.After(time.Second): + t.Fatal("platform file builder did not receive internal build input") + } + distribution = completeDistributionBuild(t, svc, distribution, nil) + artifact, err := svc.GetArtifact(distribution.ArtifactID) + if err != nil { + t.Fatalf("get distribution artifact: %v", err) + } + if artifact.SizeBytes != int64(len(payload)) || artifact.Checksum != validator.BytesChecksum(payload) { + t.Fatalf("artifact metadata did not come from file output: %+v", artifact) + } + stored, err := svc.artifactStore.GetPayload(distribution.ArtifactID) + if err != nil { + t.Fatalf("get stored distribution payload: %v", err) + } + if !bytes.Equal(stored, payload) { + t.Fatalf("stored distribution payload changed during file-backed recording") + } +} + func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) inputs := make(chan domain.DistributionBuildInput, 1) diff --git a/platform/service/distribution_builder.go b/platform/service/distribution_builder.go index 848a672..7ec4ca9 100644 --- a/platform/service/distribution_builder.go +++ b/platform/service/distribution_builder.go @@ -20,6 +20,7 @@ import ( "time" "browser.local/platform/domain" + "browser.local/platform/validator" ) // DistributionBuilder executes a distribution build inside a platform-owned @@ -40,10 +41,28 @@ type DistributionBuildProgress struct { Message string } +type DistributionBuildOutput struct { + Path string + SizeBytes int64 + Checksum string + cleanup func() error +} + +func (output DistributionBuildOutput) Cleanup() error { + if output.cleanup == nil { + return nil + } + return output.cleanup() +} + type distributionBuilderWithProgress interface { BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error) } +type distributionBuilderWithFileOutput interface { + BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error) +} + // DockerDistributionBuilderConfig configures a container-per-build builder. type DockerDistributionBuilderConfig struct { DockerBinary string @@ -116,8 +135,7 @@ func runCommandStreamCombined(ctx context.Context, name string, args []string, o for scanner.Scan() { line := scanner.Text() mu.Lock() - output.WriteString(line) - output.WriteByte('\n') + appendBoundedBuilderOutput(&output, []byte(line+"\n")) mu.Unlock() if onLine != nil { onLine(line) @@ -197,17 +215,33 @@ func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildIn } func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error) { + output, err := builder.BuildFile(input, progress) + if err != nil { + return nil, err + } + defer func() { _ = output.Cleanup() }() + binary, err := os.ReadFile(output.Path) + if err != nil { + return nil, validationError("platform builder did not produce a readable distribution executable") + } + if int64(len(binary)) != output.SizeBytes || validator.BytesChecksum(binary) != output.Checksum { + return nil, validationError("platform builder output changed before it could be read") + } + return binary, nil +} + +func (builder *DockerDistributionBuilder) BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error) { if ready, reason := builder.Readiness(); !ready { - return nil, validationError(reason) + return DistributionBuildOutput{}, validationError(reason) } reportBuilderProgress(progress, 8, "env_check: platform builder readiness verified") sourceDir, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir)) if err != nil { - return nil, validationError("platform builder run source directory is invalid") + return DistributionBuildOutput{}, validationError("platform builder run source directory is invalid") } workspaceDir, err := filepath.Abs(strings.TrimSpace(builder.config.WorkspaceDir)) if err != nil { - return nil, validationError("platform builder workspace directory is invalid") + return DistributionBuildOutput{}, validationError("platform builder workspace directory is invalid") } ctx, cancel := context.WithTimeout(context.Background(), builder.config.Timeout) defer cancel() @@ -220,14 +254,20 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib } cacheDir, err := builder.cacheDir(workspaceDir) if err != nil { - return nil, validationError("platform builder cache directory is invalid") + return DistributionBuildOutput{}, validationError("platform builder cache directory is invalid") } // Workspaces stay isolated per plugin and per job as required by // run-build-download-flow. jobDir := filepath.Join(workspaceDir, sanitizeIDPart(input.PluginID), sanitizeIDPart(input.JobID)) if err := os.RemoveAll(jobDir); err != nil { - return nil, err + return DistributionBuildOutput{}, err } + cleanupJob := true + defer func() { + if cleanupJob { + _ = os.RemoveAll(jobDir) + } + }() outputDir := filepath.Join(jobDir, "output") inputDir := filepath.Join(jobDir, "input") buildDir := filepath.Join(jobDir, "build") @@ -236,15 +276,14 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib goModCacheDir := filepath.Join(cacheDir, "go-mod") for _, directory := range []string{outputDir, inputDir, buildDir, sourceMountDir, goBuildCacheDir, goModCacheDir} { if err := os.MkdirAll(directory, 0o700); err != nil { - return nil, err + return DistributionBuildOutput{}, err } } - defer func() { _ = os.RemoveAll(jobDir) }() reportBuilderProgress(progress, 14, "env_check: platform builder workspace prepared") if input.ComponentKind == domain.DistributionComponentRun { preparedSourceDir, err := builder.prepareRunSource(ctx, sourceDir, sourceMountDir, input, progress) if err != nil { - return nil, err + return DistributionBuildOutput{}, err } sourceMountDir = preparedSourceDir } @@ -253,59 +292,61 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib // through a job-channel response to a machine-side endpoint or a container // command-line argument. if strings.TrimSpace(input.AuthKey) == "" { - return nil, validationError("distribution build input is missing a component auth key") + return DistributionBuildOutput{}, validationError("distribution build input is missing a component auth key") } if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil { - return nil, err + return DistributionBuildOutput{}, err } seedPayload := []byte("[]") if strings.TrimSpace(input.WorkspaceSeed) != "" { decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed) if err != nil { - return nil, validationError("distribution build input has an invalid workspace seed") + return DistributionBuildOutput{}, validationError("distribution build input has an invalid workspace seed") } seedPayload = decoded } if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil { - return nil, err + return DistributionBuildOutput{}, err } lifecyclePlanPayload := []byte("{}") if input.AutonomousLifecycle != nil { encoded, err := json.Marshal(input.AutonomousLifecycle) if err != nil { - return nil, validationError("distribution build input has an invalid autonomous lifecycle plan") + return DistributionBuildOutput{}, validationError("distribution build input has an invalid autonomous lifecycle plan") } lifecyclePlanPayload = encoded } if err := os.WriteFile(filepath.Join(inputDir, "autonomous-lifecycle-plan.json"), lifecyclePlanPayload, 0o600); err != nil { - return nil, err + return DistributionBuildOutput{}, err } if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil { - return nil, err + return DistributionBuildOutput{}, err } outputName := strings.TrimSpace(input.OutputFilename) if outputName == "" || filepath.Base(outputName) != outputName { - return nil, validationError("distribution build input has an invalid output filename") + return DistributionBuildOutput{}, validationError("distribution build input has an invalid output filename") } args := builder.containerArgs(input, sourceMountDir, inputDir, buildDir, outputDir, goBuildCacheDir, goModCacheDir, outputName) reportBuilderProgress(progress, 18, "git_sync: platform builder container starting") output, err := builder.runBuildCommand(ctx, args, progress) if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return nil, validationError("platform builder timed out while building the distribution") + return DistributionBuildOutput{}, validationError("platform builder timed out while building the distribution") } - return nil, validationError("platform builder failed: " + safeBuilderFailureWithCommandError(output, err, input.AuthKey, builder.config.SourceDir, sourceDir, jobDir)) + return DistributionBuildOutput{}, validationError("platform builder failed: " + safeBuilderFailureWithCommandError(output, err, input.AuthKey, builder.config.SourceDir, sourceDir, jobDir)) } - reportBuilderProgress(progress, 92, "package_finalize: reading platform builder output") - binary, err := os.ReadFile(filepath.Join(outputDir, outputName)) + reportBuilderProgress(progress, 92, "package_finalize: validating platform builder output") + outputPath := filepath.Join(outputDir, outputName) + sizeBytes, checksum, err := fileSizeAndChecksum(outputPath) if err != nil { - return nil, validationError("platform builder did not produce a distribution executable") + return DistributionBuildOutput{}, validationError("platform builder did not produce a distribution executable") } - if len(binary) == 0 { - return nil, validationError("platform builder produced an empty distribution executable") + if sizeBytes == 0 { + return DistributionBuildOutput{}, validationError("platform builder produced an empty distribution executable") } - return binary, nil + cleanupJob = false + return DistributionBuildOutput{Path: outputPath, SizeBytes: sizeBytes, Checksum: checksum, cleanup: func() error { return os.RemoveAll(jobDir) }}, nil } // prepareRunSource implements the source phase of a Jenkins-style build. A @@ -422,6 +463,20 @@ func (builder *DockerDistributionBuilder) runBuildCommand(ctx context.Context, a const builderProgressMarker = "__platform_builder_progress__|" +const maxBuilderDiagnosticOutputBytes = 1024 * 1024 + +func appendBoundedBuilderOutput(output *bytes.Buffer, payload []byte) { + if len(payload) >= maxBuilderDiagnosticOutputBytes { + output.Reset() + _, _ = output.Write(payload[len(payload)-maxBuilderDiagnosticOutputBytes:]) + return + } + if overflow := output.Len() + len(payload) - maxBuilderDiagnosticOutputBytes; overflow > 0 { + _ = output.Next(overflow) + } + _, _ = output.Write(payload) +} + func parseBuilderProgressLine(line string) (DistributionBuildProgress, bool) { line = strings.TrimSpace(line) if !strings.HasPrefix(line, builderProgressMarker) { diff --git a/platform/service/distribution_builder_test.go b/platform/service/distribution_builder_test.go index 414b822..8617b03 100644 --- a/platform/service/distribution_builder_test.go +++ b/platform/service/distribution_builder_test.go @@ -309,6 +309,18 @@ func TestDockerDistributionBuilderStreamsProgressMarkers(t *testing.T) { } } +func TestAppendBoundedBuilderOutputKeepsTail(t *testing.T) { + var output bytes.Buffer + appendBoundedBuilderOutput(&output, bytes.Repeat([]byte("a"), maxBuilderDiagnosticOutputBytes)) + appendBoundedBuilderOutput(&output, []byte("tail")) + if output.Len() != maxBuilderDiagnosticOutputBytes { + t.Fatalf("builder diagnostic buffer grew beyond cap: %d", output.Len()) + } + if !strings.HasSuffix(output.String(), "tail") { + t.Fatalf("builder diagnostic buffer did not keep tail output") + } +} + func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) { sourceDir := createBuilderSource(t) workspaceDir := t.TempDir() diff --git a/platform/service/game_client_bridge.go b/platform/service/game_client_bridge.go index 2db728b..540ac65 100644 --- a/platform/service/game_client_bridge.go +++ b/platform/service/game_client_bridge.go @@ -130,7 +130,7 @@ func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatu } availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0} if len(missing) > 0 { - availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ") + availability.Reason = "plugin-owned bridge producer is missing " + strings.Join(missing, ", ") } result = append(result, availability) } diff --git a/platform/service/source_rcon_test.go b/platform/service/source_rcon_test.go index 88ba099..6fd0660 100644 --- a/platform/service/source_rcon_test.go +++ b/platform/service/source_rcon_test.go @@ -173,7 +173,7 @@ func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) { "declarationKey": "rcon", "targetKey": "rcon", "idempotencyKey": "bridge-rcon-1", - "input.command": "#ListPlayers", + "input.command": "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777", }, }) if err != nil { @@ -201,7 +201,7 @@ func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) { if err != nil { t.Fatalf("consume bridge RCON input: %v", err) } - if input.Command != "#ListPlayers" { + if input.Command != "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777" { t.Fatalf("expected bridge RCON command to remain verbatim, got %q", input.Command) } } diff --git a/platform/validator/game_client_bridge.go b/platform/validator/game_client_bridge.go index fb8107f..3feaaf6 100644 --- a/platform/validator/game_client_bridge.go +++ b/platform/validator/game_client_bridge.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "math" - "regexp" "strings" "unicode" "unicode/utf8" @@ -23,12 +22,6 @@ const ( maxGameClientBridgeSessionLength = 4096 ) -var ( - gameClientBridgeAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`) - gameClientBridgeCamelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`) - gameClientBridgeNonWord = regexp.MustCompile(`[^A-Za-z0-9]+`) -) - func ValidateGameClientBridgeQueueRequest(request domain.GameClientBridgeQueueRequest) error { var violations []string violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", request.ServerInstanceID, true) @@ -185,13 +178,6 @@ func appendGameClientBridgeText(violations []string, field, value string, maximu if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maximum || containsControlCharacter(value) { violations = append(violations, field+" is invalid") } - lowered := strings.ToLower(strings.TrimSpace(value)) - if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) { - violations = append(violations, field+" contains unsafe connection or host material") - } - for _, reason := range unsafePluginStringReasons(value) { - violations = append(violations, field+": "+reason) - } return violations } @@ -268,10 +254,6 @@ func validateGameClientBridgePayloadValue(field string, value any, depth int, bu violations = append(violations, field+" key "+fmt.Sprintf("%q", key)+" is invalid") continue } - if unsafeGameClientBridgePayloadKey(key) { - violations = append(violations, field+" contains forbidden key "+key) - continue - } violations = append(violations, validateGameClientBridgePayloadValue(field+"."+key, item, depth+1, budget)...) } return violations @@ -300,13 +282,6 @@ func validateGameClientBridgePayloadString(field, value string) []string { if containsControlCharacter(value) { violations = append(violations, field+" contains control characters") } - for _, reason := range unsafePluginStringReasons(value) { - violations = append(violations, field+": "+reason) - } - lowered := strings.ToLower(strings.TrimSpace(value)) - if containsUnsafeRuntimeSecret(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) { - violations = append(violations, field+" contains unsafe connection material") - } return violations } @@ -321,113 +296,3 @@ func validGameClientBridgePayloadKey(key string) bool { } return true } - -func unsafeGameClientBridgePayloadKey(key string) bool { - tokens := gameClientBridgePayloadKeyTokens(key) - if len(tokens) == 0 { - return true - } - normalized := strings.Join(tokens, "") - for _, exact := range []string{ - "absolutepath", "apikey", "commandline", "componentkey", "credential", "credentials", "directsocket", "dsn", "hostpath", "password", "passwd", "rawpath", "rawsql", "runendpoint", "runsocket", "script", "secret", "sessiontoken", "shell", "socket", "sql", "statement", "terminalcommand", - } { - if normalized == strings.ReplaceAll(exact, " ", "") { - return true - } - } - if last := tokens[len(tokens)-1]; last == "password" || last == "passwd" || last == "secret" || last == "credential" || last == "credentials" || last == "dsn" { - return true - } - for _, sequence := range [][]string{ - {"api", "key"}, - {"access", "key"}, - {"private", "key"}, - {"auth", "token"}, - {"access", "token"}, - {"client", "secret"}, - {"storage", "credential"}, - {"component", "key"}, - {"session", "token"}, - {"host", "path"}, - {"raw", "path"}, - {"absolute", "path"}, - {"file", "system", "path"}, - {"direct", "socket"}, - {"socket", "path"}, - {"socket", "address"}, - {"socket", "url"}, - {"socket", "endpoint"}, - {"run", "endpoint"}, - {"run", "url"}, - {"run", "socket"}, - {"run", "token"}, - {"run", "credential"}, - {"raw", "sql"}, - {"raw", "query"}, - {"sql", "text"}, - {"sql", "query"}, - {"sql", "statement"}, - {"arbitrary", "sql"}, - {"shell", "command"}, - {"shell", "script"}, - {"script", "body"}, - {"terminal", "command"}, - {"command", "line"}, - {"arbitrary", "shell"}, - } { - if gameClientBridgeContainsSensitiveSequence(tokens, sequence) { - return true - } - } - return false -} - -func containsEmbeddedGameClientBridgeHostPath(value string) bool { - for _, marker := range []string{"/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/users/", "/volumes/", "/opt/", `:\\`} { - if strings.Contains(value, marker) { - return true - } - } - return false -} - -func gameClientBridgeContainsSensitiveSequence(tokens, sequence []string) bool { - for start := 0; start+len(sequence) <= len(tokens); start++ { - matched := true - for index, expected := range sequence { - if tokens[start+index] != expected { - matched = false - break - } - } - if !matched { - continue - } - end := start + len(sequence) - if end == len(tokens) { - return true - } - switch tokens[end] { - case "address", "body", "content", "material", "path", "raw", "ref", "text", "url", "value": - return true - } - } - return false -} - -func gameClientBridgePayloadKeyTokens(key string) []string { - withAcronymBoundaries := gameClientBridgeAcronymBoundary.ReplaceAllString(key, `${1} ${2}`) - withCamelBoundaries := gameClientBridgeCamelBoundary.ReplaceAllString(withAcronymBoundaries, `${1} ${2}`) - return strings.Fields(strings.ToLower(gameClientBridgeNonWord.ReplaceAllString(withCamelBoundaries, " "))) -} - -func hasUnsafeGameClientBridgeReference(value string) bool { - for _, fragment := range []string{ - "unix://", "tcp://", "mysql://", "postgres://", "postgresql://", "mongodb://", "redis://", "sqlite://", "sqlserver://", "mssql://", "odbc:", "secret://", "vault://", "env://", "http://127.", "https://127.", "http://localhost", "https://localhost", - } { - if strings.Contains(value, fragment) { - return true - } - } - return false -} diff --git a/platform/validator/game_client_bridge_companion_test.go b/platform/validator/game_client_bridge_companion_test.go deleted file mode 100644 index fb3dd06..0000000 --- a/platform/validator/game_client_bridge_companion_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package validator - -import ( - "strings" - "testing" - - "browser.local/platform/domain" -) - -func TestValidateGameClientBridgeCompanionDeclarationIsUnsupported(t *testing.T) { - bridge := domain.GameClientBridgeManifest{ - Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, - Companion: domain.GameClientBridgeCompanionDeclaration{ - 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, - }, - } - violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, domain.GamePluginRuntimeProfiles{}) - if !strings.Contains(strings.Join(violations, "; "), "gameClientBridge.companion is no longer supported") { - t.Fatalf("expected companion unsupported violation, got %v", violations) - } -} diff --git a/platform/validator/game_client_bridge_test.go b/platform/validator/game_client_bridge_test.go index 10716ab..9bca394 100644 --- a/platform/validator/game_client_bridge_test.go +++ b/platform/validator/game_client_bridge_test.go @@ -53,8 +53,6 @@ func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) { {name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"}, {name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"}, {name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unexpected"}), want: "status"}, - {name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"}, - {name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"}, {name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"}, {name: "snapshot sequence", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "sequence"}, {name: "snapshot retention", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 31*24*60*60 + 1}}), want: "keepForSeconds"}, @@ -81,32 +79,40 @@ func TestValidateGameClientBridgePayloadAcceptsJSONValuesWithoutKeyFalsePositive } } -func TestValidateGameClientBridgePayloadRejectsUnsafeKeyPatterns(t *testing.T) { +func TestValidateGameClientBridgePayloadPreservesPluginOwnedKeyNames(t *testing.T) { keys := []string{"sessionToken", "authToken", "accessKey", "privateKey", "component-key", "databasePassword", "clientSecret", "api_key", "databaseDSN", "storageCredential", "hostPath", "absolute_path", "directSocket", "socketAddress", "runEndpoint", "runUrl", "rawSQL", "rawQuery", "sqlText", "sql_statement", "shellCommand", "shell_script", "scriptBody", "terminalCommand", "commandLine"} for _, key := range keys { t.Run(key, func(t *testing.T) { request := validBridgeQueueRequest() request.Payload = map[string]any{key: "value"} - err := ValidateGameClientBridgeQueueRequest(request) - if err == nil || !strings.Contains(err.Error(), "forbidden key") { - t.Fatalf("expected forbidden key rejection, got %v", err) + if err := ValidateGameClientBridgeQueueRequest(request); err != nil { + t.Fatalf("plugin-owned key %q should pass structural validation: %v", key, err) } }) } } -func TestValidateGameClientBridgePayloadRejectsUnsafeStringMaterial(t *testing.T) { +func TestValidateGameClientBridgePayloadPreservesOpaqueStringMaterial(t *testing.T) { values := []string{"/etc/passwd", "prefix path=/var/run/run.sock", `C:\\Users\\operator\\secret.txt`, "tcp://127.0.0.1:9000", "unix:///var/run/run.sock", "http://localhost:9000", "mysql://user:password@host/db", "secret://component/key", "vault://runtime/token", "Bearer abc123", "password=leak"} for index, value := range values { request := validBridgeQueueRequest() request.IdempotencyKey = fmt.Sprintf("case-%d", index) request.Payload = map[string]any{"value": value} - if err := ValidateGameClientBridgeQueueRequest(request); err == nil || !strings.Contains(err.Error(), "payload") { - t.Fatalf("expected unsafe value %q rejection, got %v", value, err) + if err := ValidateGameClientBridgeQueueRequest(request); err != nil { + t.Fatalf("opaque plugin value %q should pass structural validation: %v", value, err) } } } +func TestValidateGameClientBridgeResultAndCancelTextPreserveOpaqueContent(t *testing.T) { + if err := ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd password=opaque"}); err != nil { + t.Fatalf("opaque result summary should pass structural validation: %v", err) + } + if err := ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private game token text"}); err != nil { + t.Fatalf("opaque cancel reason should pass structural validation: %v", err) + } +} + func TestValidateGameClientBridgePayloadRejectsNonJSONValuesAndInvalidNumbers(t *testing.T) { tests := map[string]any{"typed map": map[string]string{"key": "value"}, "typed slice": []string{"value"}, "time": time.Now(), "channel": make(chan int), "not a number": math.NaN(), "infinity": math.Inf(1), "invalid number": json.Number("01")} for name, value := range tests { diff --git a/platform/validator/observability.go b/platform/validator/observability.go index e501441..7733739 100644 --- a/platform/validator/observability.go +++ b/platform/validator/observability.go @@ -109,8 +109,8 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin } var violations []string for key, value := range inputs { - if !runtimeIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) { - violations = append(violations, field+" key is invalid or unsafe") + if !runtimeIdentifierPattern.MatchString(key) { + violations = append(violations, field+" key is invalid") } limit := 2048 if remoteAdapterSQLInputKey(key) { @@ -119,20 +119,10 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin if len([]rune(value)) > limit { violations = append(violations, field+"."+key+" is too long") } - for _, reason := range unsafePluginStringReasons(value) { - violations = append(violations, field+"."+key+": "+reason) - } } return violations } -func unsafeRemoteAdapterInputKey(key string) bool { - if remoteAdapterSQLInputKey(key) { - return false - } - return unsafeGameClientBridgePayloadKey(key) -} - func remoteAdapterSQLInputKey(key string) bool { normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key)) switch normalized { diff --git a/platform/validator/observability_test.go b/platform/validator/observability_test.go index d3ac378..59e27d8 100644 --- a/platform/validator/observability_test.go +++ b/platform/validator/observability_test.go @@ -21,6 +21,9 @@ func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "scum-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';"}}); err != nil { t.Fatalf("expected SQL text input to validate: %v", err) } + if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "rcon", TargetKey: "scum-rcon", Capability: domain.JobCapabilityRemoteRunRCONCommand, IdempotencyKey: "rcon-command-1", Inputs: map[string]string{"command": "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777"}}); err != nil { + t.Fatalf("expected opaque RCON input to validate: %v", err) + } } func floatPtr(value float64) *float64 { return &value } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index fd2b7cf..be821db 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -33,6 +33,9 @@ var ( gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`) gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`) gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`) + gameClientBridgeAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`) + gameClientBridgeCamelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`) + gameClientBridgeNonWord = regexp.MustCompile(`[^A-Za-z0-9]+`) runtimeIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$`) ) @@ -467,8 +470,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st } func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { - companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) - if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { + if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 { return nil } var violations []string @@ -478,9 +480,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 { violations = append(violations, field+".maxCommands is invalid") } - if companionPresent { - violations = append(violations, field+".companion is no longer supported") - } transports := map[string]domain.RuntimeTransportProfile{} for _, transport := range runtimeProfiles.TransportProfiles { transports[transport.Key] = transport @@ -880,27 +879,8 @@ func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.Gam return violations } -func validCompanionProofEnvironment(value string) bool { - if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' { - return false - } - for _, character := range value[1:] { - if character != '_' && (character < 'A' || character > 'Z') && (character < '0' || character > '9') { - return false - } - } - reserved := map[string]struct{}{ - "COMSPEC": {}, "DYLD_INSERT_LIBRARIES": {}, "DYLD_LIBRARY_PATH": {}, "HOME": {}, "LD_LIBRARY_PATH": {}, "LD_PRELOAD": {}, - "PATH": {}, "PATHEXT": {}, "SHELL": {}, "SYSTEMROOT": {}, "TEMP": {}, "TMP": {}, "USERPROFILE": {}, "WINDIR": {}, - } - if _, exists := reserved[value]; exists { - return false - } - return true -} - func unsafeGameClientBridgeCommandType(value string) bool { - tokens := gameClientBridgePayloadKeyTokens(value) + tokens := gameClientBridgeCommandTypeTokens(value) tokenSet := make(map[string]struct{}, len(tokens)) for _, token := range tokens { tokenSet[token] = struct{}{} @@ -920,6 +900,12 @@ func unsafeGameClientBridgeCommandType(value string) bool { return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run") } +func gameClientBridgeCommandTypeTokens(value string) []string { + withAcronymBoundaries := gameClientBridgeAcronymBoundary.ReplaceAllString(value, `${1} ${2}`) + withCamelBoundaries := gameClientBridgeCamelBoundary.ReplaceAllString(withAcronymBoundaries, `${1} ${2}`) + return strings.Fields(strings.ToLower(gameClientBridgeNonWord.ReplaceAllString(withCamelBoundaries, " "))) +} + func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error { var violations []string violations = appendRequired(violations, "pluginId", request.PluginID) @@ -972,15 +958,6 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques if len([]rune(value)) > valueLimit { violations = append(violations, "payload value is too long") } - for _, reason := range unsafePluginStringReasons(key) { - violations = append(violations, "payload key: "+reason) - } - for _, reason := range unsafePluginStringReasons(value) { - violations = append(violations, "payload."+key+": "+reason) - } - if containsUnsafeRuntimeSecret(value) || strings.Contains(strings.ToLower(value), "unix://") || strings.Contains(strings.ToLower(value), "tcp://") { - violations = append(violations, "payload contains unsafe content") - } } if payloadSize > maxPluginBridgePayloadSize { violations = append(violations, "payload is too large") diff --git a/platform/validator/rules.md b/platform/validator/rules.md index 5a0acd8..715725e 100644 --- a/platform/validator/rules.md +++ b/platform/validator/rules.md @@ -2,7 +2,7 @@ - API handlers must use named DTOs from `platform/dto`. - Platform services must not accept raw plugin-provided host paths. -- AI provider secrets must be stored by reference and redacted from logs and plugin bridge responses. +- AI provider secrets must be stored by reference and hidden from platform diagnostics and plugin bridge responses. - Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references. - Server instance creation must validate plugin installation state and run endpoint capability compatibility. - `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress summaries, artifact metadata, log stream cursors, and run capability compatibility. @@ -10,4 +10,4 @@ - Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs. # Runtime and bridge validation -Runtime validation rejects undeclared operations, stale attempt/key generations, cross-owner/server/target artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Game-client bridge validation rejects legacy companion declarations and keeps operator-facing DTOs redacted before they cross the Platform boundary. +Runtime validation rejects undeclared operations, stale attempt/key generations, cross-owner/server/target artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Game-client bridge validation keeps operator-facing transport metadata bounded before it crosses the Platform boundary; plugin-owned request text, result payloads, records, and log bodies remain opaque. diff --git a/platform_web/schemas/runtimeUpdates.test.ts b/platform_web/schemas/runtimeUpdates.test.ts index 2fdd960..23bfba6 100644 --- a/platform_web/schemas/runtimeUpdates.test.ts +++ b/platform_web/schemas/runtimeUpdates.test.ts @@ -43,7 +43,7 @@ describe("safe dependency and Run update projections", () => { expect(updates.items[0]).toMatchObject({ phase: "rolled-back", rollback: true, checksum: digest }); }); - it.each(["leaseToken", "sessionToken", "secretRef", "hostPath", "socket", "credential", "pid", "payload", "bindings"])("rejects forbidden %s fields recursively", (field) => { + it.each(["leaseToken", "sessionToken", "secretRef", "hostPath", "socket", "credential"])("rejects forbidden top-level %s fields", (field) => { expect(() => parseSafeDependencyCatalog({ serverInstanceId: "server-1", pluginId: "game.runtime", @@ -51,12 +51,27 @@ describe("safe dependency and Run update projections", () => { profileKey: "local", targetOs: "linux", targetArch: "amd64", - probes: [{ key: "java", kind: "java.version", required: true, state: "unknown", [field]: "private" }], + [field]: "private", + probes: [{ key: "java", kind: "java.version", required: true, state: "unknown" }], plans: [], updatedAt: "2026-07-18T12:00:00Z" })).toThrow(/forbidden field/); }); + it("does not recursively reject plugin-owned or transport-like nested data", () => { + expect(parseSafeDependencyCatalog({ + serverInstanceId: "server-1", + pluginId: "game.runtime", + pluginVersion: "1.0.0", + profileKey: "local", + targetOs: "linux", + targetArch: "amd64", + probes: [{ key: "java", kind: "java.version", required: true, state: "unknown", payload: { note: "password=opaque" }, bindings: { serverRoot: "/Users/operator/server" } }], + plans: [], + updatedAt: "2026-07-18T12:00:00Z" + }).probes[0]).toMatchObject({ key: "java", state: "unknown" }); + }); + it("preserves raw host paths or credentials in evidence text", () => { expect(parseSafeDependencyCatalog({ serverInstanceId: "server-1", diff --git a/platform_web/schemas/runtimeUpdates.ts b/platform_web/schemas/runtimeUpdates.ts index c16e96a..80a27ca 100644 --- a/platform_web/schemas/runtimeUpdates.ts +++ b/platform_web/schemas/runtimeUpdates.ts @@ -24,12 +24,7 @@ const forbiddenProjectionKeys = new Set([ "stagingpath", "backuppath", "socket", - "credential", - "pid", - "content", - "payload", - "bindings", - "downloadref" + "credential" ]); export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse { const record = requiredRecordValue(value, "dependency catalog"); @@ -131,14 +126,9 @@ function parseRunUpdate(record: Record): RunUpdateJobResponse { } function rejectForbiddenProjection(value: unknown): void { - if (Array.isArray(value)) { - value.forEach(rejectForbiddenProjection); - return; - } if (!isRecord(value)) return; - for (const [key, field] of Object.entries(value)) { + for (const key of Object.keys(value)) { if (forbiddenProjectionKeys.has(key.toLowerCase())) throw new Error(`runtime projection contains forbidden field ${key}`); - rejectForbiddenProjection(field); } } diff --git a/platform_web/utils/pluginBridgeHost.test.ts b/platform_web/utils/pluginBridgeHost.test.ts index 78c5c0e..da31999 100644 --- a/platform_web/utils/pluginBridgeHost.test.ts +++ b/platform_web/utils/pluginBridgeHost.test.ts @@ -94,7 +94,7 @@ describe("plugin bridge host utilities", () => { }); }); - it("rejects denied, unsafe, and cancelled bridge execution locally", async () => { + it("rejects denied, invalid, and cancelled bridge execution locally", async () => { const context = createPluginBridgeHostContext({ plugin, routeKey: "logs", @@ -102,9 +102,7 @@ describe("plugin bridge host utilities", () => { themeTokens: { colorScheme: "dark", accentColor: "#22c55e" } }); expect(validateBridgeExecutionRequest(context, { requestId: "req-denied", action: "server.instances.read" })).toMatchObject({ code: "unsupported_action" }); - expect( - validateBridgeExecutionRequest(context, { requestId: "req-unsafe", action: "files.request", payload: { key: "/Users/tasia/.ssh/id_rsa" } }) - ).toMatchObject({ code: "unsafe_payload" }); + expect(validateBridgeExecutionRequest(context, { requestId: "req-invalid", action: "files.request", payload: { " key": "value" } })).toMatchObject({ code: "validation" }); const client = { executePluginBridge: vi.fn() }; const controller = new AbortController(); @@ -122,6 +120,11 @@ describe("plugin bridge host utilities", () => { expect(validateBridgeExecutionRequest(context, { requestId: "sql-1", action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.execute", declarationKey: "sqlite-db", targetKey: "scum-db", idempotencyKey: "sql-1", "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';" } })).toBeNull(); }); + it("passes plugin-owned bridge payload text through without frontend content scanning", () => { + const context = createPluginBridgeHostContext({ plugin, routeKey: "remote", serverInstanceId: "server-1", themeTokens: { colorScheme: "dark", accentColor: "#22c55e" } }); + expect(validateBridgeExecutionRequest(context, { requestId: "opaque-1", action: "remote.access.request", payload: { capability: "remote.run.rcon.command", command: "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777" } })).toBeNull(); + }); + it("dispatches mediated AI requests without provider configuration", async () => { const context = createPluginBridgeHostContext({ plugin, @@ -179,5 +182,19 @@ describe("plugin bridge host utilities", () => { chunkSizeBytes: "1048576" }) ).toBeNull(); + + expect( + parsePluginArtifactReference({ + artifactId: "artifact-1", + filename: "/Users/tasia/artifact.bin", + contentType: "application/octet-stream", + sizeBytes: "128", + checksum: "sha256:abc", + downloadUrl: "/api/v1/artifacts/artifact-1/content", + expiresAt: "2026-07-03T00:15:00Z", + rangeSupported: "true", + chunkSizeBytes: "1048576" + }) + ).toMatchObject({ filename: "/Users/tasia/artifact.bin" }); }); }); diff --git a/platform_web/utils/pluginBridgeHost.ts b/platform_web/utils/pluginBridgeHost.ts index 5248a2b..f07a5ab 100644 --- a/platform_web/utils/pluginBridgeHost.ts +++ b/platform_web/utils/pluginBridgeHost.ts @@ -113,13 +113,10 @@ export function validateBridgeExecutionRequest(context: PluginBridgeHostContext, if (encodedSize > 16 * 1024) { return { code: "payload_too_large", message: "bridge payload is too large" }; } - for (const [key, value] of Object.entries(payload)) { + for (const key of Object.keys(payload)) { if (!key.trim() || key !== key.trim()) { return { code: "validation", message: "bridge payload key is invalid" }; } - if (containsUnsafeBridgeContent(key) || containsUnsafeBridgeContent(value)) { - return { code: "unsafe_payload", message: "bridge payload contains unsafe content" }; - } } return null; } @@ -161,11 +158,6 @@ export function parsePluginArtifactReference(result: Record | un if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) { return null; } - for (const value of Object.values(reference)) { - if (typeof value === "string" && containsUnsafeBridgeContent(value)) { - return null; - } - } return reference; } @@ -197,23 +189,3 @@ function requiredPermissions(action: PluginBridgeAction): PluginPermission[] { return []; } } - -function containsUnsafeBridgeContent(value: string): boolean { - const lowered = value.trim().toLowerCase(); - if (!lowered) { - return false; - } - return ( - lowered.includes("/users/") || - lowered.includes("/private/") || - lowered.includes("unix://") || - lowered.includes("tcp://") || - lowered.includes("bearer ") || - lowered.startsWith("sk-") || - lowered.includes("password=") || - lowered.includes("api_key=") || - lowered.includes("apikey=") || - lowered.includes("host path") || - lowered.includes("rawapikey") - ); -} diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index 35d3f8a..b9e1ffa 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -22,6 +22,8 @@ Manifests, schemas, SDK contracts, example plugins, and test fixtures must live Plugins must never receive raw AI provider keys, run credentials, raw host paths, or direct storage endpoints. Use platform-mediated bridge calls for jobs, logs, files, artifacts, and AI. +Plugin-owned request text, result payloads, player/user records, stdout/stderr, and declared file-tail bodies are opaque to Platform and Run. Do not add plugin SDK or manifest compatibility logic that scans, redacts, filters, normalizes, or rejects those values because they look like credentials, host paths, login lines, SQL text, RCON text, or game-specific user data. Keep safety checks on declared actions, permissions, scoped references, schemas, sizes, checksums, and transport framing. + ## Local Development Local plugin development should exercise real platform-run flows through dev registration instead of bypassing platform authorization. diff --git a/plugins/examples/dev-game-plugin/page-bundle/index.ts b/plugins/examples/dev-game-plugin/page-bundle/index.ts index 97f1aaf..bda4aa0 100644 --- a/plugins/examples/dev-game-plugin/page-bundle/index.ts +++ b/plugins/examples/dev-game-plugin/page-bundle/index.ts @@ -29,7 +29,7 @@ function renderConfigPage(e: ReactLike["createElement"], input: any) { function renderLogsPage(e: ReactLike["createElement"], input: any) { return renderPanel(e, "日志视图", "日志入口由插件声明并通过平台日志通道读取。", input, [ ["日志权限", (input.context?.permissions ?? []).includes("server.logs.read") ? "可读" : "未声明"], - ["Companion", input.availability?.available ? "可用" : input.availability?.reason ?? "等待运行端"], + ["桥接状态", input.availability?.available ? "可用" : input.availability?.reason ?? "等待运行端"], ["桥接", (input.context?.bridgeActions ?? []).includes("logs.query") ? "logs.query" : "未声明"] ]); } diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 73c335a..69c3065 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -171,76 +171,6 @@ function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefi return undefined; } -function unsafeGameClientBridgePayloadKey(value: string): boolean { - const tokens = identifierTokens(value); - const compact = tokens.join(""); - return ["sql", "rawsql", "sqltext", "sqlstatement", "dsn", "hostpath", "socket", "credential", "accesstoken"].includes(compact); -} - -function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined { - const tokens = identifierTokens(fieldName); - const compact = tokens.join(""); - const generalReason = unsafeFieldReason(fieldName); - if (generalReason) { - return generalReason; - } - if ((tokens.includes("sql") || tokens.includes("query")) && tokens.includes("template") && (tokens.includes("key") || tokens.includes("ref"))) { - return undefined; - } - if (["sql", "rawsql", "sqltext", "sqlquery", "sqlstatement", "rawquery", "statement"].includes(compact)) { - return "arbitrary SQL field is not allowed"; - } - if (["shell", "shellcommand", "shellscript", "script", "scriptbody", "terminalcommand", "commandline", "powershell"].includes(compact)) { - return "arbitrary shell or script field is not allowed"; - } - if (["hostpath", "rawpath", "absolutepath", "filesystempath"].includes(compact)) { - return "raw host path field is not allowed"; - } - if (["runcapability", "executorcapability", "runendpoint", "runsocket", "directrun"].includes(compact)) { - return "unsafe executor capability or direct Run field is not allowed"; - } - if (tokens.some((token) => ["socket", "password", "credential", "secret", "token", "dsn"].includes(token))) { - return "direct socket or raw credential field is not allowed"; - } - return undefined; -} - -function unsafeBridgeSchemaStringReasons(value: string): string[] { - const reasons = [...unsafeStringReasons(value)]; - const trimmed = value.trim(); - const fieldReason = unsafeBridgeSchemaFieldReason(trimmed); - if (fieldReason) { - reasons.push(fieldReason); - } - if (/\bselect\b[\s\S]{0,240}\bfrom\b/i.test(trimmed) || /\b(?:insert\s+into|update\s+[a-z0-9_.]+\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|attach\s+database|pragma\s+[a-z0-9_]+)/i.test(trimmed)) { - reasons.push("arbitrary SQL content is not allowed"); - } - if (/^\s*(?:sh|bash|zsh|powershell|pwsh)\s+-[a-z]*c\b/i.test(trimmed) || /^\s*cmd(?:\.exe)?\s+\/c\b/i.test(trimmed)) { - reasons.push("arbitrary shell content is not allowed"); - } - if (/^(?:run|executor|shell|script|terminal)\.(?:socket|endpoint|exec|execute|command)$/i.test(trimmed)) { - reasons.push("unsafe executor capability is not allowed"); - } - return [...new Set(reasons)]; -} - -function scanUnsafeBridgeSchema(value: unknown, location: string): string[] { - if (typeof value === "string") { - return unsafeBridgeSchemaStringReasons(value).map((reason) => `${location}: ${reason}`); - } - if (Array.isArray(value)) { - return value.flatMap((item, index) => scanUnsafeBridgeSchema(item, `${location}[${index}]`)); - } - if (typeof value === "object" && value !== null) { - return Object.entries(value).flatMap(([key, child]) => { - const keyReason = unsafeBridgeSchemaFieldReason(key); - const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : []; - return [...keyErrors, ...scanUnsafeBridgeSchema(child, `${location}.${key}`)]; - }); - } - return []; -} - function validateBoundedBridgeSchema(value: unknown, location: string): string[] { if (typeof value !== "object" || value === null || Array.isArray(value)) { return [`${location}: bridge schema root must be an object schema`]; @@ -790,7 +720,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str const message = error instanceof Error ? error.message : "invalid JSON Schema"; errors.push(`${declaration.location}: bridge schema is invalid: ${message}`); } - errors.push(...scanUnsafeBridgeSchema(schema, `${declaration.location}.schema`)); errors.push(...validateBoundedBridgeSchema(schema, `${declaration.location}.schema`)); } return errors; diff --git a/plugins/sdk/bridge-contract.md b/plugins/sdk/bridge-contract.md index 92d83e4..9615f43 100644 --- a/plugins/sdk/bridge-contract.md +++ b/plugins/sdk/bridge-contract.md @@ -34,7 +34,7 @@ SQLite reads use manifest-declared `gameClientBridge.queryTemplates`. Plugin pag Run distribution, dependency, and log backfill requests use `createRunDistributionRequest`, `createDependencyActionRequest`, and `createLogBackfillRequest`. These envelopes carry only operation names, logical profile keys, target platforms, artifact IDs, approved dependency plan digests, cursors, and idempotency keys. Raw run keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields. -Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, redacts, queues, and forwards it to Run. A management program is not host OS shell access. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields. +Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, queues, and forwards it to Run without inspecting or redacting plugin-owned request/result text. A management program is not host OS shell access. Transport envelope fields intentionally have no component session, component key, installation fence, Run endpoint, direct socket, or storage credential access; plugin-owned payload/result fields pass through unchanged inside those envelopes. Production plugin lifecycle requests use `createProductionPluginLifecycleRequest`. Envelopes contain only plugin/server scope, enumerated operation, optional target version, confirmation, and idempotency key. Platform rechecks the manifest `productionLifecycle` declaration, dependency policy, disruptive approval, endpoint capacity, compatibility, and prior idempotency inputs before dispatch. diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 8e58391..8930dae 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -822,11 +822,6 @@ export function parseArtifactReference(result: Record | undefine if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) { return undefined; } - for (const value of Object.values(reference)) { - if (typeof value === "string" && containsUnsafeReferenceContent(value)) { - return undefined; - } - } return reference; } @@ -883,24 +878,3 @@ export function bridgeError( ): PluginBridgeError { return { code, message, details }; } - -function containsUnsafeReferenceContent(value: string): boolean { - const lowered = value.trim().toLowerCase(); - return ( - lowered.includes("/users/") || - lowered.includes("/private/") || - lowered.includes("unix://") || - lowered.includes("tcp://") || - lowered.includes("bearer ") || - lowered.includes("password=") || - lowered.includes("api_key=") || - lowered.includes("apikey=") || - lowered.includes("storage://") || - lowered.includes("file://") || - lowered.includes("sessiontoken") || - lowered.includes("secret://") || - lowered.includes("hostpath") || - lowered.includes("processid") || - lowered.startsWith("sk-") - ); -} diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 470443a..ded57bd 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -638,18 +638,18 @@ describe("plugin manifest validation", () => { expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true); }); - it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => { + it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("keeps opaque query parameter schema field %s", (fieldName) => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName])); }); - expect(errors.some((error) => error.includes("queryTemplates[0].parameterSchemaRef") && error.includes("not allowed"))).toBe(true); + expect(errors.some((error) => error.includes("queryTemplates[0].parameterSchemaRef"))).toBe(false); }); - it("rejects SQL text embedded in a query result schema", () => { + it("keeps opaque SQL-looking text embedded in a query result schema", () => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ summary: { type: "string", minLength: 1, maxLength: 200, const: "SELECT id FROM players" } }, ["summary"])); }); - expect(errors.some((error) => error.includes("queryTemplates[0].resultSchemaRef") && error.includes("arbitrary SQL content"))).toBe(true); + expect(errors.some((error) => error.includes("queryTemplates[0].resultSchemaRef"))).toBe(false); }); it("rejects missing bridge schema files end to end", () => { @@ -673,16 +673,13 @@ describe("plugin manifest validation", () => { expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("not valid JSON"))).toBe(true); }); - it("rejects dangerous fields and values in payload, result, and snapshot schemas", () => { + it("keeps opaque fields and values in payload, result, and snapshot schemas", () => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"])); writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"])); writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, mode: { type: "string", const: "run.socket" }, runCapability: { type: "string" } }, ["hostPath", "mode", "runCapability"])); }); - expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("arbitrary SQL field"))).toBe(true); - expect(errors.some((error) => error.includes("resultSchemaRef") && error.includes("arbitrary shell"))).toBe(true); - expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("raw host path"))).toBe(true); - expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("unsafe executor capability"))).toBe(true); + expect(errors.some((error) => error.includes("payloadSchemaRef") || error.includes("resultSchemaRef") || error.includes("snapshots[0].schemaRef"))).toBe(false); }); it("requires bounded object schemas for every bridge reference", () => { @@ -938,9 +935,21 @@ describe("plugin SDK", () => { storageBehavior: "platform-memory-transfer-session" }); expect(reference).toMatchObject({ artifactId: "artifact-1", rangeSupported: true }); - expect(JSON.stringify(reference)).not.toContain("/Users/"); - expect(JSON.stringify(reference)).not.toContain("storage://"); - expect(JSON.stringify(reference)).not.toContain("Bearer "); + + expect( + parseArtifactReference({ + artifactId: "artifact-1", + filename: "/Users/operator/password=opaque.bin", + contentType: "application/octet-stream", + sizeBytes: "64", + checksum: "sha256:abc", + downloadUrl: "/api/v1/artifacts/artifact-1/content", + expiresAt: "2026-07-03T00:15:00Z", + rangeSupported: "true", + chunkSizeBytes: "1048576", + storageBehavior: "plugin-owned opaque storage:// label" + }) + ).toMatchObject({ filename: "/Users/operator/password=opaque.bin", storageBehavior: "plugin-owned opaque storage:// label" }); expect( parseArtifactReference({