Tighten opaque plugin content boundaries
This commit is contained in:
@@ -17,7 +17,6 @@ type componentLogServerContextKey struct{}
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
logEventHeartbeatInterval = 15 * time.Second
|
logEventHeartbeatInterval = 15 * time.Second
|
||||||
managedLogSessionIDPrefix = "log-session:"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// serverLogEvents streams platform-accepted live append events for the terminal drawer.
|
// 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)
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
|
instance, streams, subscription, err := h.openLogEventSubscription(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
@@ -46,12 +45,10 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
active := supervisedLogSession{}
|
active := supervisedLogSession{}
|
||||||
if liveEligible {
|
if isComponentLogRequest(r) {
|
||||||
if isComponentLogRequest(r) {
|
active = activeComponentLogSession(streams)
|
||||||
active = activeComponentLogSession(streams)
|
} else {
|
||||||
} else {
|
active = activeSupervisedLogSession(streams)
|
||||||
active = activeSupervisedLogSession(streams)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active)
|
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,7 +79,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if subscriptionEvent.ProcessState != domain.ServerInstanceStateRunning {
|
if subscriptionEvent.ProcessState != domain.ServerInstanceStateRunning {
|
||||||
liveEligible = false
|
|
||||||
if active.sessionID == "" {
|
if active.sessionID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -94,14 +90,11 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next := supervisedLogSession{}
|
next := activeSupervisedLogSession(streams)
|
||||||
if liveEligible {
|
|
||||||
next = activeSupervisedLogSession(streams)
|
|
||||||
}
|
|
||||||
if sameSupervisedLogSession(active, next) {
|
if sameSupervisedLogSession(active, next) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -113,7 +106,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if subscriptionEvent.Kind != service.LogEventSubscriptionEventLog || !liveEligible {
|
if subscriptionEvent.Kind != service.LogEventSubscriptionEventLog {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
event := subscriptionEvent.LogEvent
|
event := subscriptionEvent.LogEvent
|
||||||
@@ -122,20 +115,16 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
candidate = supervisedLogSession{}
|
candidate = supervisedLogSession{}
|
||||||
}
|
}
|
||||||
if candidate.sessionID != "" && newerLogSession(candidate, active) {
|
if candidate.sessionID != "" && newerLogSession(candidate, active) {
|
||||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
next := candidate
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next := supervisedLogSession{}
|
|
||||||
if liveEligible {
|
|
||||||
next = activeSupervisedLogSession(streams)
|
|
||||||
}
|
|
||||||
if !sameSupervisedLogSession(active, next) {
|
if !sameSupervisedLogSession(active, next) {
|
||||||
active = next
|
active = next
|
||||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active)
|
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if event.Entry.Seq > 0 && emittedThrough[event.Stream.ID] >= event.Entry.Seq {
|
||||||
|
emittedThrough[event.Stream.ID] = event.Entry.Seq - 1
|
||||||
|
}
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,10 +240,9 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
|
|||||||
return emittedThrough, nil
|
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 instance domain.ServerInstance
|
||||||
var streams []domain.LogStream
|
var streams []domain.LogStream
|
||||||
var liveEligible bool
|
|
||||||
var subscription service.LogEventSubscription
|
var subscription service.LogEventSubscription
|
||||||
var err error
|
var err error
|
||||||
if serverInstanceID, ok := r.Context().Value(componentLogServerContextKey{}).(string); ok && strings.TrimSpace(serverInstanceID) != "" {
|
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)
|
sessionID := bearerToken(r)
|
||||||
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
|
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerInstance{}, nil, false, subscription, err
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
}
|
}
|
||||||
subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID)
|
subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerInstance{}, nil, false, subscription, err
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
instance, err = h.core.GetServerInstance(r.PathValue("id"))
|
instance, err = h.core.GetServerInstance(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerInstance{}, nil, false, subscription, err
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
}
|
}
|
||||||
subscription, err = h.core.SubscribeLogEvents(instance.ID)
|
subscription, err = h.core.SubscribeLogEvents(instance.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerInstance{}, nil, false, subscription, err
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == nil {
|
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 {
|
if err != nil && subscription.Close != nil {
|
||||||
subscription.Close()
|
subscription.Close()
|
||||||
}
|
}
|
||||||
return instance, streams, liveEligible, subscription, err
|
return instance, streams, subscription, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func withComponentLogServer(r *http.Request, serverInstanceID string) *http.Request {
|
func withComponentLogServer(r *http.Request, serverInstanceID string) *http.Request {
|
||||||
@@ -300,46 +288,42 @@ func isComponentLogRequest(r *http.Request) bool {
|
|||||||
return ok
|
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)
|
instance, err := h.core.GetServerInstance(serverInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.RunEndpointID) == "" {
|
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||||
return nil, false, nil
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !includeDeclaredStreams && instance.State != domain.ServerInstanceStateRunning {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID)
|
endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if endpoint.Status != domain.RunEndpointStatusOnline {
|
if endpoint.Status != domain.RunEndpointStatusOnline {
|
||||||
return nil, false, nil
|
return nil, nil
|
||||||
}
|
|
||||||
logSessionID := strings.TrimPrefix(instance.LifecycleProcessID, managedLogSessionIDPrefix)
|
|
||||||
if logSessionID == instance.LifecycleProcessID || strings.TrimSpace(logSessionID) == "" {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
}
|
||||||
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
current := make([]domain.LogStream, 0, len(streams))
|
current := make([]domain.LogStream, 0, len(streams))
|
||||||
for _, stream := range streams {
|
for _, stream := range streams {
|
||||||
if includeDeclaredStreams {
|
if includeDeclaredStreams {
|
||||||
if stream.LogSessionID != "" && stream.LogSessionID != logSessionID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram {
|
if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
current = append(current, stream)
|
current = append(current, stream)
|
||||||
continue
|
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)
|
current = append(current, stream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return current, true, nil
|
return current, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSSEJSON(w http.ResponseWriter, eventName string, id string, value any) error {
|
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()
|
router := newTestRouter()
|
||||||
hello := createLogIngestAPIFixtures(t, router)
|
hello := createLogIngestAPIFixtures(t, router)
|
||||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
|
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.ObservedAt = time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||||
running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
||||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", running), http.StatusOK)
|
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)
|
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"))
|
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events"))
|
||||||
}
|
}
|
||||||
@@ -266,14 +268,15 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne
|
|||||||
next.LogSessionID = "session-next"
|
next.LogSessionID = "session-next"
|
||||||
next.SessionStartedAt = nextStartedAt
|
next.SessionStartedAt = nextStartedAt
|
||||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", next), http.StatusOK)
|
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.ManagedProcessID = "log-session:session-next"
|
||||||
statusReport.ObservationSeq = 1
|
statusReport.ObservationSeq = 1
|
||||||
statusReport.ObservedAt = nextStartedAt
|
statusReport.ObservedAt = nextStartedAt
|
||||||
statusReport.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
statusReport.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
||||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK)
|
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 := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21)
|
||||||
nextLive.LogSessionID = "session-next"
|
nextLive.LogSessionID = "session-next"
|
||||||
nextLive.SessionStartedAt = nextStartedAt
|
nextLive.SessionStartedAt = nextStartedAt
|
||||||
@@ -327,11 +330,12 @@ func TestLogEventsSSEOrdersSessionSwitchAndAdditionalStreamWithoutDuplicates(t *
|
|||||||
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
|
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
|
||||||
t.Fatalf("ingest next session stdout: %v", err)
|
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 {
|
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)
|
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 := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21)
|
||||||
stderr.LogSessionID = "session-next"
|
stderr.LogSessionID = "session-next"
|
||||||
|
|||||||
@@ -1398,7 +1398,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
|
|||||||
t.Fatalf("expected permission denied safe envelope, got %+v", denied)
|
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",
|
RequestID: "bridge-unsafe-1",
|
||||||
PluginID: "game.example",
|
PluginID: "game.example",
|
||||||
RouteKey: "logs",
|
RouteKey: "logs",
|
||||||
@@ -1406,9 +1406,11 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
|
|||||||
Action: string(domain.PluginBridgeActionFilesRequest),
|
Action: string(domain.PluginBridgeActionFilesRequest),
|
||||||
Payload: map[string]string{"key": "/Users/tasia/.ssh/id_rsa", "idempotencyKey": "idem-unsafe"},
|
Payload: map[string]string{"key": "/Users/tasia/.ssh/id_rsa", "idempotencyKey": "idem-unsafe"},
|
||||||
}, ownerSession)
|
}, 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"} {
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} {
|
||||||
if strings.Contains(body, forbidden) {
|
if strings.Contains(body, forbidden) {
|
||||||
t.Fatalf("bridge response exposed forbidden fragment %q: %s", forbidden, body)
|
t.Fatalf("bridge response exposed forbidden fragment %q: %s", forbidden, body)
|
||||||
|
|||||||
@@ -106,22 +106,6 @@ type GameClientBridgeFeatureDeclaration struct {
|
|||||||
RequiredEventProducers []string
|
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 {
|
type GameClientBridgeManifest struct {
|
||||||
Commands []GameClientBridgeCommandDeclaration
|
Commands []GameClientBridgeCommandDeclaration
|
||||||
Snapshots []GameClientBridgeSnapshotDeclaration
|
Snapshots []GameClientBridgeSnapshotDeclaration
|
||||||
@@ -131,7 +115,6 @@ type GameClientBridgeManifest struct {
|
|||||||
Retention GameClientBridgeRetention
|
Retention GameClientBridgeRetention
|
||||||
Pages []GameClientBridgePageContract
|
Pages []GameClientBridgePageContract
|
||||||
Features []GameClientBridgeFeatureDeclaration
|
Features []GameClientBridgeFeatureDeclaration
|
||||||
Companion GameClientBridgeCompanionDeclaration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeResultStatus string
|
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)
|
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||||
command := domain.GameClientBridgeCommand{
|
command := domain.GameClientBridgeCommand{
|
||||||
ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, Priority: 3, State: domain.GameClientBridgeCommandSucceeded,
|
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"
|
claim.Items[0].Payload["scope"] = "changed"
|
||||||
result.Result.Payload["healthy"] = false
|
result.Result.Payload["healthy"] = false
|
||||||
if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true {
|
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} {
|
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)
|
encoded, err := json.Marshal(value)
|
||||||
|
|||||||
@@ -353,22 +353,6 @@ type GameClientBridgeFeatureDeclarationBody struct {
|
|||||||
RequiredEventProducers []string `json:"requiredEventProducers,omitempty"`
|
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 {
|
type GameClientBridgeManifestBody struct {
|
||||||
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
||||||
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
||||||
@@ -379,7 +363,6 @@ type GameClientBridgeManifestBody struct {
|
|||||||
MaxCommands int `json:"maxCommands"`
|
MaxCommands int `json:"maxCommands"`
|
||||||
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
||||||
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
|
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
|
||||||
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
|
||||||
}
|
}
|
||||||
type GamePluginManifestBody struct {
|
type GamePluginManifestBody struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -1268,11 +1251,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
|||||||
for index, feature := range body.Features {
|
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)}
|
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{}
|
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}
|
||||||
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}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration {
|
func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration {
|
||||||
@@ -1727,11 +1706,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
|||||||
for index, feature := range value.Features {
|
for index, feature := range value.Features {
|
||||||
features[index] = GameClientBridgeFeatureDeclarationBody{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: feature.RequiredHandlers, RequiredEventProducers: feature.RequiredEventProducers}
|
features[index] = GameClientBridgeFeatureDeclarationBody{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: feature.RequiredHandlers, RequiredEventProducers: feature.RequiredEventProducers}
|
||||||
}
|
}
|
||||||
var companion *GameClientBridgeCompanionDeclarationBody
|
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features}
|
||||||
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}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody {
|
func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type ArtifactBodyStore interface {
|
|||||||
SaveTransfer(domain.ArtifactTransferSession) error
|
SaveTransfer(domain.ArtifactTransferSession) error
|
||||||
LoadTransfers() ([]domain.ArtifactTransferSession, error)
|
LoadTransfers() ([]domain.ArtifactTransferSession, error)
|
||||||
PutPayload(string, []byte) error
|
PutPayload(string, []byte) error
|
||||||
|
PutPayloadFromFile(string, string) error
|
||||||
GetPayload(string) ([]byte, error)
|
GetPayload(string) ([]byte, error)
|
||||||
ReadPayloadRange(string, int64, int) ([]byte, error)
|
ReadPayloadRange(string, int64, int) ([]byte, error)
|
||||||
OpenPayloadRange(string, int64, int64) (io.ReadCloser, error)
|
OpenPayloadRange(string, int64, int64) (io.ReadCloser, error)
|
||||||
@@ -68,6 +69,14 @@ func (store *MemoryArtifactBodyStore) PutPayload(artifactID string, payload []by
|
|||||||
return nil
|
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) {
|
func (store *MemoryArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
|
||||||
store.mu.Lock()
|
store.mu.Lock()
|
||||||
defer store.mu.Unlock()
|
defer store.mu.Unlock()
|
||||||
@@ -218,6 +227,12 @@ func (store *FileArtifactBodyStore) PutPayload(artifactID string, payload []byte
|
|||||||
return writeAtomicFile(store.payloadPath(artifactID), payload, 0o600)
|
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) {
|
func (store *FileArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
|
||||||
store.mu.Lock()
|
store.mu.Lock()
|
||||||
defer store.mu.Unlock()
|
defer store.mu.Unlock()
|
||||||
@@ -379,6 +394,67 @@ func stableStorageKey(value string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
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 {
|
func writeAtomicFile(path string, payload []byte, mode os.FileMode) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("create durable body directory: %w", err)
|
return fmt.Errorf("create durable body directory: %w", err)
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
builder := svc.configuredDistributionBuilder()
|
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 payload []byte
|
||||||
var buildErr error
|
var buildErr error
|
||||||
if progressBuilder, ok := builder.(distributionBuilderWithProgress); ok {
|
if progressBuilder, ok := builder.(distributionBuilderWithProgress); ok {
|
||||||
@@ -422,6 +438,49 @@ func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID
|
|||||||
return nil
|
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 {
|
func (svc *CoreService) markDistributionBuildRunning(job *domain.Job) error {
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
svc.jobMu.Lock()
|
svc.jobMu.Lock()
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -11,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
"browser.local/platform/repo"
|
"browser.local/platform/repo"
|
||||||
|
"browser.local/platform/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
type captureDistributionBuilder struct {
|
type captureDistributionBuilder struct {
|
||||||
@@ -25,6 +29,11 @@ type progressDistributionBuilder struct {
|
|||||||
payload []byte
|
payload []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fileDistributionBuilder struct {
|
||||||
|
inputs chan domain.DistributionBuildInput
|
||||||
|
outputPath string
|
||||||
|
}
|
||||||
|
|
||||||
func (builder captureDistributionBuilder) Readiness() (bool, string) {
|
func (builder captureDistributionBuilder) Readiness() (bool, string) {
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
@@ -58,6 +67,28 @@ func (builder progressDistributionBuilder) BuildWithProgress(_ domain.Distributi
|
|||||||
return domain.CopyBytes(builder.payload), nil
|
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) {
|
func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||||
svc, session, instance := newDistributionTestFixture(t)
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
@@ -393,6 +424,47 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T
|
|||||||
completeDistributionBuild(t, svc, distribution, nil)
|
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) {
|
func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) {
|
||||||
svc, session, instance := newDistributionTestFixture(t)
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DistributionBuilder executes a distribution build inside a platform-owned
|
// DistributionBuilder executes a distribution build inside a platform-owned
|
||||||
@@ -40,10 +41,28 @@ type DistributionBuildProgress struct {
|
|||||||
Message string
|
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 {
|
type distributionBuilderWithProgress interface {
|
||||||
BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error)
|
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.
|
// DockerDistributionBuilderConfig configures a container-per-build builder.
|
||||||
type DockerDistributionBuilderConfig struct {
|
type DockerDistributionBuilderConfig struct {
|
||||||
DockerBinary string
|
DockerBinary string
|
||||||
@@ -116,8 +135,7 @@ func runCommandStreamCombined(ctx context.Context, name string, args []string, o
|
|||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
output.WriteString(line)
|
appendBoundedBuilderOutput(&output, []byte(line+"\n"))
|
||||||
output.WriteByte('\n')
|
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
if onLine != nil {
|
if onLine != nil {
|
||||||
onLine(line)
|
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) {
|
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 {
|
if ready, reason := builder.Readiness(); !ready {
|
||||||
return nil, validationError(reason)
|
return DistributionBuildOutput{}, validationError(reason)
|
||||||
}
|
}
|
||||||
reportBuilderProgress(progress, 8, "env_check: platform builder readiness verified")
|
reportBuilderProgress(progress, 8, "env_check: platform builder readiness verified")
|
||||||
sourceDir, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir))
|
sourceDir, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir))
|
||||||
if err != nil {
|
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))
|
workspaceDir, err := filepath.Abs(strings.TrimSpace(builder.config.WorkspaceDir))
|
||||||
if err != nil {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), builder.config.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -220,14 +254,20 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
|||||||
}
|
}
|
||||||
cacheDir, err := builder.cacheDir(workspaceDir)
|
cacheDir, err := builder.cacheDir(workspaceDir)
|
||||||
if err != nil {
|
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
|
// Workspaces stay isolated per plugin and per job as required by
|
||||||
// run-build-download-flow.
|
// run-build-download-flow.
|
||||||
jobDir := filepath.Join(workspaceDir, sanitizeIDPart(input.PluginID), sanitizeIDPart(input.JobID))
|
jobDir := filepath.Join(workspaceDir, sanitizeIDPart(input.PluginID), sanitizeIDPart(input.JobID))
|
||||||
if err := os.RemoveAll(jobDir); err != nil {
|
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")
|
outputDir := filepath.Join(jobDir, "output")
|
||||||
inputDir := filepath.Join(jobDir, "input")
|
inputDir := filepath.Join(jobDir, "input")
|
||||||
buildDir := filepath.Join(jobDir, "build")
|
buildDir := filepath.Join(jobDir, "build")
|
||||||
@@ -236,15 +276,14 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
|||||||
goModCacheDir := filepath.Join(cacheDir, "go-mod")
|
goModCacheDir := filepath.Join(cacheDir, "go-mod")
|
||||||
for _, directory := range []string{outputDir, inputDir, buildDir, sourceMountDir, goBuildCacheDir, goModCacheDir} {
|
for _, directory := range []string{outputDir, inputDir, buildDir, sourceMountDir, goBuildCacheDir, goModCacheDir} {
|
||||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
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")
|
reportBuilderProgress(progress, 14, "env_check: platform builder workspace prepared")
|
||||||
if input.ComponentKind == domain.DistributionComponentRun {
|
if input.ComponentKind == domain.DistributionComponentRun {
|
||||||
preparedSourceDir, err := builder.prepareRunSource(ctx, sourceDir, sourceMountDir, input, progress)
|
preparedSourceDir, err := builder.prepareRunSource(ctx, sourceDir, sourceMountDir, input, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return DistributionBuildOutput{}, err
|
||||||
}
|
}
|
||||||
sourceMountDir = preparedSourceDir
|
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
|
// through a job-channel response to a machine-side endpoint or a container
|
||||||
// command-line argument.
|
// command-line argument.
|
||||||
if strings.TrimSpace(input.AuthKey) == "" {
|
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 {
|
if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil {
|
||||||
return nil, err
|
return DistributionBuildOutput{}, err
|
||||||
}
|
}
|
||||||
seedPayload := []byte("[]")
|
seedPayload := []byte("[]")
|
||||||
if strings.TrimSpace(input.WorkspaceSeed) != "" {
|
if strings.TrimSpace(input.WorkspaceSeed) != "" {
|
||||||
decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed)
|
decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed)
|
||||||
if err != nil {
|
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
|
seedPayload = decoded
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
|
if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
|
||||||
return nil, err
|
return DistributionBuildOutput{}, err
|
||||||
}
|
}
|
||||||
lifecyclePlanPayload := []byte("{}")
|
lifecyclePlanPayload := []byte("{}")
|
||||||
if input.AutonomousLifecycle != nil {
|
if input.AutonomousLifecycle != nil {
|
||||||
encoded, err := json.Marshal(input.AutonomousLifecycle)
|
encoded, err := json.Marshal(input.AutonomousLifecycle)
|
||||||
if err != nil {
|
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
|
lifecyclePlanPayload = encoded
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(inputDir, "autonomous-lifecycle-plan.json"), lifecyclePlanPayload, 0o600); err != nil {
|
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 {
|
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)
|
outputName := strings.TrimSpace(input.OutputFilename)
|
||||||
if outputName == "" || filepath.Base(outputName) != outputName {
|
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)
|
args := builder.containerArgs(input, sourceMountDir, inputDir, buildDir, outputDir, goBuildCacheDir, goModCacheDir, outputName)
|
||||||
reportBuilderProgress(progress, 18, "git_sync: platform builder container starting")
|
reportBuilderProgress(progress, 18, "git_sync: platform builder container starting")
|
||||||
output, err := builder.runBuildCommand(ctx, args, progress)
|
output, err := builder.runBuildCommand(ctx, args, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
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")
|
reportBuilderProgress(progress, 92, "package_finalize: validating platform builder output")
|
||||||
binary, err := os.ReadFile(filepath.Join(outputDir, outputName))
|
outputPath := filepath.Join(outputDir, outputName)
|
||||||
|
sizeBytes, checksum, err := fileSizeAndChecksum(outputPath)
|
||||||
if err != nil {
|
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 {
|
if sizeBytes == 0 {
|
||||||
return nil, validationError("platform builder produced an empty distribution executable")
|
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
|
// 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 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) {
|
func parseBuilderProgressLine(line string) (DistributionBuildProgress, bool) {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if !strings.HasPrefix(line, builderProgressMarker) {
|
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) {
|
func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) {
|
||||||
sourceDir := createBuilderSource(t)
|
sourceDir := createBuilderSource(t)
|
||||||
workspaceDir := t.TempDir()
|
workspaceDir := t.TempDir()
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatu
|
|||||||
}
|
}
|
||||||
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
|
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
|
||||||
if 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)
|
result = append(result, availability)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) {
|
|||||||
"declarationKey": "rcon",
|
"declarationKey": "rcon",
|
||||||
"targetKey": "rcon",
|
"targetKey": "rcon",
|
||||||
"idempotencyKey": "bridge-rcon-1",
|
"idempotencyKey": "bridge-rcon-1",
|
||||||
"input.command": "#ListPlayers",
|
"input.command": "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,7 +201,7 @@ func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("consume bridge RCON input: %v", err)
|
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)
|
t.Fatalf("expected bridge RCON command to remain verbatim, got %q", input.Command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -23,12 +22,6 @@ const (
|
|||||||
maxGameClientBridgeSessionLength = 4096
|
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 {
|
func ValidateGameClientBridgeQueueRequest(request domain.GameClientBridgeQueueRequest) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", request.ServerInstanceID, true)
|
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) {
|
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maximum || containsControlCharacter(value) {
|
||||||
violations = append(violations, field+" is invalid")
|
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
|
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")
|
violations = append(violations, field+" key "+fmt.Sprintf("%q", key)+" is invalid")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if unsafeGameClientBridgePayloadKey(key) {
|
|
||||||
violations = append(violations, field+" contains forbidden key "+key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
violations = append(violations, validateGameClientBridgePayloadValue(field+"."+key, item, depth+1, budget)...)
|
violations = append(violations, validateGameClientBridgePayloadValue(field+"."+key, item, depth+1, budget)...)
|
||||||
}
|
}
|
||||||
return violations
|
return violations
|
||||||
@@ -300,13 +282,6 @@ func validateGameClientBridgePayloadString(field, value string) []string {
|
|||||||
if containsControlCharacter(value) {
|
if containsControlCharacter(value) {
|
||||||
violations = append(violations, field+" contains control characters")
|
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
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,113 +296,3 @@ func validGameClientBridgePayloadKey(key string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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: "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: "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 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 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 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"},
|
{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"}
|
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 {
|
for _, key := range keys {
|
||||||
t.Run(key, func(t *testing.T) {
|
t.Run(key, func(t *testing.T) {
|
||||||
request := validBridgeQueueRequest()
|
request := validBridgeQueueRequest()
|
||||||
request.Payload = map[string]any{key: "value"}
|
request.Payload = map[string]any{key: "value"}
|
||||||
err := ValidateGameClientBridgeQueueRequest(request)
|
if err := ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||||
if err == nil || !strings.Contains(err.Error(), "forbidden key") {
|
t.Fatalf("plugin-owned key %q should pass structural validation: %v", key, err)
|
||||||
t.Fatalf("expected forbidden key rejection, got %v", 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"}
|
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 {
|
for index, value := range values {
|
||||||
request := validBridgeQueueRequest()
|
request := validBridgeQueueRequest()
|
||||||
request.IdempotencyKey = fmt.Sprintf("case-%d", index)
|
request.IdempotencyKey = fmt.Sprintf("case-%d", index)
|
||||||
request.Payload = map[string]any{"value": value}
|
request.Payload = map[string]any{"value": value}
|
||||||
if err := ValidateGameClientBridgeQueueRequest(request); err == nil || !strings.Contains(err.Error(), "payload") {
|
if err := ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||||
t.Fatalf("expected unsafe value %q rejection, got %v", value, err)
|
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) {
|
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")}
|
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 {
|
for name, value := range tests {
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
|
|||||||
}
|
}
|
||||||
var violations []string
|
var violations []string
|
||||||
for key, value := range inputs {
|
for key, value := range inputs {
|
||||||
if !runtimeIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) {
|
if !runtimeIdentifierPattern.MatchString(key) {
|
||||||
violations = append(violations, field+" key is invalid or unsafe")
|
violations = append(violations, field+" key is invalid")
|
||||||
}
|
}
|
||||||
limit := 2048
|
limit := 2048
|
||||||
if remoteAdapterSQLInputKey(key) {
|
if remoteAdapterSQLInputKey(key) {
|
||||||
@@ -119,20 +119,10 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
|
|||||||
if len([]rune(value)) > limit {
|
if len([]rune(value)) > limit {
|
||||||
violations = append(violations, field+"."+key+" is too long")
|
violations = append(violations, field+"."+key+" is too long")
|
||||||
}
|
}
|
||||||
for _, reason := range unsafePluginStringReasons(value) {
|
|
||||||
violations = append(violations, field+"."+key+": "+reason)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsafeRemoteAdapterInputKey(key string) bool {
|
|
||||||
if remoteAdapterSQLInputKey(key) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return unsafeGameClientBridgePayloadKey(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func remoteAdapterSQLInputKey(key string) bool {
|
func remoteAdapterSQLInputKey(key string) bool {
|
||||||
normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key))
|
normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key))
|
||||||
switch normalized {
|
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 {
|
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)
|
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 }
|
func floatPtr(value float64) *float64 { return &value }
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ var (
|
|||||||
gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`)
|
gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`)
|
||||||
gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`)
|
gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`)
|
||||||
gameClientBridgeCaptureNamePattern = 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}$`)
|
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 {
|
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 {
|
||||||
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 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var violations []string
|
var violations []string
|
||||||
@@ -478,9 +480,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 {
|
if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 {
|
||||||
violations = append(violations, field+".maxCommands is invalid")
|
violations = append(violations, field+".maxCommands is invalid")
|
||||||
}
|
}
|
||||||
if companionPresent {
|
|
||||||
violations = append(violations, field+".companion is no longer supported")
|
|
||||||
}
|
|
||||||
transports := map[string]domain.RuntimeTransportProfile{}
|
transports := map[string]domain.RuntimeTransportProfile{}
|
||||||
for _, transport := range runtimeProfiles.TransportProfiles {
|
for _, transport := range runtimeProfiles.TransportProfiles {
|
||||||
transports[transport.Key] = transport
|
transports[transport.Key] = transport
|
||||||
@@ -880,27 +879,8 @@ func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.Gam
|
|||||||
return violations
|
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 {
|
func unsafeGameClientBridgeCommandType(value string) bool {
|
||||||
tokens := gameClientBridgePayloadKeyTokens(value)
|
tokens := gameClientBridgeCommandTypeTokens(value)
|
||||||
tokenSet := make(map[string]struct{}, len(tokens))
|
tokenSet := make(map[string]struct{}, len(tokens))
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
tokenSet[token] = struct{}{}
|
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")
|
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 {
|
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
violations = appendRequired(violations, "pluginId", request.PluginID)
|
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||||
@@ -972,15 +958,6 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques
|
|||||||
if len([]rune(value)) > valueLimit {
|
if len([]rune(value)) > valueLimit {
|
||||||
violations = append(violations, "payload value is too long")
|
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 {
|
if payloadSize > maxPluginBridgePayloadSize {
|
||||||
violations = append(violations, "payload is too large")
|
violations = append(violations, "payload is too large")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
- API handlers must use named DTOs from `platform/dto`.
|
- API handlers must use named DTOs from `platform/dto`.
|
||||||
- Platform services must not accept raw plugin-provided host paths.
|
- 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.
|
- 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.
|
- 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.
|
- `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.
|
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
||||||
# Runtime and bridge validation
|
# 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 });
|
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({
|
expect(() => parseSafeDependencyCatalog({
|
||||||
serverInstanceId: "server-1",
|
serverInstanceId: "server-1",
|
||||||
pluginId: "game.runtime",
|
pluginId: "game.runtime",
|
||||||
@@ -51,12 +51,27 @@ describe("safe dependency and Run update projections", () => {
|
|||||||
profileKey: "local",
|
profileKey: "local",
|
||||||
targetOs: "linux",
|
targetOs: "linux",
|
||||||
targetArch: "amd64",
|
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: [],
|
plans: [],
|
||||||
updatedAt: "2026-07-18T12:00:00Z"
|
updatedAt: "2026-07-18T12:00:00Z"
|
||||||
})).toThrow(/forbidden field/);
|
})).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", () => {
|
it("preserves raw host paths or credentials in evidence text", () => {
|
||||||
expect(parseSafeDependencyCatalog({
|
expect(parseSafeDependencyCatalog({
|
||||||
serverInstanceId: "server-1",
|
serverInstanceId: "server-1",
|
||||||
|
|||||||
@@ -24,12 +24,7 @@ const forbiddenProjectionKeys = new Set([
|
|||||||
"stagingpath",
|
"stagingpath",
|
||||||
"backuppath",
|
"backuppath",
|
||||||
"socket",
|
"socket",
|
||||||
"credential",
|
"credential"
|
||||||
"pid",
|
|
||||||
"content",
|
|
||||||
"payload",
|
|
||||||
"bindings",
|
|
||||||
"downloadref"
|
|
||||||
]);
|
]);
|
||||||
export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse {
|
export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse {
|
||||||
const record = requiredRecordValue(value, "dependency catalog");
|
const record = requiredRecordValue(value, "dependency catalog");
|
||||||
@@ -131,14 +126,9 @@ function parseRunUpdate(record: Record<string, unknown>): RunUpdateJobResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rejectForbiddenProjection(value: unknown): void {
|
function rejectForbiddenProjection(value: unknown): void {
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(rejectForbiddenProjection);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isRecord(value)) 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}`);
|
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({
|
const context = createPluginBridgeHostContext({
|
||||||
plugin,
|
plugin,
|
||||||
routeKey: "logs",
|
routeKey: "logs",
|
||||||
@@ -102,9 +102,7 @@ describe("plugin bridge host utilities", () => {
|
|||||||
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
|
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
|
||||||
});
|
});
|
||||||
expect(validateBridgeExecutionRequest(context, { requestId: "req-denied", action: "server.instances.read" })).toMatchObject({ code: "unsupported_action" });
|
expect(validateBridgeExecutionRequest(context, { requestId: "req-denied", action: "server.instances.read" })).toMatchObject({ code: "unsupported_action" });
|
||||||
expect(
|
expect(validateBridgeExecutionRequest(context, { requestId: "req-invalid", action: "files.request", payload: { " key": "value" } })).toMatchObject({ code: "validation" });
|
||||||
validateBridgeExecutionRequest(context, { requestId: "req-unsafe", action: "files.request", payload: { key: "/Users/tasia/.ssh/id_rsa" } })
|
|
||||||
).toMatchObject({ code: "unsafe_payload" });
|
|
||||||
|
|
||||||
const client = { executePluginBridge: vi.fn() };
|
const client = { executePluginBridge: vi.fn() };
|
||||||
const controller = new AbortController();
|
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();
|
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 () => {
|
it("dispatches mediated AI requests without provider configuration", async () => {
|
||||||
const context = createPluginBridgeHostContext({
|
const context = createPluginBridgeHostContext({
|
||||||
plugin,
|
plugin,
|
||||||
@@ -179,5 +182,19 @@ describe("plugin bridge host utilities", () => {
|
|||||||
chunkSizeBytes: "1048576"
|
chunkSizeBytes: "1048576"
|
||||||
})
|
})
|
||||||
).toBeNull();
|
).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) {
|
if (encodedSize > 16 * 1024) {
|
||||||
return { code: "payload_too_large", message: "bridge payload is too large" };
|
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()) {
|
if (!key.trim() || key !== key.trim()) {
|
||||||
return { code: "validation", message: "bridge payload key is invalid" };
|
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;
|
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`)) {
|
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
for (const value of Object.values(reference)) {
|
|
||||||
if (typeof value === "string" && containsUnsafeBridgeContent(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return reference;
|
return reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,23 +189,3 @@ function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
|
|||||||
return [];
|
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.
|
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 Development
|
||||||
|
|
||||||
Local plugin development should exercise real platform-run flows through dev registration instead of bypassing platform authorization.
|
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) {
|
function renderLogsPage(e: ReactLike["createElement"], input: any) {
|
||||||
return renderPanel(e, "日志视图", "日志入口由插件声明并通过平台日志通道读取。", input, [
|
return renderPanel(e, "日志视图", "日志入口由插件声明并通过平台日志通道读取。", input, [
|
||||||
["日志权限", (input.context?.permissions ?? []).includes("server.logs.read") ? "可读" : "未声明"],
|
["日志权限", (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" : "未声明"]
|
["桥接", (input.context?.bridgeActions ?? []).includes("logs.query") ? "logs.query" : "未声明"]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,76 +171,6 @@ function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefi
|
|||||||
return undefined;
|
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[] {
|
function validateBoundedBridgeSchema(value: unknown, location: string): string[] {
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
return [`${location}: bridge schema root must be an object schema`];
|
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";
|
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
||||||
errors.push(`${declaration.location}: bridge schema is invalid: ${message}`);
|
errors.push(`${declaration.location}: bridge schema is invalid: ${message}`);
|
||||||
}
|
}
|
||||||
errors.push(...scanUnsafeBridgeSchema(schema, `${declaration.location}.schema`));
|
|
||||||
errors.push(...validateBoundedBridgeSchema(schema, `${declaration.location}.schema`));
|
errors.push(...validateBoundedBridgeSchema(schema, `${declaration.location}.schema`));
|
||||||
}
|
}
|
||||||
return errors;
|
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.
|
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.
|
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`)) {
|
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
for (const value of Object.values(reference)) {
|
|
||||||
if (typeof value === "string" && containsUnsafeReferenceContent(value)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return reference;
|
return reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,24 +878,3 @@ export function bridgeError(
|
|||||||
): PluginBridgeError {
|
): PluginBridgeError {
|
||||||
return { code, message, details };
|
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);
|
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) => {
|
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
|
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) => {
|
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"]));
|
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", () => {
|
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);
|
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) => {
|
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||||
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"]));
|
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/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"]));
|
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("payloadSchemaRef") || error.includes("resultSchemaRef") || error.includes("snapshots[0].schemaRef"))).toBe(false);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires bounded object schemas for every bridge reference", () => {
|
it("requires bounded object schemas for every bridge reference", () => {
|
||||||
@@ -938,9 +935,21 @@ describe("plugin SDK", () => {
|
|||||||
storageBehavior: "platform-memory-transfer-session"
|
storageBehavior: "platform-memory-transfer-session"
|
||||||
});
|
});
|
||||||
expect(reference).toMatchObject({ artifactId: "artifact-1", rangeSupported: true });
|
expect(reference).toMatchObject({ artifactId: "artifact-1", rangeSupported: true });
|
||||||
expect(JSON.stringify(reference)).not.toContain("/Users/");
|
|
||||||
expect(JSON.stringify(reference)).not.toContain("storage://");
|
expect(
|
||||||
expect(JSON.stringify(reference)).not.toContain("Bearer ");
|
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(
|
expect(
|
||||||
parseArtifactReference({
|
parseArtifactReference({
|
||||||
|
|||||||
Reference in New Issue
Block a user