Remove AI config approval flow
This commit is contained in:
@@ -25,7 +25,8 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
instance, streams, subscription, err := h.openLogEventSubscription(r)
|
||||
jobID := strings.TrimSpace(r.URL.Query().Get("jobId"))
|
||||
instance, streams, subscription, err := h.openLogEventSubscription(r, jobID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -45,7 +46,9 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
active := supervisedLogSession{}
|
||||
if isComponentLogRequest(r) {
|
||||
if jobID != "" {
|
||||
active = activeJobLogSession(jobID, streams)
|
||||
} else if isComponentLogRequest(r) {
|
||||
active = activeComponentLogSession(streams)
|
||||
} else {
|
||||
active = activeSupervisedLogSession(streams)
|
||||
@@ -72,6 +75,9 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if subscriptionEvent.Kind == service.LogEventSubscriptionEventProcessState {
|
||||
if jobID != "" {
|
||||
continue
|
||||
}
|
||||
if isComponentLogRequest(r) {
|
||||
continue
|
||||
}
|
||||
@@ -90,7 +96,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher.Flush()
|
||||
continue
|
||||
}
|
||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r), jobID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -111,10 +117,16 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
event := subscriptionEvent.LogEvent
|
||||
candidate := activeSupervisedLogSession([]domain.LogStream{event.Stream})
|
||||
if jobID != "" {
|
||||
if !strings.HasPrefix(event.Stream.ID, "job."+jobID+".") {
|
||||
continue
|
||||
}
|
||||
candidate = activeJobLogSession(jobID, []domain.LogStream{event.Stream})
|
||||
}
|
||||
if active.allStreams {
|
||||
candidate = supervisedLogSession{}
|
||||
}
|
||||
if candidate.sessionID != "" && newerLogSession(candidate, active) {
|
||||
if jobID == "" && candidate.sessionID != "" && newerLogSession(candidate, active) {
|
||||
next := candidate
|
||||
if !sameSupervisedLogSession(active, next) {
|
||||
active = next
|
||||
@@ -129,7 +141,11 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if !active.contains(event.Stream) {
|
||||
if !active.allStreams || event.Stream.ServerInstanceID != instance.ID {
|
||||
if jobID == "" {
|
||||
if !active.allStreams || event.Stream.ServerInstanceID != instance.ID {
|
||||
continue
|
||||
}
|
||||
} else if !strings.HasPrefix(event.Stream.ID, "job."+jobID+".") {
|
||||
continue
|
||||
}
|
||||
active.streams = append(active.streams, event.Stream)
|
||||
@@ -161,6 +177,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type supervisedLogSession struct {
|
||||
jobID string
|
||||
sessionID string
|
||||
startedAt time.Time
|
||||
streams []domain.LogStream
|
||||
@@ -193,6 +210,11 @@ func activeSupervisedLogSession(streams []domain.LogStream) supervisedLogSession
|
||||
return active
|
||||
}
|
||||
|
||||
func activeJobLogSession(jobID string, streams []domain.LogStream) supervisedLogSession {
|
||||
active := supervisedLogSession{jobID: jobID, sessionID: "job:" + jobID, streams: append([]domain.LogStream(nil), streams...)}
|
||||
return active
|
||||
}
|
||||
|
||||
func newerLogSession(candidate supervisedLogSession, current supervisedLogSession) bool {
|
||||
if candidate.sessionID == "" || candidate.sessionID == current.sessionID {
|
||||
return false
|
||||
@@ -207,10 +229,13 @@ func newerLogSession(candidate supervisedLogSession, current supervisedLogSessio
|
||||
}
|
||||
|
||||
func sameSupervisedLogSession(left supervisedLogSession, right supervisedLogSession) bool {
|
||||
return left.sessionID == right.sessionID && left.startedAt.Equal(right.startedAt)
|
||||
return left.jobID == right.jobID && left.sessionID == right.sessionID && left.startedAt.Equal(right.startedAt)
|
||||
}
|
||||
|
||||
func (session supervisedLogSession) contains(stream domain.LogStream) bool {
|
||||
if session.jobID != "" {
|
||||
return strings.HasPrefix(stream.ID, "job."+session.jobID+".") && session.hasStream(stream.ID)
|
||||
}
|
||||
if session.allStreams {
|
||||
return session.hasStream(stream.ID)
|
||||
}
|
||||
@@ -240,7 +265,7 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
|
||||
return emittedThrough, nil
|
||||
}
|
||||
|
||||
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
|
||||
func (h *coreHandlers) openLogEventSubscription(r *http.Request, jobID string) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
|
||||
var instance domain.ServerInstance
|
||||
var streams []domain.LogStream
|
||||
var subscription service.LogEventSubscription
|
||||
@@ -270,8 +295,23 @@ func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerI
|
||||
return domain.ServerInstance{}, nil, subscription, err
|
||||
}
|
||||
}
|
||||
if err == nil && jobID != "" {
|
||||
if h.enforceAuthorization && isComponentLogRequest(r) {
|
||||
err = fmt.Errorf("job log streams require session authorization")
|
||||
} else {
|
||||
var job domain.Job
|
||||
if h.enforceAuthorization {
|
||||
job, err = h.core.GetJobForSession(bearerToken(r), jobID)
|
||||
} else {
|
||||
job, err = h.core.GetJob(jobID)
|
||||
}
|
||||
if err == nil && job.ServerInstanceID != instance.ID {
|
||||
err = service.ErrForbidden
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r), jobID)
|
||||
}
|
||||
if err != nil && subscription.Close != nil {
|
||||
subscription.Close()
|
||||
@@ -288,23 +328,25 @@ func isComponentLogRequest(r *http.Request) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool) ([]domain.LogStream, error) {
|
||||
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool, jobID string) ([]domain.LogStream, error) {
|
||||
instance, err := h.core.GetServerInstance(serverInstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !includeDeclaredStreams && instance.State != domain.ServerInstanceStateRunning {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endpoint.Status != domain.RunEndpointStatusOnline {
|
||||
return nil, nil
|
||||
if jobID == "" {
|
||||
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !includeDeclaredStreams && instance.State != domain.ServerInstanceStateRunning {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endpoint.Status != domain.RunEndpointStatusOnline {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
@@ -312,6 +354,12 @@ func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDecla
|
||||
}
|
||||
current := make([]domain.LogStream, 0, len(streams))
|
||||
for _, stream := range streams {
|
||||
if jobID != "" {
|
||||
if strings.HasPrefix(stream.ID, "job."+jobID+".") {
|
||||
current = append(current, stream)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if includeDeclaredStreams {
|
||||
if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram {
|
||||
continue
|
||||
|
||||
@@ -189,6 +189,56 @@ func TestLogEventsSSEStreamsBufferedAppendAfterOpen(t *testing.T) {
|
||||
assertSSEEvent(t, reader, "log", `"seq":3`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEStreamsScopedJobLogsWhenServerIsStopped(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
job := postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
|
||||
ID: "job-scoped-log", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-scoped-log",
|
||||
})
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{
|
||||
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded,
|
||||
Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"},
|
||||
}), http.StatusOK)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?jobId="+job.ID, nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
router.ServeHTTP(streamWriter, request)
|
||||
_ = streamWriter.Close()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = streamReader.Close()
|
||||
<-done
|
||||
})
|
||||
if status := <-streamWriter.status; status != http.StatusOK {
|
||||
t.Fatalf("unexpected scoped job SSE status: %d", status)
|
||||
}
|
||||
reader := bufio.NewReader(streamReader)
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"job:`+job.ID+`"`)
|
||||
streamOne, streamOneData := readSSEEvent(t, reader)
|
||||
streamTwo, streamTwoData := readSSEEvent(t, reader)
|
||||
if streamOne != "stream" || streamTwo != "stream" || !strings.Contains(streamOneData, `"id":"job.`+job.ID+`.stdout"`) && !strings.Contains(streamTwoData, `"id":"job.`+job.ID+`.stdout"`) || !strings.Contains(streamOneData, `"id":"job.`+job.ID+`.stderr"`) && !strings.Contains(streamTwoData, `"id":"job.`+job.ID+`.stderr"`) {
|
||||
t.Fatalf("unexpected scoped job stream snapshot: %q %s; %q %s", streamOne, streamOneData, streamTwo, streamTwoData)
|
||||
}
|
||||
assertSSEEvent(t, reader, "ready", `"streamCount":2`)
|
||||
|
||||
batch := validLogBatchRequestForStream(t, hello.SessionToken, "job."+job.ID+".stdout", "stdout", 1, 1, 0)
|
||||
batch.LogSessionID = ""
|
||||
batch.SessionStartedAt = time.Time{}
|
||||
batch.Entries[0].Line = "JOB-SCOPED-LIVE-OUTPUT"
|
||||
checksum, err := validator.LogEntriesChecksum([]domain.LogEntry{{Seq: batch.Entries[0].Seq, Timestamp: batch.Entries[0].Timestamp, Level: batch.Entries[0].Level, Line: batch.Entries[0].Line}})
|
||||
if err != nil {
|
||||
t.Fatalf("checksum scoped job log: %v", err)
|
||||
}
|
||||
batch.Checksum = checksum
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"line":"JOB-SCOPED-LIVE-OUTPUT"`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
@@ -54,8 +54,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
||||
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
||||
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
||||
mux.HandleFunc("/api/v1/ai/config-diffs", h.aiConfigDiffs)
|
||||
mux.HandleFunc("/api/v1/ai/config-diffs/{id}/approve", h.aiConfigDiffApprove)
|
||||
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
||||
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
||||
mux.HandleFunc("/api/v1/backups", h.backups)
|
||||
@@ -201,60 +199,6 @@ func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
||||
}
|
||||
|
||||
// aiConfigDiffs godoc
|
||||
// @Summary List reviewable AI config diffs
|
||||
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
||||
// @Tags plugin-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.AIConfigDiffListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs [get]
|
||||
func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListAIConfigDiffsForSession(bearerToken(r), domain.AIConfigDiffFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), PluginID: r.URL.Query().Get("pluginId"), State: domain.AIConfigDiffState(r.URL.Query().Get("state"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.AIConfigDiffListFromDomain(items))
|
||||
}
|
||||
|
||||
// aiConfigDiffApprove godoc
|
||||
// @Summary Approve one reviewable AI config diff
|
||||
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
||||
// @Tags plugin-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "AI config diff ID"
|
||||
// @Param body body dto.AIConfigDiffApprovalRequest true "AI config diff approval"
|
||||
// @Success 202 {object} dto.AIConfigDiffApprovalResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs/{id}/approve [post]
|
||||
func (h *coreHandlers) aiConfigDiffApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.AIConfigDiffApprovalRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.ApproveAIConfigDiffForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.AIConfigDiffApprovalFromDomain(result))
|
||||
}
|
||||
|
||||
// authRegister godoc
|
||||
// @Summary Register a platform account
|
||||
// @Description Creates a pending low-privilege platform account without granting platform administrator rights.
|
||||
@@ -865,7 +809,7 @@ func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
response, err := h.core.InvokeAIForSession(bearerToken(r), domain.AIInvocationRequest{
|
||||
RequestID: "config-suggestion:" + request.ServerInstanceID,
|
||||
RequestID: fmt.Sprintf("config-suggestion:%s:%d", request.ServerInstanceID, time.Now().UnixNano()),
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
Purpose: "config.suggest",
|
||||
Prompt: request.Prompt,
|
||||
@@ -888,7 +832,11 @@ func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request
|
||||
if response.ConfigRecommendation != nil {
|
||||
suggested = response.ConfigRecommendation.SuggestedConfig
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested})
|
||||
var execution *dto.AIConfigExecutionResponse
|
||||
if response.ConfigExecution != nil {
|
||||
execution = &dto.AIConfigExecutionResponse{Status: response.ConfigExecution.Status, Job: dto.JobFromDomain(response.ConfigExecution.Job)}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested, ConfigExecution: execution})
|
||||
}
|
||||
|
||||
// gamePlugins godoc
|
||||
|
||||
@@ -1072,6 +1072,7 @@ func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
||||
adminSession := createAdminSession(t, router)
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
registration := validGamePluginManifestRegistrationRequest()
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityConfigWrite)
|
||||
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"}
|
||||
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
||||
@@ -1117,19 +1118,20 @@ func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
||||
}, adminSession)
|
||||
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||
configRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-suggestions", dto.LlmConfigSuggestionRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
Prompt: "Turn off pvp and keep this reviewable",
|
||||
Prompt: "Turn off pvp and apply it now",
|
||||
CurrentConfig: "server.name=AI API Server\n",
|
||||
}, adminSession)
|
||||
assertStatus(t, configRecorder, http.StatusOK)
|
||||
config := decodeBody[dto.LlmConfigSuggestionResponse](t, configRecorder)
|
||||
if config.SuggestedConfig == "" || !strings.Contains(config.SuggestedConfig, "ai.recommendation=review-required") {
|
||||
t.Fatalf("expected reviewable config suggestion, got %+v", config)
|
||||
if config.SuggestedConfig == "" || config.ConfigExecution == nil || config.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
t.Fatalf("expected immediately dispatched config suggestion, got %+v", config)
|
||||
}
|
||||
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-ai-api", adminSession)
|
||||
if jobs.Count != 0 {
|
||||
t.Fatalf("AI suggestion must not dispatch config writes, got %+v", jobs)
|
||||
if jobs.Count != 1 || !containsJobID(jobs.Items, config.ConfigExecution.Job.ID) {
|
||||
t.Fatalf("AI suggestion must dispatch one config write, got %+v", jobs)
|
||||
}
|
||||
|
||||
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
||||
@@ -1597,22 +1599,16 @@ func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
|
||||
t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles)
|
||||
}
|
||||
|
||||
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
||||
if invocation.ConfigRecommendation == nil || invocation.ConfigRecommendation.DiffID == "" {
|
||||
t.Fatalf("expected persisted AI config diff, got %+v", invocation)
|
||||
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-direct", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
||||
if invocation.ConfigRecommendation == nil || invocation.ConfigExecution == nil || invocation.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
t.Fatalf("expected direct AI config write dispatch, got %+v", invocation)
|
||||
}
|
||||
diffs := getJSONWithAuth[dto.AIConfigDiffListResponse](t, router, "/api/v1/ai/config-diffs?serverInstanceId="+serverID, adminSession)
|
||||
if diffs.Count != 1 || diffs.Items[0].State != string(domain.AIConfigDiffStatePending) {
|
||||
t.Fatalf("expected one pending AI diff, got %+v", diffs)
|
||||
}
|
||||
approvalRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-diffs/"+invocation.ConfigRecommendation.DiffID+"/approve", dto.AIConfigDiffApprovalRequest{IdempotencyKey: "api-ai-diff-approve"}, adminSession)
|
||||
assertStatus(t, approvalRecorder, http.StatusAccepted)
|
||||
approval := decodeBody[dto.AIConfigDiffApprovalResponse](t, approvalRecorder)
|
||||
if approval.Preview.State != string(domain.AIConfigDiffStateApproved) || approval.Dispatch.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
t.Fatalf("expected approved diff with config write job, got %+v", approval)
|
||||
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId="+serverID, adminSession)
|
||||
if jobs.Count < 2 || !containsJobID(jobs.Items, invocation.ConfigExecution.Job.ID) {
|
||||
t.Fatalf("expected AI config write job in durable job list, got %+v", jobs)
|
||||
}
|
||||
|
||||
evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval)
|
||||
evidence := fmt.Sprintf("%+v %+v %+v", lifecycle, lifecycles, invocation)
|
||||
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
||||
if strings.Contains(evidence, forbidden) {
|
||||
t.Fatalf("plugin operations response leaked forbidden fragment %q: %s", forbidden, evidence)
|
||||
@@ -1992,7 +1988,7 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform"},
|
||||
ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
@@ -2010,3 +2006,12 @@ func validRunEndpointRequest() dto.RunEndpointCreateRequest {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func containsJobID(items []dto.JobResponse, id string) bool {
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ Server owner membership actions hide and reject platform administrators. Server
|
||||
|
||||
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
|
||||
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
|
||||
Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns reviewable typed diff metadata through AI config-diff approval flows rather than raw config text.
|
||||
Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns typed recommendation and execution metadata; Platform validates and dispatches the bounded config.write job immediately rather than exposing a separate approval queue.
|
||||
|
||||
Observability responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
|
||||
|
||||
@@ -85,10 +85,10 @@ File dispatch responses expose only logical target keys, scoped input/artifact r
|
||||
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
||||
- `POST /api/v1/ai-providers/{id}/test`: invoke the configured provider client with a bounded health request and return only a redacted `AIProviderTestResponse`.
|
||||
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
|
||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
|
||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional config recommendation, direct `configExecution.job` metadata, and safe errors.
|
||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` after the bounded config.write job is queued.
|
||||
|
||||
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions persist an expiring diff and never dispatch run-side writes before separate approval.
|
||||
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions validate the current config revision/checksum, permissions, runtime binding, endpoint/plugin capability, and content safety before dispatching one bounded run-side write job.
|
||||
AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser.
|
||||
|
||||
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a scoped secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
||||
@@ -115,8 +115,6 @@ Marketplace catalog state remains separate from production lifecycle installatio
|
||||
|
||||
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
|
||||
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, and idempotency before creating one durable Run job.
|
||||
- `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session.
|
||||
- `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job.
|
||||
|
||||
These responses expose logical IDs, states, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
|
||||
|
||||
@@ -152,7 +150,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
||||
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries or interpret their body; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events, or a scoped durable job log stream when `jobId` is supplied. It does not interpret log bodies; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
|
||||
Runtime distribution APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
|
||||
|
||||
@@ -25,8 +25,11 @@ type AIConfigRecommendation struct {
|
||||
Key string
|
||||
SuggestedConfig string
|
||||
DiffSummary string
|
||||
DiffID string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type AIConfigExecution struct {
|
||||
Status string
|
||||
Job Job
|
||||
}
|
||||
|
||||
type AIInvocationSafeError struct {
|
||||
@@ -43,6 +46,7 @@ type AIInvocationResponse struct {
|
||||
Status string
|
||||
Recommendation string
|
||||
ConfigRecommendation *AIConfigRecommendation
|
||||
ConfigExecution *AIConfigExecution
|
||||
Usage AIInvocationUsage
|
||||
Error *AIInvocationSafeError
|
||||
}
|
||||
@@ -64,6 +68,11 @@ func CopyAIInvocationResponse(response AIInvocationResponse) AIInvocationRespons
|
||||
recommendation := *response.ConfigRecommendation
|
||||
response.ConfigRecommendation = &recommendation
|
||||
}
|
||||
if response.ConfigExecution != nil {
|
||||
execution := *response.ConfigExecution
|
||||
execution.Job = CopyJob(execution.Job)
|
||||
response.ConfigExecution = &execution
|
||||
}
|
||||
if response.Error != nil {
|
||||
errorCopy := *response.Error
|
||||
errorCopy.Details = CopyStringSlice(errorCopy.Details)
|
||||
|
||||
@@ -66,56 +66,6 @@ type PluginLifecycleResult struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
type AIConfigDiffState string
|
||||
|
||||
const (
|
||||
AIConfigDiffStatePending AIConfigDiffState = "pending"
|
||||
AIConfigDiffStateApproved AIConfigDiffState = "approved"
|
||||
AIConfigDiffStateCancelled AIConfigDiffState = "cancelled"
|
||||
AIConfigDiffStateExpired AIConfigDiffState = "expired"
|
||||
)
|
||||
|
||||
type AIConfigDiffPreview struct {
|
||||
ID string
|
||||
RequestID string
|
||||
CreatedBy string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProviderID string
|
||||
Model string
|
||||
Key string
|
||||
ConfigVersion int
|
||||
CurrentConfigChecksum string
|
||||
ProposedConfig string
|
||||
DiffSummary string
|
||||
State AIConfigDiffState
|
||||
ExpiresAt time.Time
|
||||
ApprovedBy string
|
||||
ApprovedAt time.Time
|
||||
ApprovalIdempotencyKey string
|
||||
CancelledBy string
|
||||
CancelledAt time.Time
|
||||
JobID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AIConfigDiffFilter struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
State AIConfigDiffState
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalRequest struct {
|
||||
DiffID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalResult struct {
|
||||
Preview AIConfigDiffPreview
|
||||
Dispatch ServerConfigWriteDispatch
|
||||
}
|
||||
|
||||
func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation {
|
||||
return installation
|
||||
}
|
||||
@@ -134,22 +84,3 @@ func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResu
|
||||
result.Job = CopyJob(result.Job)
|
||||
return result
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffPreview(preview AIConfigDiffPreview) AIConfigDiffPreview {
|
||||
return preview
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffPreviews(previews []AIConfigDiffPreview) []AIConfigDiffPreview {
|
||||
if previews == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]AIConfigDiffPreview, len(previews))
|
||||
copy(out, previews)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffApprovalResult(result AIConfigDiffApprovalResult) AIConfigDiffApprovalResult {
|
||||
result.Preview = CopyAIConfigDiffPreview(result.Preview)
|
||||
result.Dispatch = CopyServerConfigWriteDispatch(result.Dispatch)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -363,9 +363,8 @@ type GamePluginManifestServer struct {
|
||||
}
|
||||
|
||||
type GamePluginManifestAI struct {
|
||||
Purposes []string
|
||||
Mediation string
|
||||
ConfigWritePolicy string
|
||||
Purposes []string
|
||||
Mediation string
|
||||
}
|
||||
|
||||
type GamePluginProductionLifecycle struct {
|
||||
|
||||
@@ -22,9 +22,10 @@ type LlmConfigSuggestionRequest struct {
|
||||
}
|
||||
|
||||
type LlmConfigSuggestionResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||
ConfigExecution *AIConfigExecutionResponse `json:"configExecution,omitempty"`
|
||||
}
|
||||
|
||||
type AIInvocationUsageResponse struct {
|
||||
@@ -39,8 +40,11 @@ type AIConfigRecommendationResponse struct {
|
||||
Key string `json:"key"`
|
||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||
DiffSummary string `json:"diffSummary"`
|
||||
DiffID string `json:"diffId,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type AIConfigExecutionResponse struct {
|
||||
Status string `json:"status"`
|
||||
Job JobResponse `json:"job"`
|
||||
}
|
||||
|
||||
type AIInvocationSafeErrorResponse struct {
|
||||
@@ -57,6 +61,7 @@ type AIInvocationResponse struct {
|
||||
Status string `json:"status"`
|
||||
Recommendation string `json:"recommendation,omitempty"`
|
||||
ConfigRecommendation *AIConfigRecommendationResponse `json:"configRecommendation,omitempty"`
|
||||
ConfigExecution *AIConfigExecutionResponse `json:"configExecution,omitempty"`
|
||||
Usage AIInvocationUsageResponse `json:"usage"`
|
||||
Error *AIInvocationSafeErrorResponse `json:"error,omitempty"`
|
||||
}
|
||||
@@ -84,10 +89,12 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
||||
Key: response.ConfigRecommendation.Key,
|
||||
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
|
||||
DiffSummary: response.ConfigRecommendation.DiffSummary,
|
||||
DiffID: response.ConfigRecommendation.DiffID,
|
||||
ExpiresAt: response.ConfigRecommendation.ExpiresAt,
|
||||
}
|
||||
}
|
||||
var execution *AIConfigExecutionResponse
|
||||
if response.ConfigExecution != nil {
|
||||
execution = &AIConfigExecutionResponse{Status: response.ConfigExecution.Status, Job: JobFromDomain(response.ConfigExecution.Job)}
|
||||
}
|
||||
var safeError *AIInvocationSafeErrorResponse
|
||||
if response.Error != nil {
|
||||
safeError = &AIInvocationSafeErrorResponse{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||
@@ -100,6 +107,7 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
||||
Status: response.Status,
|
||||
Recommendation: response.Recommendation,
|
||||
ConfigRecommendation: config,
|
||||
ConfigExecution: execution,
|
||||
Usage: AIInvocationUsageResponse{
|
||||
ProviderID: response.Usage.ProviderID,
|
||||
Model: response.Usage.Model,
|
||||
|
||||
@@ -42,44 +42,6 @@ type PluginLifecycleActionResponse struct {
|
||||
Job JobResponse `json:"job"`
|
||||
}
|
||||
|
||||
type AIConfigDiffPreviewResponse struct {
|
||||
ID string `json:"id"`
|
||||
RequestID string `json:"requestId"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
ProviderID string `json:"providerId,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Key string `json:"key"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
CurrentConfigChecksum string `json:"currentConfigChecksum,omitempty"`
|
||||
ProposedConfig string `json:"proposedConfig,omitempty"`
|
||||
DiffSummary string `json:"diffSummary"`
|
||||
State string `json:"state"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ApprovedBy string `json:"approvedBy,omitempty"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty"`
|
||||
CancelledBy string `json:"cancelledBy,omitempty"`
|
||||
CancelledAt time.Time `json:"cancelledAt,omitempty"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AIConfigDiffListResponse struct {
|
||||
Items []AIConfigDiffPreviewResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalRequest struct {
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalResponse struct {
|
||||
Preview AIConfigDiffPreviewResponse `json:"preview"`
|
||||
Dispatch ServerConfigWriteDispatchResponse `json:"dispatch"`
|
||||
}
|
||||
|
||||
func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest {
|
||||
return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
@@ -101,25 +63,3 @@ func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) Plugin
|
||||
result = domain.CopyPluginLifecycleResult(result)
|
||||
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job)}
|
||||
}
|
||||
|
||||
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
||||
preview = domain.CopyAIConfigDiffPreview(preview)
|
||||
return AIConfigDiffPreviewResponse{ID: preview.ID, RequestID: preview.RequestID, CreatedBy: preview.CreatedBy, ServerInstanceID: preview.ServerInstanceID, PluginID: preview.PluginID, ProviderID: preview.ProviderID, Model: preview.Model, Key: preview.Key, ConfigVersion: preview.ConfigVersion, CurrentConfigChecksum: preview.CurrentConfigChecksum, ProposedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, State: string(preview.State), ExpiresAt: preview.ExpiresAt, ApprovedBy: preview.ApprovedBy, ApprovedAt: preview.ApprovedAt, CancelledBy: preview.CancelledBy, CancelledAt: preview.CancelledAt, JobID: preview.JobID, CreatedAt: preview.CreatedAt, UpdatedAt: preview.UpdatedAt}
|
||||
}
|
||||
|
||||
func AIConfigDiffListFromDomain(previews []domain.AIConfigDiffPreview) AIConfigDiffListResponse {
|
||||
items := make([]AIConfigDiffPreviewResponse, len(previews))
|
||||
for i, preview := range previews {
|
||||
items[i] = AIConfigDiffFromDomain(preview)
|
||||
}
|
||||
return AIConfigDiffListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func (request AIConfigDiffApprovalRequest) ToDomain(diffID string) domain.AIConfigDiffApprovalRequest {
|
||||
return domain.AIConfigDiffApprovalRequest{DiffID: diffID, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AIConfigDiffApprovalResponse {
|
||||
result = domain.CopyAIConfigDiffApprovalResult(result)
|
||||
return AIConfigDiffApprovalResponse{Preview: AIConfigDiffFromDomain(result.Preview), Dispatch: ServerConfigWriteDispatchFromDomain(result.Dispatch)}
|
||||
}
|
||||
|
||||
@@ -243,9 +243,8 @@ type GamePluginManifestServerBody struct {
|
||||
}
|
||||
|
||||
type GamePluginManifestAIBody struct {
|
||||
Purposes []string `json:"purposes,omitempty"`
|
||||
Mediation string `json:"mediation,omitempty"`
|
||||
ConfigWritePolicy string `json:"configWritePolicy,omitempty"`
|
||||
Purposes []string `json:"purposes,omitempty"`
|
||||
Mediation string `json:"mediation,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginProductionLifecycleBody struct {
|
||||
@@ -1206,7 +1205,7 @@ func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestS
|
||||
}
|
||||
|
||||
func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation, ConfigWritePolicy: ai.ConfigWritePolicy}
|
||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation}
|
||||
}
|
||||
|
||||
func (lifecycle GamePluginProductionLifecycleBody) ToDomain() domain.GamePluginProductionLifecycle {
|
||||
|
||||
@@ -14,7 +14,7 @@ Required model groups:
|
||||
- log streams and ingestion cursors.
|
||||
- operational events.
|
||||
- server-bound plugin lifecycle installations and linked jobs.
|
||||
- reviewable AI config diffs and approval fences.
|
||||
- AI config execution metadata is carried by durable jobs; retired recommendation records are no longer part of the active model or snapshot schema.
|
||||
|
||||
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
||||
# Durable Client Manager persistence
|
||||
|
||||
@@ -455,33 +455,6 @@ type PluginLifecycleInstallation struct {
|
||||
|
||||
func (PluginLifecycleInstallation) TableName() string { return "plugin_lifecycle_installations" }
|
||||
|
||||
type AIConfigDiff struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
RequestID string `json:"requestId" db:"request_id"`
|
||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
PluginID string `json:"pluginId,omitempty" db:"plugin_id"`
|
||||
ProviderID string `json:"providerId,omitempty" db:"provider_id"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
Key string `json:"key" db:"key"`
|
||||
ConfigVersion int `json:"configVersion" db:"config_version"`
|
||||
CurrentConfigChecksum string `json:"currentConfigChecksum" db:"current_config_checksum"`
|
||||
ProposedConfig string `json:"proposedConfig" db:"proposed_config"`
|
||||
DiffSummary string `json:"diffSummary" db:"diff_summary"`
|
||||
State domain.AIConfigDiffState `json:"state" db:"state"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
ApprovedBy string `json:"approvedBy,omitempty" db:"approved_by"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty" db:"approved_at"`
|
||||
ApprovalIdempotencyKey string `json:"approvalIdempotencyKey,omitempty" db:"approval_idempotency_key"`
|
||||
CancelledBy string `json:"cancelledBy,omitempty" db:"cancelled_by"`
|
||||
CancelledAt time.Time `json:"cancelledAt,omitempty" db:"cancelled_at"`
|
||||
JobID string `json:"jobId,omitempty" db:"job_id"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (AIConfigDiff) TableName() string { return "ai_config_diffs" }
|
||||
|
||||
func UserFromDomain(user domain.User) User {
|
||||
user = domain.CopyUser(user)
|
||||
return User{
|
||||
|
||||
@@ -18,7 +18,6 @@ func TestTableNames(t *testing.T) {
|
||||
Artifact{}.TableName(): "artifacts",
|
||||
LogStream{}.TableName(): "log_streams",
|
||||
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
||||
AIConfigDiff{}.TableName(): "ai_config_diffs",
|
||||
SCUMUser{}.TableName(): "scum_user",
|
||||
SCUMUserTrajectory{}.TableName(): "scum_user_trajectory",
|
||||
SCUMVehicle{}.TableName(): "scum_vehicle",
|
||||
|
||||
@@ -38,4 +38,4 @@ AI invocation responses must be bounded and must not include raw provider creden
|
||||
|
||||
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
|
||||
|
||||
Provider failures return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected.
|
||||
Provider failures return a stable safe error without URL, header, key, request-body secret, or stack details. A config suggestion response includes the proposed content plus `configExecution` metadata for the bounded `config.write` job that Platform dispatches immediately after the current config version/checksum, permissions, runtime binding, and content-safety checks pass. The response contains the job ID and target key so the caller can follow execution logs without receiving provider credentials.
|
||||
|
||||
@@ -53,7 +53,7 @@ A server instance is created from one installed game management plugin and is la
|
||||
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
|
||||
- `POST /api/v1/server-instances/{id}/restart` validates the instance is `running` or `stopped`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues the plugin-declared stop job recorded as a `restart` lifecycle operation.
|
||||
- `POST /api/v1/server-instances/{id}/update` validates the instance is `running`, `stopped`, `ready`, or `failed`, checks the expected config version, verifies the plugin install action and run endpoint `process.install` capability, and queues the plugin-declared install/update job recorded as an `update` lifecycle operation.
|
||||
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages.
|
||||
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses the platform-mediated direct dispatch response from `config.suggest`; the response carries typed `configExecution` job metadata without returning provider credentials to plugin pages.
|
||||
- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks.
|
||||
- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user.
|
||||
|
||||
@@ -61,7 +61,7 @@ Workflow route responses include the accepted action, bounded server instance me
|
||||
|
||||
Server metrics responses are bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys.
|
||||
|
||||
Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
|
||||
Config write dispatch and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
|
||||
|
||||
Marketplace state actions update only registry install state. They do not download packages, dispatch run jobs, execute plugin bridge code, write server files, expose package bytes, or contact external services. Package acquisition and runtime execution remain deferred to explicit future changes.
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ type StoreSnapshot struct {
|
||||
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
||||
Backups []domain.BackupRecord `json:"backups"`
|
||||
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
||||
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
|
||||
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
||||
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
|
||||
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
|
||||
@@ -152,10 +151,6 @@ func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return &persistentGameClientBridgeCommandRepository{
|
||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||
@@ -327,7 +322,6 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
@@ -368,7 +362,6 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||
|
||||
@@ -15,7 +15,6 @@ func normalizeStoreSnapshot(snapshot StoreSnapshot) StoreSnapshot {
|
||||
snapshot.ServerInstances = rewriteSnapshotServerInstances(snapshot.ServerInstances, replacements)
|
||||
snapshot.RuntimeBindings = rewriteSnapshotRuntimeBindings(snapshot.RuntimeBindings, replacements)
|
||||
snapshot.PluginLifecycles = rewriteSnapshotPluginLifecycles(snapshot.PluginLifecycles, replacements)
|
||||
snapshot.AIConfigDiffs = rewriteSnapshotAIConfigDiffs(snapshot.AIConfigDiffs, replacements)
|
||||
snapshot.PluginDataRecords = retainedSnapshotPluginData(snapshot.PluginDataRecords, replacements)
|
||||
return snapshot
|
||||
}
|
||||
@@ -81,17 +80,6 @@ func rewriteSnapshotPluginLifecycles(values []domain.PluginLifecycleInstallation
|
||||
return out
|
||||
}
|
||||
|
||||
func rewriteSnapshotAIConfigDiffs(values []domain.AIConfigDiffPreview, replacements map[string]domain.GamePlugin) []domain.AIConfigDiffPreview {
|
||||
out := make([]domain.AIConfigDiffPreview, len(values))
|
||||
for index, value := range values {
|
||||
if kept, ok := replacements[value.PluginID]; ok {
|
||||
value.PluginID = kept.ID
|
||||
}
|
||||
out[index] = domain.CopyAIConfigDiffPreview(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func retainedSnapshotPluginData(values []domain.PluginDataRecord, replacements map[string]domain.GamePlugin) []domain.PluginDataRecord {
|
||||
byID := map[string]domain.PluginDataRecord{}
|
||||
for _, value := range values {
|
||||
|
||||
@@ -132,10 +132,6 @@ func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return &persistentGameClientBridgeCommandRepository{
|
||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||
@@ -328,7 +324,6 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
@@ -370,7 +365,6 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||
|
||||
@@ -147,13 +147,6 @@ type PluginLifecycleRepository interface {
|
||||
Update(domain.PluginLifecycleInstallation) error
|
||||
}
|
||||
|
||||
type AIConfigDiffRepository interface {
|
||||
Create(domain.AIConfigDiffPreview) error
|
||||
Get(id string) (domain.AIConfigDiffPreview, error)
|
||||
List(domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||
Update(domain.AIConfigDiffPreview) error
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandRepository interface {
|
||||
Create(domain.GameClientBridgeCommand) error
|
||||
Get(id string) (domain.GameClientBridgeCommand, error)
|
||||
@@ -247,7 +240,6 @@ type Store interface {
|
||||
MetricSamples() MetricSampleRepository
|
||||
Backups() BackupRepository
|
||||
PluginLifecycles() PluginLifecycleRepository
|
||||
AIConfigDiffs() AIConfigDiffRepository
|
||||
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
||||
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
|
||||
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
|
||||
@@ -278,7 +270,6 @@ type MemoryStore struct {
|
||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||
@@ -378,11 +369,6 @@ func NewMemoryStore() *MemoryStore {
|
||||
domain.CopyPluginLifecycleInstallation,
|
||||
matchPluginLifecycle,
|
||||
),
|
||||
aiConfigDiffs: newMemoryRepository(
|
||||
func(preview domain.AIConfigDiffPreview) string { return preview.ID },
|
||||
domain.CopyAIConfigDiffPreview,
|
||||
matchAIConfigDiff,
|
||||
),
|
||||
bridgeCommands: newMemoryGameClientBridgeCommandRepository(),
|
||||
bridgeSnapshots: newMemoryGameClientBridgeSnapshotRepository(),
|
||||
bridgeStreams: newMemoryRepository(
|
||||
@@ -421,7 +407,6 @@ func (store *MemoryStore) Backups() BackupRepository { retu
|
||||
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
return store.pluginLifecycle
|
||||
}
|
||||
func (store *MemoryStore) AIConfigDiffs() AIConfigDiffRepository { return store.aiConfigDiffs }
|
||||
func (store *MemoryStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return store.bridgeCommands
|
||||
}
|
||||
@@ -722,12 +707,6 @@ func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filte
|
||||
(filter.CurrentState == "" || installation.CurrentState == filter.CurrentState)
|
||||
}
|
||||
|
||||
func matchAIConfigDiff(preview domain.AIConfigDiffPreview, filter domain.AIConfigDiffFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || preview.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || preview.PluginID == filter.PluginID) &&
|
||||
(filter.State == "" || preview.State == filter.State)
|
||||
}
|
||||
|
||||
func matchGameClientBridgeCommand(command domain.GameClientBridgeCommand, filter domain.GameClientBridgeCommandFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || command.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || command.PluginID == filter.PluginID) &&
|
||||
|
||||
@@ -435,19 +435,9 @@ func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
||||
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade",
|
||||
IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
||||
}
|
||||
diff := domain.AIConfigDiffPreview{
|
||||
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
||||
ServerInstanceID: "server-1", PluginID: "game.scum", ProviderID: "ai.openai", Model: "gpt-4.1",
|
||||
Key: "server.properties", ConfigVersion: 4, CurrentConfigChecksum: "sha256:" + strings.Repeat("a", 64),
|
||||
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
|
||||
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
|
||||
}
|
||||
if err := store.PluginLifecycles().Create(installation); err != nil {
|
||||
t.Fatalf("create plugin lifecycle: %v", err)
|
||||
}
|
||||
if err := store.AIConfigDiffs().Create(diff); err != nil {
|
||||
t.Fatalf("create AI config diff: %v", err)
|
||||
}
|
||||
|
||||
restarted, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
@@ -457,10 +447,6 @@ func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
||||
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
||||
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
||||
}
|
||||
gotDiff, diffErr := restarted.AIConfigDiffs().Get(diff.ID)
|
||||
if diffErr != nil || gotDiff.State != diff.State || gotDiff.CurrentConfigChecksum != diff.CurrentConfigChecksum || gotDiff.ProposedConfig != diff.ProposedConfig {
|
||||
t.Fatalf("unexpected durable AI config diff: diff=%+v err=%v", gotDiff, diffErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
@@ -24,7 +23,7 @@ func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AI
|
||||
if model == "" && len(provider.Models) > 0 {
|
||||
model = provider.Models[0]
|
||||
}
|
||||
recommendation := "Mock AI recommendation for " + request.Purpose + ": review the proposed change before dispatch."
|
||||
recommendation := "Mock AI recommendation for " + request.Purpose + ": configuration changes are dispatched automatically."
|
||||
result := domain.AIProviderInvocationResult{
|
||||
Recommendation: recommendation,
|
||||
Usage: domain.AIInvocationUsage{
|
||||
@@ -46,10 +45,12 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
var currentConfig domain.ServerConfig
|
||||
var hasCurrentConfig bool
|
||||
var err error
|
||||
if request.ServerInstanceID != "" {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
@@ -63,6 +64,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
currentConfig = config
|
||||
hasCurrentConfig = true
|
||||
request.CurrentConfig = config.Content
|
||||
}
|
||||
}
|
||||
@@ -111,16 +114,25 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
Usage: result.Usage,
|
||||
}
|
||||
if result.SuggestedConfig != "" {
|
||||
if request.ServerInstanceID == "" {
|
||||
if request.ServerInstanceID == "" || !hasCurrentConfig {
|
||||
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
||||
}
|
||||
idempotencyKey := aiConfigWriteIdempotencyKey(request.RequestID, request.ServerInstanceID)
|
||||
svc.pluginOperationsMu.Lock()
|
||||
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
|
||||
dispatch, dispatchErr := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
ExpectedConfigVersion: currentConfig.ConfigVersion,
|
||||
ExpectedChecksum: currentConfig.Checksum,
|
||||
Key: currentConfig.Key,
|
||||
ProposedContent: result.SuggestedConfig,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
svc.pluginOperationsMu.Unlock()
|
||||
if persistErr != nil {
|
||||
return domain.AIInvocationResponse{}, persistErr
|
||||
if dispatchErr != nil {
|
||||
return domain.AIInvocationResponse{}, dispatchErr
|
||||
}
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: currentConfig.Key, SuggestedConfig: result.SuggestedConfig, DiffSummary: "AI 配置建议已直接派发 config.write 任务"}
|
||||
response.ConfigExecution = &domain.AIConfigExecution{Status: dispatch.Status, Job: dispatch.Job}
|
||||
}
|
||||
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
@@ -179,7 +191,7 @@ func buildSuggestedConfig(currentConfig string, prompt string) string {
|
||||
if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") {
|
||||
base += "\npvp=false"
|
||||
}
|
||||
return base + "\n# ai.recommendation=review-required\n"
|
||||
return base + "\n# ai.recommendation=auto-applied\n"
|
||||
}
|
||||
|
||||
func boundedTokenEstimate(value string) int {
|
||||
|
||||
@@ -283,7 +283,7 @@ func boundedProviderPrompt(request domain.AIInvocationRequest) string {
|
||||
builder.WriteString(request.CurrentConfig)
|
||||
}
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes require separate operator approval.")
|
||||
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes are dispatched automatically as a bounded config.write job.")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@ import (
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const (
|
||||
aiConfigDiffTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
@@ -128,84 +124,6 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Status: "queued"}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previews, err := svc.store.AIConfigDiffs().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]domain.AIConfigDiffPreview, 0, len(previews))
|
||||
for _, preview := range previews {
|
||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||
if err == nil && canAccessServer(user, instance) {
|
||||
visible = append(visible, preview)
|
||||
}
|
||||
}
|
||||
sort.Slice(visible, func(i, j int) bool { return visible[i].CreatedAt.After(visible[j].CreatedAt) })
|
||||
return domain.CopyAIConfigDiffPreviews(visible), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error) {
|
||||
if err := validator.ValidateAIConfigDiffApprovalRequest(request); err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
svc.pluginOperationsMu.Lock()
|
||||
defer svc.pluginOperationsMu.Unlock()
|
||||
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) || (!isPlatformAdmin(user) && preview.CreatedBy != user.ID) {
|
||||
return domain.AIConfigDiffApprovalResult{}, ErrForbidden
|
||||
}
|
||||
if preview.State == domain.AIConfigDiffStateApproved {
|
||||
if preview.ApprovalIdempotencyKey != request.IdempotencyKey {
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is already approved with another idempotency key")
|
||||
}
|
||||
job, err := svc.store.Jobs().Get(preview.JobID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
dispatch := domain.ServerConfigWriteDispatch{Job: job, Status: "queued"}
|
||||
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||
}
|
||||
if preview.State != domain.AIConfigDiffStatePending {
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is not pending approval")
|
||||
}
|
||||
if !preview.ExpiresAt.After(svc.now()) {
|
||||
preview.State = domain.AIConfigDiffStateExpired
|
||||
preview.UpdatedAt = svc.now()
|
||||
_ = svc.store.AIConfigDiffs().Update(preview)
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff has expired")
|
||||
}
|
||||
dispatch, err := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{ServerInstanceID: preview.ServerInstanceID, ExpectedConfigVersion: preview.ConfigVersion, ExpectedChecksum: preview.CurrentConfigChecksum, Key: preview.Key, ProposedContent: preview.ProposedConfig, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
preview.State = domain.AIConfigDiffStateApproved
|
||||
preview.ApprovedBy = user.ID
|
||||
preview.ApprovedAt = stamp
|
||||
preview.ApprovalIdempotencyKey = request.IdempotencyKey
|
||||
preview.JobID = dispatch.Job.ID
|
||||
preview.UpdatedAt = stamp
|
||||
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectPluginOperationsJobResult(job domain.Job, stamp time.Time) error {
|
||||
if !strings.HasPrefix(job.ID, "job-plugin-lifecycle-") || job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
|
||||
return nil
|
||||
@@ -230,63 +148,6 @@ func (svc *CoreService) projectPluginOperationsJobResult(job domain.Job, stamp t
|
||||
return svc.store.PluginLifecycles().Update(installation)
|
||||
}
|
||||
|
||||
func (svc *CoreService) persistAIConfigDiff(actorID string, provider domain.AIProvider, request domain.AIInvocationRequest, result domain.AIProviderInvocationResult) (domain.AIConfigDiffPreview, error) {
|
||||
config, err := svc.getServerConfigForUser(actorID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
preview := domain.AIConfigDiffPreview{ID: aiConfigDiffID(request.RequestID, request.ServerInstanceID), RequestID: request.RequestID, CreatedBy: actorID, ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, ProviderID: provider.ID, Model: result.Usage.Model, Key: config.Key, ConfigVersion: config.ConfigVersion, CurrentConfigChecksum: config.Checksum, ProposedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch", State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(aiConfigDiffTTL), CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := validator.ValidateAIConfigDiffPreview(preview); err != nil {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
if err := svc.store.AIConfigDiffs().Create(preview); err != nil {
|
||||
if !errors.Is(err, repo.ErrDuplicate) {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
existing, getErr := svc.store.AIConfigDiffs().Get(preview.ID)
|
||||
if getErr != nil {
|
||||
return domain.AIConfigDiffPreview{}, getErr
|
||||
}
|
||||
if existing.CreatedBy != actorID || existing.ServerInstanceID != request.ServerInstanceID || existing.PluginID != request.PluginID || existing.ProposedConfig != result.SuggestedConfig {
|
||||
return domain.AIConfigDiffPreview{}, validationError("requestId is already used for a different AI config recommendation")
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string) (domain.ServerConfig, error) {
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerConfig{}, err
|
||||
}
|
||||
user, err := svc.store.Users().Get(userID)
|
||||
if err != nil {
|
||||
return domain.ServerConfig{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return domain.ServerConfig{}, ErrForbidden
|
||||
}
|
||||
config := domain.ServerConfig{ServerInstanceID: instance.ID, ConfigVersion: instance.ConfigVersion, Format: "properties", Key: instance.ConfigKey, Content: instance.ConfigContent, Checksum: instance.ConfigChecksum, Source: "platform-derived", UpdatedAt: instance.ConfigUpdatedAt}
|
||||
if config.ConfigVersion <= 0 {
|
||||
config.ConfigVersion = 1
|
||||
}
|
||||
if config.Key == "" {
|
||||
config.Key = "server.properties"
|
||||
}
|
||||
if config.Content == "" {
|
||||
config.Content = buildLogicalServerConfig(instance)
|
||||
}
|
||||
if config.Checksum == "" {
|
||||
config.Checksum = validator.BytesChecksum([]byte(config.Content))
|
||||
}
|
||||
if config.UpdatedAt.IsZero() {
|
||||
config.UpdatedAt = svc.now()
|
||||
}
|
||||
return config, validator.ValidateServerConfig(config)
|
||||
}
|
||||
|
||||
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||
svc.pluginOperationsMu.Lock()
|
||||
defer svc.pluginOperationsMu.Unlock()
|
||||
@@ -436,7 +297,7 @@ func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
|
||||
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func aiConfigDiffID(requestID, serverInstanceID string) string {
|
||||
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
|
||||
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
|
||||
func aiConfigWriteIdempotencyKey(requestID, serverInstanceID string) string {
|
||||
sum := sha256.Sum256([]byte("ai-config-write\x00" + requestID + "\x00" + serverInstanceID))
|
||||
return "ai-config-write-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
|
||||
func TestAIConfigRecommendationDispatchesConfigWriteImmediately(t *testing.T) {
|
||||
svc, session, instance := newPluginOperationsFixture(t)
|
||||
provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"})
|
||||
if err != nil {
|
||||
@@ -82,43 +82,19 @@ func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testin
|
||||
if err != nil {
|
||||
t.Fatalf("invoke AI: %v", err)
|
||||
}
|
||||
if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" {
|
||||
t.Fatalf("expected persisted config recommendation, got %+v", response)
|
||||
if response.ConfigRecommendation == nil || response.ConfigExecution == nil || response.ConfigExecution.Job.ID == "" {
|
||||
t.Fatalf("expected direct config write dispatch, got %+v", response)
|
||||
}
|
||||
if response.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite || response.ConfigExecution.Status != "queued" {
|
||||
t.Fatalf("unexpected AI config execution: %+v", response.ConfigExecution)
|
||||
}
|
||||
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs)
|
||||
if len(jobs) != 1 || jobs[0].ID != response.ConfigExecution.Job.ID {
|
||||
t.Fatalf("AI recommendation must dispatch exactly one config write job: %+v", jobs)
|
||||
}
|
||||
approved, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("approve AI diff: %v", err)
|
||||
}
|
||||
if approved.Preview.State != domain.AIConfigDiffStateApproved || approved.Dispatch.Job.ID == "" {
|
||||
t.Fatalf("expected approved diff and queued job, got %+v", approved)
|
||||
}
|
||||
repeated, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||
if err != nil || repeated.Dispatch.Job.ID != approved.Dispatch.Job.ID {
|
||||
t.Fatalf("repeat approval must return original job: %+v err=%v", repeated, err)
|
||||
}
|
||||
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("approval must dispatch exactly one job, got %+v", jobs)
|
||||
}
|
||||
|
||||
staleResponse, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-stale", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke stale AI candidate: %v", err)
|
||||
}
|
||||
stored, err := svc.store.ServerInstances().Get(instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get server: %v", err)
|
||||
}
|
||||
stored.ConfigVersion++
|
||||
if err := svc.store.ServerInstances().Update(stored); err != nil {
|
||||
t.Fatalf("advance config revision: %v", err)
|
||||
}
|
||||
if _, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: staleResponse.ConfigRecommendation.DiffID, IdempotencyKey: "approve-stale"}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
|
||||
t.Fatalf("expected stale revision rejection, got %v", err)
|
||||
repeated, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-1", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||
if err != nil || repeated.ConfigExecution == nil || repeated.ConfigExecution.Job.ID != response.ConfigExecution.Job.ID {
|
||||
t.Fatalf("repeat AI request must reuse the idempotent job: %+v err=%v", repeated, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,8 +116,6 @@ type Core interface {
|
||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
||||
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
|
||||
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||
ApproveAIConfigDiffForSession(string, domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error)
|
||||
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
|
||||
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
|
||||
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
|
||||
@@ -912,17 +910,6 @@ func (svc *CoreService) replaceGamePluginReference(fromPluginID, toPluginID, toP
|
||||
return err
|
||||
}
|
||||
}
|
||||
previews, err := svc.store.AIConfigDiffs().List(domain.AIConfigDiffFilter{PluginID: fromPluginID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, preview := range previews {
|
||||
preview.PluginID = toPluginID
|
||||
preview.UpdatedAt = stamp
|
||||
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return svc.replacePluginDataReferences(fromPluginID, toPluginID)
|
||||
}
|
||||
|
||||
@@ -1215,9 +1202,14 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
||||
if response.ConfigRecommendation != nil {
|
||||
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
|
||||
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
|
||||
base.Result["diffId"] = response.ConfigRecommendation.DiffID
|
||||
base.Result["key"] = response.ConfigRecommendation.Key
|
||||
base.Result["expiresAt"] = response.ConfigRecommendation.ExpiresAt
|
||||
}
|
||||
if response.ConfigExecution != nil {
|
||||
base.Result["executionStatus"] = response.ConfigExecution.Status
|
||||
base.Result["jobId"] = response.ConfigExecution.Job.ID
|
||||
base.Result["capability"] = response.ConfigExecution.Job.Capability
|
||||
base.Result["targetKey"] = response.ConfigExecution.Job.TargetKey
|
||||
base.Result["inputRef"] = response.ConfigExecution.Job.InputRef
|
||||
}
|
||||
if response.Error != nil {
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||
|
||||
@@ -2448,7 +2448,7 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform"},
|
||||
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
|
||||
@@ -44,42 +44,6 @@ func ValidatePluginLifecycleRequest(request domain.PluginLifecycleRequest) error
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAIConfigDiffPreview(preview domain.AIConfigDiffPreview) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", preview.ID)
|
||||
violations = appendRequired(violations, "requestId", preview.RequestID)
|
||||
violations = appendRequired(violations, "createdBy", preview.CreatedBy)
|
||||
violations = appendRequired(violations, "serverInstanceId", preview.ServerInstanceID)
|
||||
violations = appendRequired(violations, "key", preview.Key)
|
||||
violations = appendRequired(violations, "diffSummary", preview.DiffSummary)
|
||||
if preview.ConfigVersion <= 0 {
|
||||
violations = append(violations, "configVersion must be positive")
|
||||
}
|
||||
if !validAIConfigDiffState(preview.State) {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
if !validLogicalFileKey(preview.Key) || !validConfigFileKey(preview.Key) {
|
||||
violations = append(violations, "key is invalid")
|
||||
}
|
||||
if len([]byte(preview.ProposedConfig)) > maxServerConfigContentSize || containsUnsafeRuntimeSecret(preview.ProposedConfig) || looksLikeRawHostPath(preview.ProposedConfig) {
|
||||
violations = append(violations, "proposedConfig is unsafe")
|
||||
}
|
||||
if len(preview.DiffSummary) > maxProductionMessageLength || unsafeProductionText(preview.DiffSummary) {
|
||||
violations = append(violations, "diffSummary is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "diffId", request.DiffID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if unsafeProductionText(request.DiffID) || unsafeProductionText(request.IdempotencyKey) {
|
||||
violations = append(violations, "AI config approval request is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
||||
switch state {
|
||||
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
||||
@@ -98,15 +62,6 @@ func validPluginLifecycleOperation(operation domain.PluginLifecycleOperation) bo
|
||||
}
|
||||
}
|
||||
|
||||
func validAIConfigDiffState(state domain.AIConfigDiffState) bool {
|
||||
switch state {
|
||||
case domain.AIConfigDiffStatePending, domain.AIConfigDiffStateApproved, domain.AIConfigDiffStateCancelled, domain.AIConfigDiffStateExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func unsafeProductionText(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
return containsUnsafeRuntimeSecret(value) ||
|
||||
|
||||
@@ -231,9 +231,6 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
if manifest.AI.Mediation != "platform" {
|
||||
violations = append(violations, "manifest.ai.mediation must be platform")
|
||||
}
|
||||
if manifest.AI.ConfigWritePolicy != "review-required" {
|
||||
violations = append(violations, "manifest.ai.configWritePolicy must be review-required")
|
||||
}
|
||||
}
|
||||
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
||||
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
||||
@@ -2123,7 +2120,7 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f
|
||||
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
||||
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
||||
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
||||
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "ai.configWritePolicy", value: manifest.AI.ConfigWritePolicy}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
||||
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
||||
values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations)
|
||||
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
||||
|
||||
@@ -410,7 +410,7 @@ func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration
|
||||
Pages: []domain.GamePluginPage{
|
||||
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
||||
},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform"},
|
||||
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user