Tighten opaque plugin content boundaries
This commit is contained in:
@@ -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,13 +45,11 @@ 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)
|
||||
}
|
||||
}
|
||||
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active)
|
||||
if err != nil {
|
||||
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>): 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> | 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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" : "未声明"]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -822,11 +822,6 @@ export function parseArtifactReference(result: Record<string, string> | 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-")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user