diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index 7734a27..3658af1 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -13,6 +13,12 @@ - The executor advertises `remote.run.db.sqlite.probe`, accepts only package-scoped logical `databases/...` SQLite targets, applies query-only fixed introspection plus binding/job/fence and output bounds, and returns SHA-256-fingerprinted redacted envelopes without SCUM-specific branches or raw database content. - The active binding `server-run-server-scum-1785923898033` has not yet been rebuilt and deployed from that commit, and its current capability report does not include `remote.run.db.sqlite.probe`. Keep every probe/read/write gate disabled and do not submit a current-service probe until deployment compatibility is verified. +### Server-management diagnostic evidence (2026-08-12) + +- Personal server-management MCP inventory found `青火网络` (`OqXCYpgMSCZyslYA`) as the matching candidate server, and `test_connection` succeeded. +- Bounded SSH diagnostics checked only process/service metadata, emitted no command lines, paths, SQL, database content, credentials, or SCUM rows, and found no independent process for active endpoint `server-run-server-scum-1785923898033`; a generic `run` process/service name was also absent. +- Because the active Windows Run deployment still cannot be proven to advertise `remote.run.db.sqlite.probe`, no Platform durable schema probe was submitted and current-service evidence tasks remain blocked. + - [x] 2.1 Add a release gate that keeps every database-backed SCUM read and write capability disabled until capability-specific current-service evidence matches a versioned plugin adapter; do not add production SQL or mutation assets before this group is complete. - [x] 2.2 Define the minimal generic schema-probe request/result contract, safe error model, binding identity, bounds, and redacted evidence DTO needed by Platform and the plugin without embedding SCUM table names or host paths in Run-facing generic code. - [ ] 2.3 If the active binding lacks the minimal bounded query-only probe executor, create/hand off a separately authorized task rooted in the independent Run repository, wait for its tests/commit/deployment evidence, and record that evidence here; do not edit or vendor Run source from this change. @@ -41,6 +47,12 @@ ## 4. Generic External Run Execution and Result Contracts +### Platform probe wiring evidence (2026-08-12) + +- Platform now has an internal-only `RequestSCUMSchemaProbeForSession` path that builds a durable `remote.run.db.sqlite.probe` job from the SCUM plugin's manifest declaration, active runtime binding, logical target key, adapter version, and bounded probe limits; public remote-adapter and plugin-page requests for the probe capability are denied. +- Run terminal results may carry `executionResult.sqliteSchemaProbe`; Platform DTO/domain/validator/job-channel code validates the typed redacted envelope, job/request identity, and binding fence before persisting it on the durable job. +- Focused evidence: `go test ./dto ./service -run 'Test(RunJobResultRequestParsesSQLiteSchemaProbeEnvelope|SCUMSchemaProbeDispatchIsPlatformScheduledAndFenced|RemoteAdapterRequestPropagatesTypedInputsToRunJob)'` and `(cd platform && go test ./...)` passed locally. These tests do not prove the active Windows Run deployment or current SCUM schema, so tasks 2.3-2.9 and 4.2-4.9 remain unchecked. + - [x] 4.1 Add Platform protocol contracts under `platform/protocol`, API DTOs under `platform/dto`, validation under `platform/validator`, and plugin contracts/assets under `plugins/sdk` and `plugins/schemas`, plus contract documentation/mocks for probes, read-only template execution, typed RCON, guarded SQLite/XML mutation, parsed log events, and terminal result envelopes. - [ ] 4.2 Freeze the generic executor/result contract and hand off a separately authorized Run-repository task for packaged SQLite-template execution with query-only connections, bound parameters, one-statement validation, short busy/operation timeouts, cancellation, and row/result-byte limits. - [ ] 4.3 Require the independent Run task to return typed envelopes containing server/plugin binding, adapter/schema version, template key, asset digest, job identity, observed time, checksum, rows or affected-row count, and stable safe result/error codes. diff --git a/platform/domain/observability.go b/platform/domain/observability.go index 980e9e2..541ad9e 100644 --- a/platform/domain/observability.go +++ b/platform/domain/observability.go @@ -87,15 +87,16 @@ type RemoteAdapterDeclaration struct { } type RemoteAdapterRequest struct { - ServerInstanceID string - DeclarationKey string - TargetKey string - Capability string - TimeoutSeconds int - MaxAttempts int - IdempotencyKey string - InputRef string - Inputs map[string]string + ServerInstanceID string + DeclarationKey string + TargetKey string + Capability string + TimeoutSeconds int + MaxAttempts int + IdempotencyKey string + InputRef string + Inputs map[string]string + PlatformScheduled bool } type RemoteAdapterResult struct { diff --git a/platform/domain/resources.go b/platform/domain/resources.go index f884f50..6205bac 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1185,6 +1185,7 @@ type JobExecutionResult struct { SizeBytes int64 AuditSummary string Content string + SQLiteSchemaProbe *SCUMSchemaProbeResult ServerDeploymentEvidence *ServerDeploymentEvidence DeploymentReceipt *ServerDeploymentExecutionReceipt } @@ -2025,6 +2026,7 @@ func CopyJob(job Job) Job { job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...) job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON) job.ExecutionInput.ServerDeploymentPlan = CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan) + job.ExecutionResult.SQLiteSchemaProbe = CopySCUMSchemaProbeResultPtr(job.ExecutionResult.SQLiteSchemaProbe) job.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(job.ExecutionResult.ServerDeploymentEvidence) job.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(job.ExecutionResult.DeploymentReceipt) if job.ExecutionInput.Deployment != nil { diff --git a/platform/domain/scum_live_data.go b/platform/domain/scum_live_data.go index cb0aa24..c7144ae 100644 --- a/platform/domain/scum_live_data.go +++ b/platform/domain/scum_live_data.go @@ -262,6 +262,14 @@ func CopySCUMSchemaProbeResult(value SCUMSchemaProbeResult) SCUMSchemaProbeResul return value } +func CopySCUMSchemaProbeResultPtr(value *SCUMSchemaProbeResult) *SCUMSchemaProbeResult { + if value == nil { + return nil + } + copy := CopySCUMSchemaProbeResult(*value) + return © +} + func CopySCUMLiveDataManifest(value SCUMLiveDataManifest) SCUMLiveDataManifest { value.CapabilityGates = append([]SCUMLiveDataCapabilityGateDeclaration(nil), value.CapabilityGates...) for index := range value.CapabilityGates { diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 20f5a77..695583d 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -189,6 +189,7 @@ type RunJobExecutionResultBody struct { SizeBytes int64 `json:"sizeBytes,omitempty"` AuditSummary string `json:"auditSummary,omitempty"` Content string `json:"content,omitempty"` + SQLiteSchemaProbe *SCUMSchemaProbeResultDTO `json:"sqliteSchemaProbe,omitempty"` ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"` DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"` } @@ -448,7 +449,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult { Message: request.Message, ErrorCode: request.ErrorCode, Retryable: request.Retryable, - ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, + ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, SQLiteSchemaProbe: SCUMSchemaProbeResultPtrToDomain(request.ExecutionResult.SQLiteSchemaProbe), ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, } } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 28e1852..694999d 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -884,6 +884,7 @@ type JobExecutionResultResponse struct { Checksum string `json:"checksum,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` AuditSummary string `json:"auditSummary,omitempty"` + SQLiteSchemaProbe *SCUMSchemaProbeResultDTO `json:"sqliteSchemaProbe,omitempty"` ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"` } @@ -1930,7 +1931,7 @@ func JobFromDomain(job domain.Job) JobResponse { State: job.State, Progress: progressFromDomain(job.Progress), ResultRef: job.ResultRef, - ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, AuditSummary: job.ExecutionResult.AuditSummary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)}, + ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, AuditSummary: job.ExecutionResult.AuditSummary, SQLiteSchemaProbe: SCUMSchemaProbeResultPtrFromDomain(job.ExecutionResult.SQLiteSchemaProbe), ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)}, RetryPolicy: JobRetryPolicyResponse{ MaxAttempts: job.RetryPolicy.MaxAttempts, InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds, diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index f7a64b0..a8cebc1 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -31,6 +31,40 @@ func TestAIProviderResponseExposesOnlySecretPresence(t *testing.T) { } } +func TestRunJobResultRequestParsesSQLiteSchemaProbeEnvelope(t *testing.T) { + payload := `{ + "runEndpointId":"run-local", + "sessionToken":"run-session", + "jobId":"job-probe", + "leaseToken":"lease-probe", + "attempt":1, + "state":"succeeded", + "progress":{"percent":100,"message":"done"}, + "executionResult":{ + "kind":"sqlite.schema.probe", + "sqliteSchemaProbe":{ + "requestId":"job-probe", + "jobId":"job-probe", + "binding":{"serverInstanceId":"server-scum","runBindingId":"runtime-binding-server-scum","runEndpointId":"run-local","pluginId":"server.scum","pluginVersion":"1.0.0","adapterVersion":"scum-live-data-v0","gameVersion":"1.0.0","databaseIdentity":"logical:scum-database"}, + "status":"compatible", + "schemaFingerprint":"sha256:` + strings.Repeat("a", 64) + `", + "observedAt":"2026-08-12T00:00:00Z", + "resultDigest":"sha256:` + strings.Repeat("b", 64) + `", + "limits":{"maxObjects":256,"maxColumnsPerObject":128,"maxIndexesPerObject":64,"maxForeignKeys":64,"maxCardinalityReads":64,"maxSampleRows":3,"timeoutMs":5000,"maxResultBytes":524288} + } + } + }` + var request RunJobResultRequest + if err := json.Unmarshal([]byte(payload), &request); err != nil { + t.Fatalf("unmarshal Run job result: %v", err) + } + domainRequest := request.ToDomain() + probe := domainRequest.ExecutionResult.SQLiteSchemaProbe + if probe == nil || probe.JobID != "job-probe" || probe.Binding.DatabaseIdentity != "logical:scum-database" || probe.ResultDigest != "sha256:"+strings.Repeat("b", 64) { + t.Fatalf("sqliteSchemaProbe envelope did not parse: %+v", probe) + } +} + func TestAIProviderFromDomainCopiesModels(t *testing.T) { provider := domain.AIProvider{ ID: "ai.openai", diff --git a/platform/dto/scum_live_data.go b/platform/dto/scum_live_data.go index 001c734..0c4bdc4 100644 --- a/platform/dto/scum_live_data.go +++ b/platform/dto/scum_live_data.go @@ -98,6 +98,34 @@ func SCUMSchemaProbeRequestToDomain(value SCUMSchemaProbeRequestDTO) domain.SCUM return domain.SCUMSchemaProbeRequest{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Bounds: scumProbeBoundsToDomain(value.Bounds), RequestedAt: value.RequestedAt} } +func SCUMSchemaProbeResultToDomain(value SCUMSchemaProbeResultDTO) domain.SCUMSchemaProbeResult { + objects := make([]domain.SCUMSchemaObjectEvidence, len(value.Objects)) + for index, object := range value.Objects { + columns := make([]domain.SCUMSchemaColumnEvidence, len(object.DeclaredColumns)) + for i, column := range object.DeclaredColumns { + columns[i] = domain.SCUMSchemaColumnEvidence{NameFingerprint: column.NameFingerprint, DeclaredType: column.DeclaredType, Nullable: column.Nullable, PrimaryKey: column.PrimaryKey, Ordinal: column.Ordinal} + } + indexes := make([]domain.SCUMSchemaIndexEvidence, len(object.Indexes)) + for i, item := range object.Indexes { + indexes[i] = domain.SCUMSchemaIndexEvidence{NameFingerprint: item.NameFingerprint, Unique: item.Unique, ColumnHashes: append([]string(nil), item.ColumnHashes...)} + } + foreignKeys := make([]domain.SCUMSchemaForeignKeyEvidence, len(object.ForeignKeys)) + for i, item := range object.ForeignKeys { + foreignKeys[i] = domain.SCUMSchemaForeignKeyEvidence{FromColumnHash: item.FromColumnHash, ToObjectHash: item.ToObjectHash, ToColumnHash: item.ToColumnHash} + } + objects[index] = domain.SCUMSchemaObjectEvidence{ObjectHash: object.ObjectHash, Kind: object.Kind, NameFingerprint: object.NameFingerprint, DeclaredColumns: columns, Indexes: indexes, ForeignKeys: foreignKeys, ApproximateRows: object.ApproximateRows, SampleFingerprints: append([]string(nil), object.SampleFingerprints...)} + } + return domain.SCUMSchemaProbeResult{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Status: domain.SCUMCapabilityEvidenceStatus(value.Status), SchemaFingerprint: value.SchemaFingerprint, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, Objects: objects, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorCode(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumProbeBoundsToDomain(value.Limits)} +} + +func SCUMSchemaProbeResultPtrToDomain(value *SCUMSchemaProbeResultDTO) *domain.SCUMSchemaProbeResult { + if value == nil { + return nil + } + result := SCUMSchemaProbeResultToDomain(*value) + return &result +} + func SCUMSchemaProbeResultFromDomain(value domain.SCUMSchemaProbeResult) SCUMSchemaProbeResultDTO { value = domain.CopySCUMSchemaProbeResult(value) objects := make([]SCUMSchemaObjectEvidenceDTO, len(value.Objects)) @@ -119,6 +147,14 @@ func SCUMSchemaProbeResultFromDomain(value domain.SCUMSchemaProbeResult) SCUMSch return SCUMSchemaProbeResultDTO{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityFromDomain(value.Binding), Status: string(value.Status), SchemaFingerprint: value.SchemaFingerprint, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, Objects: objects, SafeError: SCUMSafeErrorDTO{Code: string(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumProbeBoundsFromDomain(value.Limits)} } +func SCUMSchemaProbeResultPtrFromDomain(value *domain.SCUMSchemaProbeResult) *SCUMSchemaProbeResultDTO { + if value == nil { + return nil + } + result := SCUMSchemaProbeResultFromDomain(*value) + return &result +} + func SCUMCapabilityGateFromDomain(value domain.SCUMCapabilityGate) SCUMCapabilityGateDTO { return SCUMCapabilityGateDTO{Capability: string(value.Capability), State: string(value.State), Enabled: value.Enabled, ReasonCode: string(value.ReasonCode), Reason: value.Reason} } diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 7dc7276..3512216 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -334,6 +334,18 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "run.update.staged" { return validationError("Run self-update result type is invalid") } + case domain.JobCapabilityRemoteRunDBSQLiteProbe: + if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumSchemaProbeExecutionKind { + return validationError("SQLite schema probe result type is invalid") + } + if result.State == domain.JobStateSucceeded { + if result.ExecutionResult.SQLiteSchemaProbe == nil { + return validationError("SQLite schema probe terminal result is required") + } + if err := validateSCUMSchemaProbeResultForJob(job, *result.ExecutionResult.SQLiteSchemaProbe); err != nil { + return err + } + } case domain.JobCapabilityClientManagerDeploy: if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" { return validationError("client-manager deploy result type is invalid") diff --git a/platform/service/remote_adapters.go b/platform/service/remote_adapters.go index b2ccbd6..cb8e4cb 100644 --- a/platform/service/remote_adapters.go +++ b/platform/service/remote_adapters.go @@ -44,6 +44,9 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request if err := validator.ValidateRemoteAdapterRequest(request); err != nil { return domain.RemoteAdapterResult{}, err } + if request.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe && !request.PlatformScheduled { + return domain.RemoteAdapterResult{}, forbiddenError("schema probe is scheduled by Platform and is not a direct remote-adapter request") + } instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) if err != nil { return domain.RemoteAdapterResult{}, err @@ -129,7 +132,7 @@ func isRemoteAdapterCapability(capability string) bool { domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, - domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, + domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand: return true default: @@ -166,7 +169,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind return domain.RemoteAdapterRunFile case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop: return domain.RemoteAdapterRunProcess - case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery: + case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery: return domain.RemoteAdapterDatabase case domain.JobCapabilityRemoteRunRCONCommand: return domain.RemoteAdapterRCON diff --git a/platform/service/remote_adapters_test.go b/platform/service/remote_adapters_test.go index aa132cc..3845da5 100644 --- a/platform/service/remote_adapters_test.go +++ b/platform/service/remote_adapters_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strings" "testing" "browser.local/platform/domain" @@ -86,3 +87,97 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) { t.Fatal("real Run claim aliases persisted remote inputs") } } + +func TestSCUMSchemaProbeDispatchIsPlatformScheduledAndFenced(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + plugin.Permissions.RemoteAccess = true + plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access") + plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) + plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}, DatabaseEngines: []string{"sqlite"}} + plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}) + plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys = append(plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys, "scum-database") + plugin.SCUMLiveData = domain.SCUMLiveDataManifest{SchemaVersion: "1", Probe: domain.SCUMSchemaProbeDeclaration{Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, TargetKey: "scum-database", Bounds: domain.DefaultSCUMSchemaProbeBounds()}, CapabilityGates: []domain.SCUMLiveDataCapabilityGateDeclaration{{Capability: domain.SCUMDataCapabilitySchemaProbe, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "scum-live-data-v0", EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "waiting for current service evidence"}}} + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatal(err) + } + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatal(err) + } + session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-scum-probe-owner", DisplayName: "SCUM Probe Owner", Email: "scum-probe-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-scum-probe", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Probe"}) + if err != nil { + t.Fatalf("create server instance: %v", err) + } + createCompleteRuntimeBinding(t, svc, instance, "local") + + _, err = svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "scum-database", TargetKey: "scum-database", Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, IdempotencyKey: "direct-probe-denied", InputRef: "input://scum-schema-probe/direct-probe-denied"}) + if err == nil || !strings.Contains(err.Error(), "scheduled by Platform") { + t.Fatalf("expected public probe request denial, got %v", err) + } + endpoint.Capabilities = withoutCapability(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatal(err) + } + if _, _, err = svc.RequestSCUMSchemaProbeForSession(session, instance.ID, "probe-missing-run-capability"); err == nil || !strings.Contains(err.Error(), "does not expose") { + t.Fatalf("expected missing active Run capability, got %v", err) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil || len(jobs) != 0 { + t.Fatalf("missing probe executor must not create jobs: len=%d err=%v", len(jobs), err) + } + + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatal(err) + } + probeRequest, queued, err := svc.RequestSCUMSchemaProbeForSession(session, instance.ID, "probe-current-schema") + if err != nil { + t.Fatalf("queue SCUM schema probe: %v", err) + } + job, err := svc.store.Jobs().Get(queued.RequestID) + if err != nil { + t.Fatalf("get probe job: %v", err) + } + if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteProbe || job.TargetKey != "scum-database" || job.ExecutionInput.RemoteAdapterKind != string(domain.RemoteAdapterDatabase) || job.ExecutionInput.Inputs["databaseIdentity"] != "logical:scum-database" { + t.Fatalf("unexpected probe job envelope: %+v", job) + } + if job.ExecutionInput.Inputs["jobId"] != job.ID || job.ExecutionInput.Inputs["requestId"] != probeRequest.RequestID || job.ExecutionInput.Inputs["maxResultBytes"] != "524288" { + t.Fatalf("probe inputs are not fenced and bounded: %+v", job.ExecutionInput.Inputs) + } + + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) + helloRequest.CapabilityReport.Fingerprint = "cap-scum-probe" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run hello: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil { + t.Fatalf("claim probe job: %v", err) + } + if !claim.HasJob || claim.Job == nil || claim.Job.ExecutionInput.Inputs["adapterVersion"] != "scum-live-data-v0" || claim.Job.ExecutionInput.Inputs["targetKey"] != "scum-database" { + t.Fatalf("claimed probe job lost typed inputs: %+v", claim.Job) + } + badBinding := probeRequest.Binding + badBinding.RunBindingID = "runtime-binding-other" + badProbe := domain.SCUMSchemaProbeResult{RequestID: probeRequest.RequestID, JobID: probeRequest.JobID, Binding: badBinding, Status: domain.SCUMCapabilityEvidenceCompatible, SchemaFingerprint: "sha256:" + strings.Repeat("a", 64), ObservedAt: fixedTime, ResultDigest: "sha256:" + strings.Repeat("b", 64), Limits: probeRequest.Bounds} + _, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "probe complete"}, Message: "probe complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSchemaProbeExecutionKind, SQLiteSchemaProbe: &badProbe, AuditSummary: "redacted schema probe"}}) + if err == nil || !strings.Contains(err.Error(), "binding identity") { + t.Fatalf("expected binding mismatch rejection, got %v", err) + } + goodProbe := badProbe + goodProbe.Binding = probeRequest.Binding + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "probe complete"}, Message: "probe complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSchemaProbeExecutionKind, SQLiteSchemaProbe: &goodProbe, AuditSummary: "redacted schema probe"}}); err != nil { + t.Fatalf("complete fenced probe job: %v", err) + } + stored, err := svc.store.Jobs().Get(job.ID) + if err != nil { + t.Fatalf("get completed probe job: %v", err) + } + if stored.ExecutionResult.SQLiteSchemaProbe == nil || stored.ExecutionResult.SQLiteSchemaProbe.ResultDigest != goodProbe.ResultDigest || stored.ExecutionResult.SQLiteSchemaProbe.Binding.RunBindingID != probeRequest.Binding.RunBindingID { + t.Fatalf("typed probe result was not persisted safely: %+v", stored.ExecutionResult.SQLiteSchemaProbe) + } +} diff --git a/platform/service/resources.go b/platform/service/resources.go index 8878873..d515883 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -124,6 +124,7 @@ type Core interface { ListBackupsForSession(string, domain.BackupFilter) ([]domain.BackupRecord, error) ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error) RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) + RequestSCUMSchemaProbeForSession(string, string, string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) GetServerConfigForSession(string, string) (domain.ServerConfig, error) GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error) PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) @@ -1167,6 +1168,11 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested remote capability is not declared by plugin"} return base } + if capability == domain.JobCapabilityRemoteRunDBSQLiteProbe { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "schema probe is scheduled by Platform and is not a plugin page action"} + return base + } declarationKey := strings.TrimSpace(payload["declarationKey"]) if declarationKey == "" { for _, profile := range plugin.RuntimeProfiles.TransportProfiles { diff --git a/platform/service/scum_live_data.go b/platform/service/scum_live_data.go new file mode 100644 index 0000000..487eba2 --- /dev/null +++ b/platform/service/scum_live_data.go @@ -0,0 +1,171 @@ +package service + +import ( + "errors" + "strconv" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +const scumSchemaProbeExecutionKind = "sqlite.schema.probe" + +func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) { + idempotencyKey = strings.TrimSpace(idempotencyKey) + if idempotencyKey == "" { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("idempotencyKey is required") + } + instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) + if err != nil { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err + } + probe := plugin.SCUMLiveData.Probe + if plugin.SCUMLiveData.SchemaVersion == "" || probe.Capability != domain.JobCapabilityRemoteRunDBSQLiteProbe || strings.TrimSpace(probe.TargetKey) == "" { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("SCUM schema probe is not declared by the plugin") + } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err + } + if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("bound Run is not online for SCUM schema probe") + } + if !containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("bound Run does not expose the generic SQLite schema-probe executor") + } + binding, err := svc.runtimeBindingForServer(instance.ID) + if errors.Is(err, repo.ErrNotFound) { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("runtime binding is required before SCUM schema probe") + } + if err != nil { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err + } + adapterVersion := scumSchemaProbeAdapterVersion(plugin.SCUMLiveData) + if adapterVersion == "" { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("SCUM schema-probe adapter version is not declared") + } + bounds := probe.Bounds + if bounds.MaxObjects == 0 { + bounds = domain.DefaultSCUMSchemaProbeBounds() + } + jobID := jobIDFromParts("job-remote-adapter", instance.ID, idempotencyKey) + request := domain.SCUMSchemaProbeRequest{ + RequestID: jobID, + JobID: jobID, + Binding: domain.SCUMBindingIdentity{ + ServerInstanceID: instance.ID, + RunBindingID: binding.ID, + RunEndpointID: instance.RunEndpointID, + PluginID: plugin.ID, + PluginVersion: plugin.Version, + AdapterVersion: adapterVersion, + DatabaseIdentity: "logical:" + probe.TargetKey, + }, + Bounds: bounds, + RequestedAt: svc.now(), + } + if err := validator.ValidateSCUMSchemaProbeRequest(request); err != nil { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err + } + result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ + ServerInstanceID: instance.ID, + DeclarationKey: probe.TargetKey, + TargetKey: probe.TargetKey, + Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, + TimeoutSeconds: scumSchemaProbeTimeoutSeconds(bounds), + MaxAttempts: 1, + IdempotencyKey: idempotencyKey, + InputRef: "input://scum-schema-probe/" + instance.ID + "/" + idempotencyKey, + Inputs: scumSchemaProbeInputs(request, probe.TargetKey), + PlatformScheduled: true, + }) + if err != nil { + return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err + } + return request, result, nil +} + +func scumSchemaProbeAdapterVersion(manifest domain.SCUMLiveDataManifest) string { + for _, gate := range manifest.CapabilityGates { + if gate.Capability == domain.SCUMDataCapabilitySchemaProbe { + return strings.TrimSpace(gate.AdapterVersion) + } + } + for _, gate := range manifest.CapabilityGates { + if strings.TrimSpace(gate.AdapterVersion) != "" { + return strings.TrimSpace(gate.AdapterVersion) + } + } + return "" +} + +func scumSchemaProbeTimeoutSeconds(bounds domain.SCUMSchemaProbeBounds) int { + if bounds.TimeoutMS <= 0 { + return 1 + } + seconds := (bounds.TimeoutMS + 999) / 1000 + if seconds <= 0 { + return 1 + } + return seconds +} + +func scumSchemaProbeInputs(request domain.SCUMSchemaProbeRequest, targetKey string) map[string]string { + return map[string]string{ + "requestId": request.RequestID, + "jobId": request.JobID, + "targetKey": targetKey, + "serverInstanceId": request.Binding.ServerInstanceID, + "runBindingId": request.Binding.RunBindingID, + "runEndpointId": request.Binding.RunEndpointID, + "pluginId": request.Binding.PluginID, + "pluginVersion": request.Binding.PluginVersion, + "adapterVersion": request.Binding.AdapterVersion, + "gameVersion": request.Binding.GameVersion, + "databaseIdentity": request.Binding.DatabaseIdentity, + "maxObjects": strconv.Itoa(request.Bounds.MaxObjects), + "maxColumnsPerObject": strconv.Itoa(request.Bounds.MaxColumnsPerObject), + "maxIndexesPerObject": strconv.Itoa(request.Bounds.MaxIndexesPerObject), + "maxForeignKeys": strconv.Itoa(request.Bounds.MaxForeignKeys), + "maxCardinalityReads": strconv.Itoa(request.Bounds.MaxCardinalityReads), + "maxSampleRows": strconv.Itoa(request.Bounds.MaxSampleRows), + "timeoutMs": strconv.Itoa(request.Bounds.TimeoutMS), + "maxResultBytes": strconv.Itoa(request.Bounds.MaxResultBytes), + } +} + +func scumSchemaProbeBindingFromInputs(inputs map[string]string) domain.SCUMBindingIdentity { + return domain.SCUMBindingIdentity{ + ServerInstanceID: inputs["serverInstanceId"], + RunBindingID: inputs["runBindingId"], + RunEndpointID: inputs["runEndpointId"], + PluginID: inputs["pluginId"], + PluginVersion: inputs["pluginVersion"], + AdapterVersion: inputs["adapterVersion"], + GameVersion: inputs["gameVersion"], + DatabaseIdentity: inputs["databaseIdentity"], + } +} + +func validateSCUMSchemaProbeResultForJob(job domain.Job, result domain.SCUMSchemaProbeResult) error { + if err := validator.ValidateSCUMSchemaProbeResult(result); err != nil { + return err + } + if result.JobID != job.ID || result.JobID != job.ExecutionInput.Inputs["jobId"] || result.RequestID != job.ExecutionInput.Inputs["requestId"] { + return validationError("SQLite schema probe result does not match leased job identity") + } + if !sameSCUMSchemaProbeBinding(result.Binding, scumSchemaProbeBindingFromInputs(job.ExecutionInput.Inputs)) { + return validationError("SQLite schema probe result does not match leased binding identity") + } + return nil +} + +func sameSCUMSchemaProbeBinding(a, b domain.SCUMBindingIdentity) bool { + return a.ServerInstanceID == b.ServerInstanceID && a.RunBindingID == b.RunBindingID && a.RunEndpointID == b.RunEndpointID && a.PluginID == b.PluginID && a.PluginVersion == b.PluginVersion && a.AdapterVersion == b.AdapterVersion && a.GameVersion == b.GameVersion && a.DatabaseIdentity == b.DatabaseIdentity +} diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index c2ea5ed..3e22a9a 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -51,6 +51,11 @@ func ValidateRunJobResult(result domain.RunJobResult) error { if result.ExecutionResult.Checksum != "" && !validSHA256Checksum(result.ExecutionResult.Checksum) { violations = append(violations, "executionResult.checksum must be sha256:") } + if result.ExecutionResult.SQLiteSchemaProbe != nil { + if err := ValidateSCUMSchemaProbeResult(*result.ExecutionResult.SQLiteSchemaProbe); err != nil { + violations = append(violations, "executionResult.sqliteSchemaProbe: "+err.Error()) + } + } return finish(violations) } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index c0a60ff..99632fd 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -1543,6 +1543,11 @@ func ValidateJob(job domain.Job) error { if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength { violations = append(violations, "executionResult.auditSummary is too long") } + if job.ExecutionResult.SQLiteSchemaProbe != nil { + if err := ValidateSCUMSchemaProbeResult(*job.ExecutionResult.SQLiteSchemaProbe); err != nil { + violations = append(violations, "executionResult.sqliteSchemaProbe: "+err.Error()) + } + } if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { if job.ServerInstanceID == "" { violations = append(violations, "serverInstanceId is required for scoped file jobs")