From 6c5b74b9158ad0909aa893911a1799ac58d59633 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 27 Jul 2026 09:24:44 +0800 Subject: [PATCH] feat: reveal saved server deployment inputs --- .../.openspec.yaml | 2 + .../reveal-server-deployment-inputs/design.md | 40 +++++++++++++ .../proposal.md | 27 +++++++++ .../server-deployment-input-reveal/spec.md | 37 ++++++++++++ .../reveal-server-deployment-inputs/tasks.md | 22 +++++++ platform/api/resource_handlers.go | 1 + platform/api/resource_handlers_test.go | 30 ++++++++++ platform/api/server_lifecycle_handlers.go | 15 +++++ platform/domain/resources.go | 26 ++++++++ platform/dto/server_lifecycle.go | 59 ++++++++++++++----- platform/service/resources.go | 1 + platform/service/server_deployment.go | 33 +++++++++-- platform/service/server_deployment_test.go | 8 +++ platform_web/api/client.ts | 7 ++- platform_web/api/types.ts | 20 ++++++- .../components/ServerDeploymentWorkflow.tsx | 38 ++++++++++-- platform_web/pages/ConsolePages.test.tsx | 4 ++ platform_web/pages/ServerDetailPage.tsx | 4 +- platform_web/pages/ServersPage.tsx | 2 +- 19 files changed, 345 insertions(+), 31 deletions(-) create mode 100644 openspec/changes/reveal-server-deployment-inputs/.openspec.yaml create mode 100644 openspec/changes/reveal-server-deployment-inputs/design.md create mode 100644 openspec/changes/reveal-server-deployment-inputs/proposal.md create mode 100644 openspec/changes/reveal-server-deployment-inputs/specs/server-deployment-input-reveal/spec.md create mode 100644 openspec/changes/reveal-server-deployment-inputs/tasks.md diff --git a/openspec/changes/reveal-server-deployment-inputs/.openspec.yaml b/openspec/changes/reveal-server-deployment-inputs/.openspec.yaml new file mode 100644 index 0000000..2bc06e0 --- /dev/null +++ b/openspec/changes/reveal-server-deployment-inputs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-26 diff --git a/openspec/changes/reveal-server-deployment-inputs/design.md b/openspec/changes/reveal-server-deployment-inputs/design.md new file mode 100644 index 0000000..c04cb36 --- /dev/null +++ b/openspec/changes/reveal-server-deployment-inputs/design.md @@ -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. diff --git a/openspec/changes/reveal-server-deployment-inputs/proposal.md b/openspec/changes/reveal-server-deployment-inputs/proposal.md new file mode 100644 index 0000000..6768384 --- /dev/null +++ b/openspec/changes/reveal-server-deployment-inputs/proposal.md @@ -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. diff --git a/openspec/changes/reveal-server-deployment-inputs/specs/server-deployment-input-reveal/spec.md b/openspec/changes/reveal-server-deployment-inputs/specs/server-deployment-input-reveal/spec.md new file mode 100644 index 0000000..1a0aea1 --- /dev/null +++ b/openspec/changes/reveal-server-deployment-inputs/specs/server-deployment-input-reveal/spec.md @@ -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 diff --git a/openspec/changes/reveal-server-deployment-inputs/tasks.md b/openspec/changes/reveal-server-deployment-inputs/tasks.md new file mode 100644 index 0000000..9edfe42 --- /dev/null +++ b/openspec/changes/reveal-server-deployment-inputs/tasks.md @@ -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. diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 809b74b..83f6a24 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -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) diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index e28a083..71cd3f6 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -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) diff --git a/platform/api/server_lifecycle_handlers.go b/platform/api/server_lifecycle_handlers.go index 320b565..ec3dd13 100644 --- a/platform/api/server_lifecycle_handlers.go +++ b/platform/api/server_lifecycle_handlers.go @@ -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 { diff --git a/platform/domain/resources.go b/platform/domain/resources.go index ade0f2c..de337ae 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -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 = © + } return view } diff --git a/platform/dto/server_lifecycle.go b/platform/dto/server_lifecycle.go index 8919641..1316c3b 100644 --- a/platform/dto/server_lifecycle.go +++ b/platform/dto/server_lifecycle.go @@ -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 { diff --git a/platform/service/resources.go b/platform/service/resources.go index c9ba3d7..8fb7a74 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -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) diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index 90a8e4e..40bb16e 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -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 } diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index b21800a..3a37dd7 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -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) + } } diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index cd17f0d..83a1620 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -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 { - return this.request(`/server-instances/${encodeURIComponent(id)}/deployment`); + return this.request(`/server-instances/${encodeURIComponent(id)}/deployment`); + } + + async revealServerDeployment(id: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/deployment/reveal`); } async updateServerDeployment(id: string, request: ServerDeploymentRequest): Promise { diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index fb0b72c..c895f3b 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -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 { diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index 19db854..d6622fb 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -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; onClose: () => void; onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise; } -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(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) { @@ -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 { if (!busy) onClose(); }}> + return
void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
{step === targetStep &&
@@ -98,7 +124,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
} - {step === configurationStep &&
+ {step === configurationStep &&
{kind === "edit" && onReveal &&
需要核对原配置?仅本次编辑窗口内显示已保存的目录和命令,关闭后会清除。{revealError && {revealError}}
}
{kind === "create" && } {kind === "create" && } {kind === "edit" && } @@ -118,8 +144,8 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,节点以服务器目录执行。

} {kind === "create" && bindingFields.length > 0 &&
运行连接设置

用于插件声明的逻辑连接,不是服务器目录或游戏配置。

{bindingFields.map((field) => )}
}
} - {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" ? "部署目标" : "目标"}{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{kind === "create" ? "本次保存草稿并保留专属 Run 身份" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令不会显示原文。"}
} -
{step < reviewStep ? : }
+ {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" ? "部署目标" : "目标"}{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令仅在本次显式展示后可见。"}
} +
{step < reviewStep ? : }
; } diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 88fcc98..101146e 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -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"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 4712f67..02207b4 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -362,7 +362,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa {section === "plugins" && } {section === "llm" && } {section === "history" && } - setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} /> + platformApiClient.revealServerDeployment(instance.data.id)} onClose={() => setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} /> )} @@ -466,7 +466,7 @@ function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploym return

部署定义

{view.mode || "未配置"} · 修订 {view.revision}

服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。

-
服务器目录{view.serverRootConfigured ? "已配置" : "未配置"}
高级执行目录{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}
启动设置{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}
+
服务器目录{view.serverRootConfigured ? "已配置" : "未配置"}
高级执行目录{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}
启动设置{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}
{view.latestDispatch &&
最近 Run 调度{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}
}
{isScumTemplate &&
SCUM 受控模板{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}
预检 / 扫描{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}
配置映射 / 健康验证{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}
{projection?.failureCode &&
失败原因{projection.failureCode}
}
}
{(instance.state === "draft" || instance.state === "failed") && 保存后可从详情明确发起部署。}
; diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index febd977..5cbdbfc 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -492,7 +492,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr )} setShowCreate(false)} onSubmit={handleCreate} /> - setEditDeployment(null)} onSubmit={handleUpdateDeployment} /> + platformApiClient.revealServerDeployment(editDeployment?.instance.id ?? "")} onClose={() => setEditDeployment(null)} onSubmit={handleUpdateDeployment} />