feat: reveal saved server deployment inputs

This commit is contained in:
npc0-hue
2026-07-27 09:24:44 +08:00
parent e5c94be1db
commit 6c5b74b915
19 changed files with 345 additions and 31 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-26
@@ -0,0 +1,40 @@
## Context
Platform persists each server's deployment definition, including host paths and lifecycle commands. The normal deployment endpoint intentionally exposes only configured flags, so the editor starts these fields blank. This protects operational data in ordinary reads but prevents an authorized operator from comparing the saved definition with Run behavior.
## Goals / Non-Goals
**Goals:**
- Let an authorized server manager explicitly retrieve and inspect that server's saved deployment inputs.
- Make the editor visibly require this explicit action before it receives raw values.
- Preserve redaction on all existing read projections.
- Expose a safe indication of the latest lifecycle job's deployed revision and whether its leased input contained a deployment definition.
**Non-Goals:**
- No host browsing, direct Run connection, log changes, secret exposure, or changes to the Run protocol.
- No display of runtime-binding credentials.
- No attempt to infer what a previous Run process actually executed from Platform alone.
## Decisions
1. Add a separate authenticated `GET /server-instances/{id}/deployment/reveal` endpoint rather than expanding the existing deployment read response. This makes the security-sensitive action explicit and preserves compatibility for all existing consumers.
2. Reuse server-owner authorization and return only the persisted path/command fields plus non-sensitive deployment metadata. Runtime bindings are excluded because they can contain credential references or secrets.
3. Add a reveal button to the edit workflow. It calls the endpoint only after operator intent, replaces the local blank fields, and provides a clear success/error state. A plain close/reopen returns to the normal redacted form.
4. Derive dispatch diagnostics from the current server's lifecycle jobs. It reports revision and deployment-input presence, not the leased fields themselves. Platform can prove a job was queued with the stored revision; only Run's own safe job result can prove successful execution.
## Risks / Trade-offs
- [An authorized browser session can now read operational paths/commands] → The sensitive response requires explicit owner-authorized access, is not cached in existing list/detail endpoints, and the UI fetches it only after an intentional action.
- [Operators could confuse queued dispatch with successful execution] → Label the diagnostic as dispatch evidence and separately retain Run job state/progress.
- [Sensitive values could linger in a browser tab] → Keep them only in the open workflow state and clear them when the dialog closes.
## Migration Plan
1. Deploy the additive endpoint and frontend reveal action.
2. Roll back by removing the reveal route/UI; existing saved deployment definitions and normal redacted reads remain unchanged.
## Open Questions
- None. The requested scope is explicit authorized display of existing deployment values.
@@ -0,0 +1,27 @@
## Why
Operators cannot inspect the exact protected deployment paths and commands that Platform will send to Run. The current configured-only editor makes it impossible to verify an existing deployment or investigate whether Run used the intended inputs.
## What Changes
- Add an authorized, explicit read path for a server manager to reveal that server's saved deployment inputs.
- Prefill the deployment editor from this reveal path only after the operator deliberately asks to show the configuration.
- Keep ordinary deployment reads, server lists, job summaries, logs, audits, and plugin-facing reads redacted.
- Add a safe deployment-input diagnostic that proves whether the Platform job dispatched to Run carried the saved deployment revision without putting raw values into job/status views.
## Capabilities
### New Capabilities
- `server-deployment-input-reveal`: Authorized, explicit inspection of a server's saved deployment inputs and their Run-dispatch state.
### Modified Capabilities
- None.
## Impact
- Affected API: server deployment routes and DTOs.
- Affected platform code: deployment service, repository-backed deployment data, lifecycle dispatch diagnostics, and API tests.
- Affected frontend: the shared server deployment workflow and API types/client.
- Affected external integration: Run job-input verification remains based on the existing leased deployment contract; no Run source or protocol expansion is required.
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Authorized deployment input reveal
The Platform SHALL provide an explicit authenticated read operation that lets an authorized server manager retrieve the saved server root, working directory, install command, start command, stop command, and status command for one server deployment. The operation MUST NOT return runtime bindings.
#### Scenario: Owner reveals a custom deployment
- **WHEN** an authorized server manager explicitly requests deployment input reveal for a stopped server with saved custom commands
- **THEN** the Platform returns the saved deployment paths and commands for that server only
#### Scenario: Unauthorized user requests reveal
- **WHEN** a user without access to the server requests deployment input reveal
- **THEN** the Platform rejects the request and returns no deployment input
### Requirement: Default deployment views remain redacted
The existing server deployment read endpoint and all list, detail, job, audit, log, and plugin-facing projections SHALL remain redacted after reveal support is added.
#### Scenario: Normal deployment read after reveal support
- **WHEN** an authorized user reads a configured deployment through the existing deployment endpoint
- **THEN** the response indicates configured state without returning paths or commands
### Requirement: Editor reveal is deliberate and bounded
The deployment editor SHALL start with protected inputs redacted and SHALL fetch saved path and command values only after the operator selects its explicit reveal control. Closing the editor MUST discard revealed values from its local form state.
#### Scenario: Operator opens editor without revealing
- **WHEN** an operator opens an existing deployment editor
- **THEN** protected input fields remain blank and show their configured state
#### Scenario: Operator explicitly reveals inputs
- **WHEN** an operator selects the reveal control in the open deployment editor
- **THEN** the editor displays the returned saved paths and commands for inspection and editing
### Requirement: Dispatch diagnostics distinguish Platform dispatch from Run execution
The deployment view SHALL expose safe diagnostic metadata for the most recent lifecycle job: job identifier, job state, deployed revision, and whether the leased input included a deployment definition. It MUST NOT expose the leased values.
#### Scenario: Platform queued a deployment definition
- **WHEN** Platform creates a lifecycle job from a saved deployment definition
- **THEN** the deployment diagnostic identifies the job revision and that the job included a deployment definition without returning its paths or commands
@@ -0,0 +1,22 @@
## 任务提示
- 正向提示词:为“服务器管理”提供已授权管理者主动查看和编辑已保存部署路径/命令的能力,并显示不泄露原文的 Platform→Run 调度证据。成功标准是显式展示可用、常规读取仍脱敏、测试可证明授权和调度行为。
- 方向提示词:在 `platform/` 增加受限 reveal DTO、路由、服务和测试;在 `platform_web/` 的既有 `ServerDeploymentWorkflow` 内增加显式展示控件,复用现有控制台样式。验证运行 Go/前端测试、`openspec validate --strict``scripts/check-structure.sh`
- 任务边界:不修改独立 `run/` 源码或协议;不增加 SSH、主机浏览、日志/审计原文泄露、插件访问或运行绑定凭据展示;不触及未相关根目录或既有用户改动。
## 1. Platform reveal and dispatch diagnostics
- [x] 1.1 Define reveal and safe dispatch-diagnostic domain/DTO contracts, then add the authorized reveal endpoint without changing existing redacted views.
- [x] 1.2 Derive safe latest-lifecycle-job dispatch evidence from the stored job record and include it in the normal deployment view.
- [x] 1.3 Add service/API tests for owner reveal, unauthorized denial, existing-view redaction, and dispatched deployment evidence.
## 2. Deployment editor reveal
- [x] 2.1 Add API client/types for the explicit reveal operation and safe dispatch diagnostic.
- [x] 2.2 Add an intentional reveal control to the shared deployment editor, populate returned fields, and clear the displayed values when it closes.
- [x] 2.3 Add frontend tests that retain the normal redacted opening state and cover explicit reveal behavior.
## 3. Verification
- [x] 3.1 Run focused backend and frontend validation, strict OpenSpec validation, and the repository structure check.
- [x] 3.2 Inspect the active Platform metadata and lifecycle-job evidence to report whether Run received the saved deployment definition without exposing raw values in the report.
+1
View File
@@ -68,6 +68,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus)
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
mux.HandleFunc("/api/v1/server-instances/{id}/deployment/reveal", h.serverDeploymentReveal)
mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment)
mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy)
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
+30
View File
@@ -719,6 +719,36 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation)
}
func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
endpoint := validRunEndpointRequest()
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "deployment-reveal", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Reveal", IdempotencyKey: "deployment-reveal", ProfileKey: "local", Deployment: dto.ServerDeploymentRequest{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/reveal", WorkingDirectory: "/srv/reveal", StartCommand: "./start-server"}}, adminSession)
redactedRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment", "", adminSession)
assertStatus(t, redactedRecorder, http.StatusOK)
if body := redactedRecorder.Body.String(); strings.Contains(body, "/srv/reveal") || strings.Contains(body, "./start-server") {
t.Fatalf("normal deployment view leaked protected inputs: %s", body)
}
redacted := decodeBody[dto.ServerDeploymentResponse](t, redactedRecorder)
if redacted.LatestDispatch == nil || !redacted.LatestDispatch.DeploymentDefinitionIncluded || redacted.LatestDispatch.JobID != created.Job.ID || redacted.LatestDispatch.DeploymentRevision != 1 {
t.Fatalf("expected safe deployment dispatch evidence, got %+v", redacted.LatestDispatch)
}
revealed := getJSONWithAuth[dto.ServerDeploymentRevealResponse](t, router, "/api/v1/server-instances/deployment-reveal/deployment/reveal", adminSession)
if revealed.ServerRoot != "/srv/reveal" || revealed.WorkingDirectory != "/srv/reveal" || revealed.StartCommand != "./start-server" || revealed.InstallCommand != "" {
t.Fatalf("unexpected explicitly revealed deployment: %+v", revealed)
}
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "deployment-other", DisplayName: "Other", Email: "deployment-other@example.test", Roles: []string{"server-owner"}, Password: "other-password"}, adminSession)
other := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "deployment-other@example.test", Password: "other-password"})
denied := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment/reveal", "", other.SessionID)
assertErrorResponse(t, denied, http.StatusForbidden, errorCodeForbidden)
}
func TestServerInstanceManagementAPI(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
+15
View File
@@ -37,6 +37,21 @@ func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *ht
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
// serverDeploymentReveal explicitly returns saved deployment paths and commands
// to an authorized server manager. Ordinary deployment views remain redacted.
func (h *coreHandlers) serverDeploymentReveal(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
reveal, err := h.core.RevealServerDeploymentForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerDeploymentRevealFromDomain(reveal))
}
// serverDeployment reads safe deployment metadata or updates protected deployment input.
func (h *coreHandlers) serverDeployment(w http.ResponseWriter, r *http.Request) {
switch r.Method {
+26
View File
@@ -865,6 +865,28 @@ type ServerDeploymentView struct {
Revision int
UpdatedAt time.Time
Projection ServerDeploymentProjection
LatestDispatch *ServerDeploymentDispatchEvidence
}
// ServerDeploymentReveal contains an explicitly requested operator view of
// persisted execution inputs. Runtime bindings are intentionally excluded.
type ServerDeploymentReveal struct {
ServerInstanceID string
ServerRoot string
WorkingDirectory string
InstallCommand string
StartCommand string
StopCommand string
StatusCommand string
}
// ServerDeploymentDispatchEvidence proves what Platform placed into the most
// recent lifecycle job without exposing its protected contents.
type ServerDeploymentDispatchEvidence struct {
JobID string
JobState JobState
DeploymentRevision int
DeploymentDefinitionIncluded bool
}
type ConfigDiffLine struct {
@@ -1790,6 +1812,10 @@ func CopyServerDeploymentUpdate(update ServerDeploymentUpdate) ServerDeploymentU
func CopyServerDeploymentView(view ServerDeploymentView) ServerDeploymentView {
view.CreateInputs = CopyStringMap(view.CreateInputs)
if view.LatestDispatch != nil {
copy := *view.LatestDispatch
view.LatestDispatch = &copy
}
return view
}
+44 -15
View File
@@ -22,20 +22,38 @@ type ServerDeploymentRequest struct {
}
type ServerDeploymentResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
Mode domain.ServerDeploymentMode `json:"mode,omitempty"`
ProfileKey string `json:"profileKey,omitempty"`
CreateInputs map[string]string `json:"createInputs,omitempty"`
ServerRootConfigured bool `json:"serverRootConfigured"`
WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"`
InstallCommandConfigured bool `json:"installCommandConfigured"`
StartCommandConfigured bool `json:"startCommandConfigured"`
StopCommandConfigured bool `json:"stopCommandConfigured"`
StatusCommandConfigured bool `json:"statusCommandConfigured"`
Shell domain.ServerCommandShell `json:"shell,omitempty"`
Revision int `json:"revision"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Projection ServerDeploymentProjectionBody `json:"projection,omitempty"`
ServerInstanceID string `json:"serverInstanceId"`
Mode domain.ServerDeploymentMode `json:"mode,omitempty"`
ProfileKey string `json:"profileKey,omitempty"`
CreateInputs map[string]string `json:"createInputs,omitempty"`
ServerRootConfigured bool `json:"serverRootConfigured"`
WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"`
InstallCommandConfigured bool `json:"installCommandConfigured"`
StartCommandConfigured bool `json:"startCommandConfigured"`
StopCommandConfigured bool `json:"stopCommandConfigured"`
StatusCommandConfigured bool `json:"statusCommandConfigured"`
Shell domain.ServerCommandShell `json:"shell,omitempty"`
Revision int `json:"revision"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Projection ServerDeploymentProjectionBody `json:"projection,omitempty"`
LatestDispatch *ServerDeploymentDispatchEvidenceBody `json:"latestDispatch,omitempty"`
}
type ServerDeploymentRevealResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
ServerRoot string `json:"serverRoot"`
WorkingDirectory string `json:"workingDirectory"`
InstallCommand string `json:"installCommand"`
StartCommand string `json:"startCommand"`
StopCommand string `json:"stopCommand"`
StatusCommand string `json:"statusCommand"`
}
type ServerDeploymentDispatchEvidenceBody struct {
JobID string `json:"jobId"`
JobState domain.JobState `json:"jobState"`
DeploymentRevision int `json:"deploymentRevision"`
DeploymentDefinitionIncluded bool `json:"deploymentDefinitionIncluded"`
}
type ServerDeploymentProjectionBody struct {
@@ -104,7 +122,18 @@ func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeplo
}
func ServerDeploymentFromDomain(view domain.ServerDeploymentView) ServerDeploymentResponse {
return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt), Projection: deploymentProjectionFromDomain(view.Projection)}
return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt), Projection: deploymentProjectionFromDomain(view.Projection), LatestDispatch: deploymentDispatchEvidenceFromDomain(view.LatestDispatch)}
}
func ServerDeploymentRevealFromDomain(reveal domain.ServerDeploymentReveal) ServerDeploymentRevealResponse {
return ServerDeploymentRevealResponse{ServerInstanceID: reveal.ServerInstanceID, ServerRoot: reveal.ServerRoot, WorkingDirectory: reveal.WorkingDirectory, InstallCommand: reveal.InstallCommand, StartCommand: reveal.StartCommand, StopCommand: reveal.StopCommand, StatusCommand: reveal.StatusCommand}
}
func deploymentDispatchEvidenceFromDomain(evidence *domain.ServerDeploymentDispatchEvidence) *ServerDeploymentDispatchEvidenceBody {
if evidence == nil {
return nil
}
return &ServerDeploymentDispatchEvidenceBody{JobID: evidence.JobID, JobState: evidence.JobState, DeploymentRevision: evidence.DeploymentRevision, DeploymentDefinitionIncluded: evidence.DeploymentDefinitionIncluded}
}
func deploymentProjectionFromDomain(projection domain.ServerDeploymentProjection) ServerDeploymentProjectionBody {
+1
View File
@@ -86,6 +86,7 @@ type Core interface {
CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error)
CreateServerInstanceWorkflowForSession(string, domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error)
GetServerDeploymentForSession(string, string) (domain.ServerDeploymentView, error)
RevealServerDeploymentForSession(string, string) (domain.ServerDeploymentReveal, error)
UpdateServerDeploymentForSession(string, string, domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error)
DeployServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
StartServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
+28 -5
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
@@ -17,7 +18,16 @@ func (svc *CoreService) GetServerDeploymentForSession(sessionID, serverInstanceI
if err != nil {
return domain.ServerDeploymentView{}, err
}
return deploymentView(instance), nil
return svc.deploymentView(instance)
}
func (svc *CoreService) RevealServerDeploymentForSession(sessionID, serverInstanceID string) (domain.ServerDeploymentReveal, error) {
_, instance, err := svc.requireServerOwner(sessionID, serverInstanceID)
if err != nil {
return domain.ServerDeploymentReveal{}, err
}
definition := instance.Deployment
return domain.ServerDeploymentReveal{ServerInstanceID: instance.ID, ServerRoot: definition.ServerRoot, WorkingDirectory: definition.WorkingDirectory, InstallCommand: definition.InstallCommand, StartCommand: definition.StartCommand, StopCommand: definition.StopCommand, StatusCommand: definition.StatusCommand}, nil
}
func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstanceID string, update domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error) {
@@ -70,7 +80,7 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerDeploymentView{}, err
}
return deploymentView(instance), nil
return svc.deploymentView(instance)
}
func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
@@ -216,11 +226,24 @@ func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update
return definition
}
func deploymentView(instance domain.ServerInstance) domain.ServerDeploymentView {
func (svc *CoreService) deploymentView(instance domain.ServerInstance) (domain.ServerDeploymentView, error) {
definition := instance.Deployment
return domain.CopyServerDeploymentView(domain.ServerDeploymentView{
view := domain.ServerDeploymentView{
ServerInstanceID: instance.ID, Mode: definition.Mode, ProfileKey: definition.ProfileKey, CreateInputs: domain.CopyStringMap(definition.CreateInputs),
ServerRootConfigured: definition.ServerRoot != "", WorkingDirectoryConfigured: definition.WorkingDirectory != "", InstallCommandConfigured: definition.InstallCommand != "", StartCommandConfigured: definition.StartCommand != "", StopCommandConfigured: definition.StopCommand != "", StatusCommandConfigured: definition.StatusCommand != "", Shell: definition.Shell, Revision: definition.Revision, UpdatedAt: definition.UpdatedAt,
Projection: instance.DeploymentProjection,
})
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.ServerDeploymentView{}, err
}
var latestDispatchAt time.Time
for _, job := range jobs {
if job.ExecutionInput.Deployment == nil || (view.LatestDispatch != nil && !job.CreatedAt.After(latestDispatchAt)) {
continue
}
view.LatestDispatch = &domain.ServerDeploymentDispatchEvidence{JobID: job.ID, JobState: job.State, DeploymentRevision: job.ExecutionInput.Deployment.Revision, DeploymentDefinitionIncluded: true}
latestDispatchAt = job.CreatedAt
}
return domain.CopyServerDeploymentView(view), nil
}
@@ -32,6 +32,10 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
if strings.Contains(strings.Join([]string{view.ServerInstanceID, string(view.Mode), view.ProfileKey}, " "), "/srv/") {
t.Fatalf("redacted deployment view leaked host path: %+v", view)
}
revealed, err := svc.RevealServerDeploymentForSession(ownerSession, draft.Instance.ID)
if err != nil || revealed.ServerRoot != "/srv/venv-server" || revealed.WorkingDirectory != "/srv/venv-server" || revealed.StartCommand != "/srv/venv-server/.venv/bin/python server.py" {
t.Fatalf("expected explicit deployment reveal, reveal=%+v err=%v", revealed, err)
}
if _, err := svc.UpdateServerDeploymentForSession(ownerSession, draft.Instance.ID, domain.ServerDeploymentUpdate{RunEndpointID: "run-local", Mode: domain.ServerDeploymentModeCustom}); err != nil {
t.Fatalf("bind draft to run: %v", err)
@@ -55,4 +59,8 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
if deployed.Job.ExecutionInput.Deployment == nil || deployed.Job.ExecutionInput.Deployment.StartCommand != "/srv/venv-server/.venv/bin/python server.py" || deployed.Job.Progress.Phase != "queued" {
t.Fatalf("Run job must carry protected plan and queued phase: %+v", deployed.Job)
}
view, err = svc.GetServerDeploymentForSession(ownerSession, draft.Instance.ID)
if err != nil || view.LatestDispatch == nil || view.LatestDispatch.JobID != deployed.Job.ID || view.LatestDispatch.DeploymentRevision != deployed.Job.ExecutionInput.Deployment.Revision || !view.LatestDispatch.DeploymentDefinitionIncluded {
t.Fatalf("expected safe dispatch evidence, view=%+v err=%v", view, err)
}
}
+6 -1
View File
@@ -89,6 +89,7 @@ import type {
ServerLifecycleCreateRequest,
ServerLifecycleResponse,
ServerDeploymentRequest,
ServerDeploymentRevealResponse,
ServerDeploymentResponse,
ServerConfigWriteApprovalRequest,
ServerConfigWriteDispatchResponse,
@@ -188,7 +189,11 @@ export class PlatformApiClient {
}
async getServerDeployment(id: string): Promise<ServerDeploymentResponse> {
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`);
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`);
}
async revealServerDeployment(id: string): Promise<ServerDeploymentRevealResponse> {
return this.request<ServerDeploymentRevealResponse>(`/server-instances/${encodeURIComponent(id)}/deployment/reveal`);
}
async updateServerDeployment(id: string, request: ServerDeploymentRequest): Promise<ServerDeploymentResponse> {
+19 -1
View File
@@ -528,7 +528,25 @@ export interface ServerDeploymentResponse {
shell?: ServerCommandShell;
revision: number;
updatedAt?: string;
projection?: ServerDeploymentProjectionResponse;
projection?: ServerDeploymentProjectionResponse;
latestDispatch?: ServerDeploymentDispatchEvidenceResponse;
}
export interface ServerDeploymentRevealResponse {
serverInstanceId: string;
serverRoot: string;
workingDirectory: string;
installCommand: string;
startCommand: string;
stopCommand: string;
statusCommand: string;
}
export interface ServerDeploymentDispatchEvidenceResponse {
jobId: string;
jobState: JobState;
deploymentRevision: number;
deploymentDefinitionIncluded: boolean;
}
export interface RuntimeBindingUpdateRequest {
@@ -1,7 +1,7 @@
import { CheckCircle2, CircleDashed, Compass, Download, FolderCog, HeartPulse, Rocket, ScanSearch, ServerCog, SlidersHorizontal } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse } from "../api/types";
import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse, ServerDeploymentRevealResponse } from "../api/types";
import { ManagementDialog } from "./OperationControls";
import { endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, type ServerCreateFormState } from "../contracts/serverManagement";
import { cx } from "../utils/classes";
@@ -16,14 +16,17 @@ interface ServerDeploymentWorkflowProps {
initialForm: ServerCreateFormState;
deployment?: ServerDeploymentResponse;
busy?: boolean;
onReveal?: () => Promise<ServerDeploymentRevealResponse>;
onClose: () => void;
onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise<void>;
}
export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onClose, onSubmit }: ServerDeploymentWorkflowProps) {
export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onReveal, onClose, onSubmit }: ServerDeploymentWorkflowProps) {
const [step, setStep] = useState(0);
const [form, setForm] = useState<ServerCreateFormState>(initialForm);
const [saveAsDraft, setSaveAsDraft] = useState(false);
const [revealBusy, setRevealBusy] = useState(false);
const [revealError, setRevealError] = useState("");
const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]);
const profileOptions = selectedPlugin?.runtimeProfiles?.lifecycleProfiles ?? [];
const pluginFields = selectedPlugin?.createFields ?? [];
@@ -47,6 +50,8 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
setStep(0);
setSaveAsDraft(kind === "create" && !initialForm.runEndpointId);
setForm(initialForm);
setRevealBusy(false);
setRevealError("");
}, [initialForm, kind, open]);
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
@@ -82,10 +87,31 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
await onSubmit({ ...form, deploymentTargetId: saveAsDraft ? "" : form.deploymentTargetId, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft);
}
async function revealSavedInputs() {
if (!onReveal || revealBusy) return;
setRevealBusy(true);
setRevealError("");
try {
const revealed = await onReveal();
setForm((current) => ({ ...current, serverRoot: revealed.serverRoot, workingDirectory: revealed.workingDirectory, installCommand: revealed.installCommand, startCommand: revealed.startCommand, stopCommand: revealed.stopCommand, statusCommand: revealed.statusCommand }));
} catch (error) {
setRevealError(error instanceof Error ? error.message : "无法显示已保存配置");
} finally {
setRevealBusy(false);
}
}
function closeWorkflow() {
if (busy) return;
setForm(initialForm);
setRevealError("");
onClose();
}
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
const actionLabel = kind === "create" ? "保存草稿并准备专属 Run" : "保存部署设置";
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。受保护路径和命令留空会保持原值。"} wide onClose={() => { if (!busy) onClose(); }}>
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。受保护路径和命令留空会保持原值;可主动显示已保存配置。"} wide onClose={closeWorkflow}>
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
{step === targetStep && <div className="deployment-workflow-body">
@@ -98,7 +124,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由节点策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
</div></div>}
{step === configurationStep && <div className="deployment-workflow-body"><div className="form-grid">
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span></span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}>{revealBusy ? "读取中…" : "显示已保存配置"}</button>{revealError && <span className="field-help">{revealError}</span>}</div>}<div className="form-grid">
{kind === "create" && <label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label>}
{kind === "create" && <label><select name="profileKey" value={form.profileKey} onChange={updateForm}><option value="">使</option>{profileOptions.map((profile) => <option key={profile.key} value={profile.key}>{profile.key} · {profile.mode}</option>)}</select><small className="field-help"></small></label>}
{kind === "edit" && <label><select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select><small className="field-help"></small></label>}
@@ -118,8 +144,8 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>}
{kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>}
</div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>{kind === "create" ? "部署目标" : "目标"}</span><strong>{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存草稿并保留专属 Run 身份" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令不会显示原文。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? onClose() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>{kind === "create" ? "部署目标" : "目标"}</span><strong>{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令仅在本次显式展示后可见。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
</form>
</ManagementDialog>;
}
+4
View File
@@ -196,6 +196,10 @@ describe("first-party console pages", () => {
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" })');
expect(serverDeploymentWorkflowSource).toContain("运行连接设置");
expect(serverDeploymentWorkflowSource).toContain('type={field.sensitive ? "password" : "text"}');
expect(serverDeploymentWorkflowSource).toContain("显示已保存配置");
expect(serverDeploymentWorkflowSource).toContain("revealSavedInputs");
expect(serversPageSource).toContain("revealServerDeployment");
expect(serverDetailPageSource).toContain("最近 Run 调度");
expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)");
expect(serverDeploymentWorkflowSource).not.toContain('name="id"');
expect(serverDeploymentWorkflowSource).not.toContain("实例 ID");
+2 -2
View File
@@ -362,7 +362,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
<ServerDeploymentWorkflow open={showDeploymentEditor && deployment.status === "ready"} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={deploymentWorkflowForm(instance.data, deployment.status === "ready" ? deployment.data : undefined, plugins, endpoints)} deployment={deployment.status === "ready" ? deployment.data : undefined} busy={operations.isPending(instance.data.id, "更新部署定义")} onClose={() => setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} />
<ServerDeploymentWorkflow open={showDeploymentEditor && deployment.status === "ready"} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={deploymentWorkflowForm(instance.data, deployment.status === "ready" ? deployment.data : undefined, plugins, endpoints)} deployment={deployment.status === "ready" ? deployment.data : undefined} busy={operations.isPending(instance.data.id, "更新部署定义")} onReveal={() => platformApiClient.revealServerDeployment(instance.data.id)} onClose={() => setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} />
</>
)}
@@ -466,7 +466,7 @@ function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploym
return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div>
<p className="section-copy"></p>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div></div>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span> Run </span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}</div>
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM </span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>}
<div className="action-strip" style={{ marginTop: 12 }}><button type="button" className="primary-command" disabled={instance.state === "running" || instance.state === "installing"} onClick={onEdit}><Pencil size={14} /><span></span></button>{(instance.state === "draft" || instance.state === "failed") && <span className="field-help"></span>}</div>
</article>;
+1 -1
View File
@@ -492,7 +492,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
)}
<ServerDeploymentWorkflow open={showCreate && canManageServers} kind="create" plugins={plugins} endpoints={endpoints} initialForm={form} busy={createPending} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />
<ServerDeploymentWorkflow open={editDeployment !== null} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={form} deployment={editDeployment?.deployment} busy={editDeployment ? operations.isPending(editDeployment.instance.id, "更新部署定义") : false} onClose={() => setEditDeployment(null)} onSubmit={handleUpdateDeployment} />
<ServerDeploymentWorkflow open={editDeployment !== null} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={form} deployment={editDeployment?.deployment} busy={editDeployment ? operations.isPending(editDeployment.instance.id, "更新部署定义") : false} onReveal={() => platformApiClient.revealServerDeployment(editDeployment?.instance.id ?? "")} onClose={() => setEditDeployment(null)} onSubmit={handleUpdateDeployment} />
<ManagementDialog
open={runTargetSelection !== null}