Tighten opaque plugin content boundaries

This commit is contained in:
npc0-hue
2026-09-03 18:24:39 +08:00
parent 80cddbf19d
commit 14cbc63e61
31 changed files with 452 additions and 558 deletions
+31 -47
View File
@@ -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 {
+10 -6
View File
@@ -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"
+5 -3
View File
@@ -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)
-17
View File
@@ -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
@@ -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)
}
}
}
+2 -2
View File
@@ -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)
+2 -27
View File
@@ -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 {
+76
View File
@@ -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)
@@ -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()
@@ -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)
+81 -26
View File
@@ -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) {
@@ -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()
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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)
}
}
-135
View File
@@ -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
}
@@ -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)
}
}
+15 -9
View File
@@ -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 {
+2 -12
View File
@@ -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 {
+3
View File
@@ -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 }
+11 -34
View File
@@ -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")
+2 -2
View File
@@ -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.