Add server file manager workflow

This commit is contained in:
npc0-hue
2026-08-24 12:56:55 +08:00
parent dc9b0eaf2a
commit 33d3a13e74
20 changed files with 1961 additions and 22 deletions
+219
View File
@@ -1,6 +1,7 @@
package api
import (
"io"
"net/http"
"strconv"
"strings"
@@ -92,6 +93,14 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
mux.HandleFunc("/api/v1/server-instances/{id}/files/workspace", h.serverFilesWorkspace)
mux.HandleFunc("/api/v1/server-instances/{id}/files/list", h.serverFilesList)
mux.HandleFunc("/api/v1/server-instances/{id}/files/refresh", h.serverFilesRefresh)
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverFilesReadSnapshot)
mux.HandleFunc("/api/v1/server-instances/{id}/files/read", h.serverFilesRead)
mux.HandleFunc("/api/v1/server-instances/{id}/files/write", h.serverFilesWrite)
mux.HandleFunc("/api/v1/server-instances/{id}/files/upload", h.serverFilesUpload)
mux.HandleFunc("/api/v1/server-instances/{id}/files/download", h.serverFilesDownload)
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
@@ -109,6 +118,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk))
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
@@ -1278,6 +1288,196 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt
writeJSON(w, http.StatusAccepted, dto.ServerConfigWriteDispatchFromDomain(dispatch))
}
// serverFilesWorkspace godoc
// @Summary Read server file workspace
// @Description Returns plugin-declared logical directories and transfer policy without exposing host paths.
// @Tags server-files
// @Produce json
// @Param id path string true "Server instance ID"
// @Success 200 {object} dto.ServerFileWorkspaceResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/files/workspace [get]
func (h *coreHandlers) serverFilesWorkspace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerFileWorkspaceFromDomain(workspace))
}
func (h *coreHandlers) serverFilesList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
directoryKey, err := h.serverFileDirectoryKey(r, r.URL.Query().Get("directoryKey"))
if err != nil {
writeServiceError(w, err)
return
}
result, err := h.core.ListServerFilesForSession(bearerToken(r), domain.ServerFileListRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: directoryKey, Path: r.URL.Query().Get("path"), Query: r.URL.Query().Get("query"), Recursive: r.URL.Query().Get("recursive") == "true"})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerFileListFromDomain(result))
}
func (h *coreHandlers) serverFilesRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileListRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
request.DirectoryKey, err = h.serverFileDirectoryKey(r, request.DirectoryKey)
if err != nil {
writeServiceError(w, err)
return
}
result, err := h.core.RefreshServerFileListForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerFileListFromDomain(result))
}
func (h *coreHandlers) serverFilesReadSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
}
func (h *coreHandlers) serverFilesRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileReadRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.ReadServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
}
func (h *coreHandlers) serverFilesWrite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileWriteRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.WriteServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
}
func (h *coreHandlers) serverFilesUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
upload, err := h.decodeServerFileUpload(r)
if err != nil {
writeServiceError(w, err)
return
}
result, err := h.core.UploadServerFileForSession(bearerToken(r), upload)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerFileUploadFromDomain(result))
}
func (h *coreHandlers) serverFilesDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileDownloadRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.PrepareServerFileDownloadForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerFileDownloadFromDomain(result))
}
func (h *coreHandlers) serverFileDirectoryKey(r *http.Request, requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested != "" {
return requested, nil
}
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
return "", err
}
return workspace.DefaultDirectoryKey, nil
}
func (h *coreHandlers) decodeServerFileUpload(r *http.Request) (domain.ServerFileUploadRequest, error) {
if err := r.ParseMultipartForm(8 * 1024 * 1024); err != nil {
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"multipart upload is invalid"}}
}
file, header, err := r.FormFile("file")
if err != nil {
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"file is required"}}
}
defer file.Close()
payload, err := io.ReadAll(io.LimitReader(file, validator.MaxArtifactBytes+1))
if err != nil {
return domain.ServerFileUploadRequest{}, err
}
if int64(len(payload)) > validator.MaxArtifactBytes {
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"payload is too large"}}
}
checksum := strings.TrimSpace(r.FormValue("checksum"))
if checksum == "" {
checksum = validator.BytesChecksum(payload)
}
filename := strings.TrimSpace(r.FormValue("filename"))
if filename == "" && header != nil {
filename = header.Filename
}
return domain.ServerFileUploadRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: r.FormValue("directoryKey"), RelativePath: r.FormValue("relativePath"), Filename: filename, Payload: payload, Checksum: checksum, IdempotencyKey: r.FormValue("idempotencyKey")}, nil
}
// fileOperationDispatch godoc
// @Summary Dispatch scoped file operation
// @Description Queues a scoped files.read or files.write job using logical file keys or refs, never raw host paths.
@@ -1673,6 +1873,25 @@ func (h *coreHandlers) runJobUpdateChunk(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(result))
}
// runFileInputChunk serves one bounded browser-uploaded file input range only to the active fenced Run attempt.
func (h *coreHandlers) runFileInputChunk(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunFileInputChunkRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.ReadRunFileInputChunk(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunFileInputChunkFromDomain(result))
}
// runJobUpdateHealth godoc
// @Summary Confirm a reconciled Run self-update outcome
// @Description Accepts a signed current-session health or rollback report fenced to the terminal update job attempt.
+41 -5
View File
@@ -150,7 +150,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
endpointRequest := validRunEndpointRequest()
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-metrics-api",
PluginID: "server.scum",
@@ -234,11 +236,20 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
}
}
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "file-workspace-other", DisplayName: "File Workspace Other", Email: "file-workspace-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession)
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "file-workspace-other@example.test", Password: "secret-password"}).SessionID
pluginRequest := validGamePluginRequest()
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
pluginRequest.Permissions.Files = true
pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{DefaultDirectoryKey: "scum-config", Directories: []dto.PluginLogicalDirectoryBody{{Key: "scum-config", Label: "服务器配置", Scope: "config"}, {Key: "scum-logs", Label: "日志文件", Scope: "logs"}}, Files: []dto.PluginLogicalFileBody{{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}}}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
endpointRequest := validRunEndpointRequest()
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-file-snapshot-api",
PluginID: "server.scum",
@@ -246,8 +257,33 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
Name: "File Snapshot API Server",
State: domain.ServerInstanceStateRunning,
}, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound)
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || len(workspace.Files) != 2 {
t.Fatalf("unexpected workspace: %+v", workspace)
}
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession)
foundSettings := false
for _, entry := range list.Entries {
if entry.LogicalKey == "scum-server-settings" && entry.Editable && entry.Downloadable {
foundSettings = true
}
}
if list.State != "declared" || !foundSettings {
t.Fatalf("expected declared file list, got %+v", list)
}
readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession)
assertStatus(t, readRecorder, http.StatusAccepted)
read := decodeBody[dto.FileOperationDispatchResponse](t, readRecorder)
if read.Job.Capability != domain.JobCapabilityFilesRead || read.Job.TargetKey != "scum-server-settings" {
t.Fatalf("unexpected read dispatch: %+v", read)
}
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", adminSession)
if snapshot.State != "pending" || snapshot.JobID != read.Job.ID {
t.Fatalf("expected pending read snapshot, got %+v", snapshot)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden)
}
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
+8 -1
View File
@@ -4,7 +4,7 @@ Route declarations and handler comments live in `platform/api`. Request, respons
## Implemented Core Resource Routes
All routes use JSON request and response bodies. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`.
Routes use JSON request and response bodies unless a route explicitly accepts file upload multipart form data. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`.
| Resource | Collection | Detail | DTO contracts |
| --- | --- | --- | --- |
@@ -17,6 +17,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
| Server file manager | `GET /api/v1/server-instances/{id}/files/workspace`, `GET /api/v1/server-instances/{id}/files/list`, `POST /api/v1/server-instances/{id}/files/refresh`, `POST /api/v1/server-instances/{id}/files/read`, `POST /api/v1/server-instances/{id}/files/write`, `POST /api/v1/server-instances/{id}/files/upload`, `POST /api/v1/server-instances/{id}/files/download` | `GET /api/v1/server-instances/{id}/files/read-snapshot` | `ServerFileWorkspaceResponse`, `ServerFileListResponse`, `DeclaredFileReadSnapshotResponse`, `ServerFileReadRequest`, `ServerFileWriteRequest`, `ServerFileUploadResponse`, `ServerFileDownloadRequest`, `ServerFileDownloadResponse` |
| Plugin-owned data | n/a | `GET/PUT/DELETE /api/v1/server-instances/{id}/plugin-data/{collection}`, `POST .../plugin-data/{collection}/transaction` | `PluginDataPutRequest`, `PluginDataTransactionRequest`, `PluginDataRecordResponse`, `PluginDataListResponse` |
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` |
| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` |
@@ -186,6 +187,12 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure.
## Implemented Run File Transfer Actions
- `POST /api/v1/run/files/input-chunk`: accept `RunFileInputChunkRequest`, validate the active Run endpoint session plus the fenced file-write job lease/attempt, and return `RunFileInputChunkResponse` with a bounded byte range from a server-instance scoped upload artifact.
Run file input chunks are used only for browser-staged file uploads that produce `artifact://` job inputs. The route never returns storage backend paths, browser bearer credentials, machine paths, direct sockets, or unrestricted artifact bodies. This channel is lower priority than control, job lifecycle calls, and log ingest.
## Implemented Log Ingest Actions
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
+207
View File
@@ -1000,6 +1000,7 @@ type ServerConfigWriteDispatch struct {
type FileOperationKind string
const (
FileOperationList FileOperationKind = "list"
FileOperationRead FileOperationKind = "read"
FileOperationWrite FileOperationKind = "write"
)
@@ -1042,6 +1043,151 @@ type DeclaredFileReadSnapshot struct {
Reason string
}
type ServerFileEntryKind string
const (
ServerFileEntryDirectory ServerFileEntryKind = "directory"
ServerFileEntryFile ServerFileEntryKind = "file"
)
type ServerFileTransferPolicy struct {
Channel string
UploadChunkSizeBytes int
DownloadChunkSizeBytes int
MaxInlineEditBytes int
MaxBrowserUploadBytes int64
Notes []string
}
type ServerFileWorkspaceView struct {
ServerInstanceID string
PluginID string
DefaultDirectoryKey string
Directories []PluginLogicalDirectory
Files []PluginLogicalFile
ConfigFields []PluginConfigField
Transfer ServerFileTransferPolicy
DeclaredOnly bool
RuntimeWorkspaceScope string
}
type ServerFileEntry struct {
Name string
Kind ServerFileEntryKind
DirectoryKey string
RelativePath string
LogicalKey string
Scope string
SizeBytes int64
ModifiedAt time.Time
Checksum string
Editable bool
Downloadable bool
Remark string
}
type ServerFileListRequest struct {
ServerInstanceID string
DirectoryKey string
Path string
Query string
Recursive bool
IdempotencyKey string
}
type ServerFileListResult struct {
ServerInstanceID string
PluginID string
DirectoryKey string
Path string
State string
Entries []ServerFileEntry
Job Job
RefreshedAt time.Time
Reason string
}
type ServerFileReadRequest struct {
ServerInstanceID string
PluginID string
Key string
IdempotencyKey string
}
type ServerFileWriteRequest struct {
ServerInstanceID string
PluginID string
Key string
Content string
InputRef string
ExpectedVersion int
ExpectedChecksum string
IdempotencyKey string
}
type ServerFileUploadRequest struct {
ServerInstanceID string
DirectoryKey string
RelativePath string
Filename string
Payload []byte
Checksum string
IdempotencyKey string
}
type ServerFileUploadDispatch struct {
Status string
ServerInstanceID string
DirectoryKey string
RelativePath string
ArtifactID string
InputRef string
SizeBytes int64
Checksum string
Job Job
}
type ServerFileDownloadRequest struct {
ServerInstanceID string
Key string
IdempotencyKey string
}
type ServerFileDownloadResult struct {
Status string
ServerInstanceID string
Key string
Filename string
ContentType string
Content string
Checksum string
SizeBytes int64
Artifact *ArtifactDownloadReference
Job Job
ReadAt time.Time
Reason string
}
type RunFileInputChunkRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
Offset int64
Length int
}
type RunFileInputChunk struct {
JobID string
ArtifactID string
Offset int64
TotalBytes int64
Checksum string
Payload []byte
Complete bool
}
type RunCapacity struct {
MaxJobs int
RunningJobs int
@@ -1054,6 +1200,7 @@ type RunCapacity struct {
const (
JobCapabilityConfigWrite = "config.write"
JobCapabilityFilesList = "files.list"
JobCapabilityFilesRead = "files.read"
JobCapabilityFilesWrite = "files.write"
JobCapabilityRemoteFTPRead = "remote.ftp.read"
@@ -1963,6 +2110,66 @@ func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOpe
return result
}
func CopyServerFileTransferPolicy(policy ServerFileTransferPolicy) ServerFileTransferPolicy {
policy.Notes = CopyStringSlice(policy.Notes)
return policy
}
func CopyServerFileWorkspaceView(view ServerFileWorkspaceView) ServerFileWorkspaceView {
view.Directories = append([]PluginLogicalDirectory(nil), view.Directories...)
view.Files = append([]PluginLogicalFile(nil), view.Files...)
view.ConfigFields = append([]PluginConfigField(nil), view.ConfigFields...)
view.Transfer = CopyServerFileTransferPolicy(view.Transfer)
return view
}
func CopyServerFileEntry(entry ServerFileEntry) ServerFileEntry {
return entry
}
func CopyServerFileEntries(entries []ServerFileEntry) []ServerFileEntry {
if entries == nil {
return nil
}
out := make([]ServerFileEntry, len(entries))
copy(out, entries)
return out
}
func CopyServerFileListResult(result ServerFileListResult) ServerFileListResult {
result.Entries = CopyServerFileEntries(result.Entries)
result.Job = CopyJob(result.Job)
return result
}
func CopyServerFileUploadRequest(request ServerFileUploadRequest) ServerFileUploadRequest {
request.Payload = CopyBytes(request.Payload)
return request
}
func CopyServerFileUploadDispatch(dispatch ServerFileUploadDispatch) ServerFileUploadDispatch {
dispatch.Job = CopyJob(dispatch.Job)
return dispatch
}
func CopyServerFileDownloadResult(result ServerFileDownloadResult) ServerFileDownloadResult {
if result.Artifact != nil {
artifact := CopyArtifactDownloadReference(*result.Artifact)
result.Artifact = &artifact
}
result.Job = CopyJob(result.Job)
return result
}
func CopyRunFileInputChunkRequest(request RunFileInputChunkRequest) RunFileInputChunkRequest {
return request
}
func CopyRunFileInputChunk(chunk RunFileInputChunk) RunFileInputChunk {
chunk.Payload = CopyBytes(chunk.Payload)
return chunk
}
func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes)
+29
View File
@@ -310,6 +310,26 @@ type RunUpdateChunkResponse struct {
Complete bool `json:"complete"`
}
type RunFileInputChunkRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
Offset int64 `json:"offset"`
Length int `json:"length"`
}
type RunFileInputChunkResponse struct {
JobID string `json:"jobId"`
ArtifactID string `json:"artifactId"`
Offset int64 `json:"offset"`
TotalBytes int64 `json:"totalBytes"`
Checksum string `json:"checksum"`
Payload []byte `json:"payload"`
Complete bool `json:"complete"`
}
type RunUpdateHealthRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
@@ -428,6 +448,10 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
}
}
func (request RunFileInputChunkRequest) ToDomain() domain.RunFileInputChunkRequest {
return domain.RunFileInputChunkRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, Offset: request.Offset, Length: request.Length}
}
func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuildInputRequest {
return domain.DistributionBuildInputRequest{
RunEndpointID: request.RunEndpointID,
@@ -569,6 +593,11 @@ func RunUpdateChunkFromDomain(chunk domain.RunUpdateChunk) RunUpdateChunkRespons
return RunUpdateChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete}
}
func RunFileInputChunkFromDomain(chunk domain.RunFileInputChunk) RunFileInputChunkResponse {
chunk = domain.CopyRunFileInputChunk(chunk)
return RunFileInputChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete}
}
func RunUpdateHealthFromDomain(result domain.RunUpdateHealthResult) RunUpdateHealthResponse {
return RunUpdateHealthResponse{Accepted: result.Accepted, JobID: result.JobID, Phase: result.Phase, ServerTime: result.ServerTime}
}
+171
View File
@@ -730,6 +730,110 @@ type DeclaredFileReadSnapshotResponse struct {
Reason string `json:"reason,omitempty"`
}
type ServerFileTransferPolicyResponse struct {
Channel string `json:"channel"`
UploadChunkSizeBytes int `json:"uploadChunkSizeBytes"`
DownloadChunkSizeBytes int `json:"downloadChunkSizeBytes"`
MaxInlineEditBytes int `json:"maxInlineEditBytes"`
MaxBrowserUploadBytes int64 `json:"maxBrowserUploadBytes"`
Notes []string `json:"notes,omitempty"`
}
type ServerFileWorkspaceResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
DefaultDirectoryKey string `json:"defaultDirectoryKey"`
Directories []PluginLogicalDirectoryBody `json:"directories"`
Files []PluginLogicalFileBody `json:"files"`
ConfigFields []PluginConfigFieldBody `json:"configFields"`
Transfer ServerFileTransferPolicyResponse `json:"transfer"`
DeclaredOnly bool `json:"declaredOnly"`
RuntimeWorkspaceScope string `json:"runtimeWorkspaceScope,omitempty"`
}
type ServerFileEntryResponse struct {
Name string `json:"name"`
Kind domain.ServerFileEntryKind `json:"kind"`
DirectoryKey string `json:"directoryKey"`
RelativePath string `json:"relativePath,omitempty"`
LogicalKey string `json:"logicalKey,omitempty"`
Scope string `json:"scope,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
ModifiedAt time.Time `json:"modifiedAt,omitempty"`
Checksum string `json:"checksum,omitempty"`
Editable bool `json:"editable"`
Downloadable bool `json:"downloadable"`
Remark string `json:"remark,omitempty"`
}
type ServerFileListRequest struct {
DirectoryKey string `json:"directoryKey"`
Path string `json:"path,omitempty"`
Query string `json:"query,omitempty"`
Recursive bool `json:"recursive,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
type ServerFileListResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
DirectoryKey string `json:"directoryKey"`
Path string `json:"path,omitempty"`
State string `json:"state"`
Entries []ServerFileEntryResponse `json:"entries"`
Job *JobResponse `json:"job,omitempty"`
RefreshedAt time.Time `json:"refreshedAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type ServerFileReadRequest struct {
PluginID string `json:"pluginId,omitempty"`
Key string `json:"key"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerFileWriteRequest struct {
PluginID string `json:"pluginId,omitempty"`
Key string `json:"key"`
Content string `json:"content,omitempty"`
InputRef string `json:"inputRef,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerFileUploadResponse struct {
Status string `json:"status"`
ServerInstanceID string `json:"serverInstanceId"`
DirectoryKey string `json:"directoryKey"`
RelativePath string `json:"relativePath"`
ArtifactID string `json:"artifactId"`
InputRef string `json:"inputRef"`
SizeBytes int64 `json:"sizeBytes"`
Checksum string `json:"checksum"`
Job JobResponse `json:"job"`
}
type ServerFileDownloadRequest struct {
Key string `json:"key"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerFileDownloadResponse struct {
Status string `json:"status"`
ServerInstanceID string `json:"serverInstanceId"`
Key string `json:"key"`
Filename string `json:"filename"`
ContentType string `json:"contentType,omitempty"`
Content string `json:"content,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Artifact *ArtifactDownloadReferenceResponse `json:"artifact,omitempty"`
Job *JobResponse `json:"job,omitempty"`
ReadAt time.Time `json:"readAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type RunCapacityResponse struct {
MaxJobs int `json:"maxJobs"`
RunningJobs int `json:"runningJobs"`
@@ -1295,6 +1399,22 @@ func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispa
}
}
func (request ServerFileListRequest) ToDomain(serverInstanceID string) domain.ServerFileListRequest {
return domain.ServerFileListRequest{ServerInstanceID: serverInstanceID, DirectoryKey: request.DirectoryKey, Path: request.Path, Query: request.Query, Recursive: request.Recursive, IdempotencyKey: request.IdempotencyKey}
}
func (request ServerFileReadRequest) ToDomain(serverInstanceID string) domain.ServerFileReadRequest {
return domain.ServerFileReadRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}
}
func (request ServerFileWriteRequest) ToDomain(serverInstanceID string) domain.ServerFileWriteRequest {
return domain.ServerFileWriteRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, Content: request.Content, InputRef: request.InputRef, ExpectedVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey}
}
func (request ServerFileDownloadRequest) ToDomain(serverInstanceID string) domain.ServerFileDownloadRequest {
return domain.ServerFileDownloadRequest{ServerInstanceID: serverInstanceID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}
}
func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint {
return domain.RunEndpoint{
ID: request.ID,
@@ -1827,6 +1947,57 @@ func DeclaredFileReadSnapshotFromDomain(snapshot domain.DeclaredFileReadSnapshot
}
}
func ServerFileWorkspaceFromDomain(view domain.ServerFileWorkspaceView) ServerFileWorkspaceResponse {
view = domain.CopyServerFileWorkspaceView(view)
workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: view.Directories, Files: view.Files, ConfigFields: view.ConfigFields}
body := fileWorkspaceFromDomain(workspace)
return ServerFileWorkspaceResponse{ServerInstanceID: view.ServerInstanceID, PluginID: view.PluginID, DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: body.Directories, Files: body.Files, ConfigFields: body.ConfigFields, Transfer: ServerFileTransferPolicyFromDomain(view.Transfer), DeclaredOnly: view.DeclaredOnly, RuntimeWorkspaceScope: view.RuntimeWorkspaceScope}
}
func ServerFileTransferPolicyFromDomain(policy domain.ServerFileTransferPolicy) ServerFileTransferPolicyResponse {
policy = domain.CopyServerFileTransferPolicy(policy)
return ServerFileTransferPolicyResponse{Channel: policy.Channel, UploadChunkSizeBytes: policy.UploadChunkSizeBytes, DownloadChunkSizeBytes: policy.DownloadChunkSizeBytes, MaxInlineEditBytes: policy.MaxInlineEditBytes, MaxBrowserUploadBytes: policy.MaxBrowserUploadBytes, Notes: policy.Notes}
}
func ServerFileListFromDomain(result domain.ServerFileListResult) ServerFileListResponse {
result = domain.CopyServerFileListResult(result)
items := make([]ServerFileEntryResponse, len(result.Entries))
for index, entry := range result.Entries {
items[index] = ServerFileEntryFromDomain(entry)
}
var job *JobResponse
if result.Job.ID != "" {
body := JobFromDomain(result.Job)
job = &body
}
return ServerFileListResponse{ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, DirectoryKey: result.DirectoryKey, Path: result.Path, State: result.State, Entries: items, Job: job, RefreshedAt: result.RefreshedAt, Reason: result.Reason}
}
func ServerFileEntryFromDomain(entry domain.ServerFileEntry) ServerFileEntryResponse {
entry = domain.CopyServerFileEntry(entry)
return ServerFileEntryResponse{Name: entry.Name, Kind: entry.Kind, DirectoryKey: entry.DirectoryKey, RelativePath: entry.RelativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: entry.ModifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark}
}
func ServerFileUploadFromDomain(dispatch domain.ServerFileUploadDispatch) ServerFileUploadResponse {
dispatch = domain.CopyServerFileUploadDispatch(dispatch)
return ServerFileUploadResponse{Status: dispatch.Status, ServerInstanceID: dispatch.ServerInstanceID, DirectoryKey: dispatch.DirectoryKey, RelativePath: dispatch.RelativePath, ArtifactID: dispatch.ArtifactID, InputRef: dispatch.InputRef, SizeBytes: dispatch.SizeBytes, Checksum: dispatch.Checksum, Job: JobFromDomain(dispatch.Job)}
}
func ServerFileDownloadFromDomain(result domain.ServerFileDownloadResult) ServerFileDownloadResponse {
result = domain.CopyServerFileDownloadResult(result)
var artifact *ArtifactDownloadReferenceResponse
if result.Artifact != nil {
body := ArtifactDownloadReferenceFromDomain(*result.Artifact)
artifact = &body
}
var job *JobResponse
if result.Job.ID != "" {
body := JobFromDomain(result.Job)
job = &body
}
return ServerFileDownloadResponse{Status: result.Status, ServerInstanceID: result.ServerInstanceID, Key: result.Key, Filename: result.Filename, ContentType: result.ContentType, Content: result.Content, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Artifact: artifact, Job: job, ReadAt: result.ReadAt, Reason: result.Reason}
}
func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse {
endpoint = domain.CopyRunEndpoint(endpoint)
return RunEndpointResponse{
+15 -1
View File
@@ -50,7 +50,7 @@ Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstan
Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Explicit operator-requested install, enable, disable, upgrade, rollback, retire, dependency-check, and bounded lifecycle commands remain Platform-authorized jobs. Generated Run package startup is not dependent on registration-time job assignment; it is driven by the autonomous lifecycle plan embedded by the platform builder. Assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets.
Approved `config.write` and bounded `files.read`/`files.write` assignments carry logical keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
Approved `config.write` and bounded `files.list`/`files.read`/`files.write` assignments carry logical directory keys, file keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure.
@@ -113,6 +113,20 @@ Artifact upload supports active run session validation, job/server-instance owne
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
## Server File Manager Transfer
Implemented HTTP JSON routes:
- `POST /api/v1/run/files/input-chunk`
Browser-facing file management uses server-instance scoped routes on Platform for workspace, list, read, write, upload, and download preparation. The browser only sends plugin-declared logical directory keys, logical file keys, relative names, inline text for small edits, or platform-owned `artifact://` input refs. It never receives or sends host paths, direct Run sockets, Run sessions, job lease tokens, storage credentials, or raw machine endpoints.
`files.list` jobs return a bounded `file.list` execution result containing logical entries. `files.read` jobs may return a bounded inline `file.read` result for editable text or a `resultRef` pointing to a platform artifact for larger content. Browser downloads are prepared through Platform and then read in bounded chunks using the platform artifact download contract.
Browser uploads are first staged as server-instance artifacts. Platform then queues a `files.write` job whose `inputRef` is `artifact://<id>` and whose execution input names the dedicated `run-file-transfer` channel. Run pulls those bytes through `POST /api/v1/run/files/input-chunk` while proving the active endpoint session plus job attempt and lease. The chunk route is fenced to the active file-write job, validates artifact ownership/checksum, and returns bounded byte ranges only.
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
## Client Manager lifecycle channel
Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values.
+4
View File
@@ -311,6 +311,10 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) {
return validationError("config write result checksum is invalid")
}
case domain.JobCapabilityFilesList:
if result.ExecutionResult.Kind != "file.list" {
return validationError("file list result type is invalid")
}
case domain.JobCapabilityFilesRead:
if result.ExecutionResult.Kind != "file.read" {
return validationError("file read result type is invalid")
+24 -6
View File
@@ -123,6 +123,13 @@ type Core interface {
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
GetServerFileWorkspaceForSession(string, string) (domain.ServerFileWorkspaceView, error)
ListServerFilesForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
RefreshServerFileListForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
ReadServerFileForSession(string, domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error)
WriteServerFileForSession(string, domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error)
UploadServerFileForSession(string, domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error)
PrepareServerFileDownloadForSession(string, domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error)
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
@@ -143,6 +150,7 @@ type Core interface {
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
ReadRunFileInputChunk(domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error)
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error)
PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error)
@@ -2156,11 +2164,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable {
return domain.FileOperationDispatchResult{}, ErrForbidden
}
payload, payloadErr := svc.artifactPayload(artifactID)
if payloadErr != nil {
return domain.FileOperationDispatchResult{}, payloadErr
}
content = string(payload)
}
if request.PluginID != "" {
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
@@ -2173,7 +2176,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
if plugin.Status != domain.GamePluginStatusInstalled {
return domain.FileOperationDispatchResult{}, validationError("plugin must be installed")
}
if request.Operation == domain.FileOperationRead && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
if (request.Operation == domain.FileOperationList || request.Operation == domain.FileOperationRead) && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
return domain.FileOperationDispatchResult{}, ErrForbidden
}
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
@@ -2188,6 +2191,10 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
}
capability := domain.JobCapabilityFilesRead
message := "file read queued"
if request.Operation == domain.FileOperationList {
capability = domain.JobCapabilityFilesList
message = "file list queued"
}
if request.Operation == domain.FileOperationWrite {
capability = domain.JobCapabilityFilesWrite
message = "file write queued"
@@ -2218,6 +2225,17 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
}
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
if request.Operation == domain.FileOperationList {
if len(workspace.Directories) == 0 {
return domain.PluginLogicalFile{}, false, true
}
for _, directory := range workspace.Directories {
if directory.Key == request.Key {
return domain.PluginLogicalFile{}, true, true
}
}
return domain.PluginLogicalFile{}, true, false
}
if len(workspace.Files) == 0 {
return domain.PluginLogicalFile{}, false, true
}
+508
View File
@@ -0,0 +1,508 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"path"
"sort"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
serverFileTransferChannel = "run-file-transfer"
serverFileMaxInlineEditBytes = 64 * 1024
)
type serverFileContext struct {
User domain.User
Instance domain.ServerInstance
Plugin domain.GamePlugin
Directory domain.PluginLogicalDirectory
Scope string
}
type runFileListEnvelope struct {
DirectoryKey string `json:"directoryKey"`
Path string `json:"path"`
Entries []runFileListEntry `json:"entries"`
}
type runFileListEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
DirectoryKey string `json:"directoryKey"`
RelativePath string `json:"relativePath"`
LogicalKey string `json:"logicalKey"`
Scope string `json:"scope"`
SizeBytes int64 `json:"sizeBytes"`
ModifiedAt string `json:"modifiedAt"`
Checksum string `json:"checksum"`
Editable bool `json:"editable"`
Downloadable bool `json:"downloadable"`
Remark string `json:"remark"`
}
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false)
if err != nil {
return domain.ServerFileWorkspaceView{}, err
}
workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace)
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
}
view := domain.ServerFileWorkspaceView{
ServerInstanceID: ctx.Instance.ID,
PluginID: ctx.Plugin.ID,
DefaultDirectoryKey: workspace.DefaultDirectoryKey,
Directories: workspace.Directories,
Files: workspace.Files,
ConfigFields: workspace.ConfigFields,
DeclaredOnly: true,
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
Transfer: domain.ServerFileTransferPolicy{
Channel: serverFileTransferChannel,
UploadChunkSizeBytes: validator.MaxArtifactChunkBytes,
DownloadChunkSizeBytes: validator.MaxArtifactDownloadBytes,
MaxInlineEditBytes: serverFileMaxInlineEditBytes,
MaxBrowserUploadBytes: validator.MaxArtifactBytes,
Notes: []string{
"文件字节通过独立文件传输端点传递,不占用 control、jobs 或 logs 通道。",
"Web 只发送逻辑目录和相对路径;Run 在本机工作区内解析真实路径。",
},
},
}
return domain.CopyServerFileWorkspaceView(view), nil
}
func (svc *CoreService) ListServerFilesForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request)
if err := validator.ValidateServerFileListRequest(request); err != nil {
return domain.ServerFileListResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false)
if err != nil {
return domain.ServerFileListResult{}, err
}
latest, hasLatest, err := svc.latestFileListJob(ctx.Instance.ID, request.DirectoryKey, request.Path)
if err != nil {
return domain.ServerFileListResult{}, err
}
if hasLatest && latest.State == domain.JobStateSucceeded && latest.ExecutionResult.Kind == "file.list" {
entries, parseErr := serverFileEntriesFromRunList(latest.ExecutionResult.Content, request.DirectoryKey, request.Path)
if parseErr == nil {
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil
}
}
state := "declared"
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。"
if hasLatest && !isTerminalJobState(latest.State) {
state = "pending"
reason = "Run 正在刷新目录。"
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil
}
func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request)
if request.IdempotencyKey == "" {
request.IdempotencyKey = fmt.Sprintf("file-list:%s:%s:%s", request.ServerInstanceID, request.DirectoryKey, request.Path)
}
if err := validator.ValidateServerFileListRequest(request); err != nil {
return domain.ServerFileListResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true)
if err != nil {
return domain.ServerFileListResult{}, err
}
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey),
ServerInstanceID: ctx.Instance.ID,
RunEndpointID: ctx.Instance.RunEndpointID,
Capability: domain.JobCapabilityFilesList,
TargetKey: request.DirectoryKey,
InputRef: "",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Inputs: map[string]string{
"directoryKey": request.DirectoryKey,
"path": request.Path,
"recursive": strconv.FormatBool(request.Recursive),
"query": request.Query,
}, MaxReadBytes: serverFileMaxInlineEditBytes},
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file list queued"},
})
if err != nil {
return domain.ServerFileListResult{}, err
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil
}
func (svc *CoreService) ReadServerFileForSession(sessionID string, request domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) {
if err := validator.ValidateServerFileReadRequest(request); err != nil {
return domain.FileOperationDispatchResult{}, err
}
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
}
func (svc *CoreService) WriteServerFileForSession(sessionID string, request domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) {
if request.InputRef == "" {
request.InputRef = fileManagerInlineInputRef(request.ServerInstanceID, request.Key, request.IdempotencyKey)
}
if err := validator.ValidateServerFileWriteRequest(request); err != nil {
return domain.FileOperationDispatchResult{}, err
}
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
}
func (svc *CoreService) UploadServerFileForSession(sessionID string, request domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) {
request = domain.CopyServerFileUploadRequest(request)
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
return domain.ServerFileUploadDispatch{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true)
if err != nil {
return domain.ServerFileUploadDispatch{}, err
}
if strings.EqualFold(ctx.Directory.Scope, "logs") {
return domain.ServerFileUploadDispatch{}, validationError("log directories are read-only")
}
relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename))
artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath)
artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()}
if err := svc.putBrowserFileArtifact(artifact, request.Payload); err != nil {
return domain.ServerFileUploadDispatch{}, err
}
inputRef := "artifact://" + artifact.ID
targetKey := cleanServerFileRelativePath(path.Join(request.DirectoryKey, relativePath))
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-file-upload", request.ServerInstanceID, request.IdempotencyKey),
ServerInstanceID: ctx.Instance.ID,
RunEndpointID: ctx.Instance.RunEndpointID,
Capability: domain.JobCapabilityFilesWrite,
TargetKey: targetKey,
InputRef: inputRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), ExpectedChecksum: request.Checksum, Inputs: map[string]string{
"directoryKey": request.DirectoryKey,
"relativePath": relativePath,
"filename": request.Filename,
"transfer": serverFileTransferChannel,
}},
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file upload staged; Run will pull input chunks"},
})
if err != nil {
return domain.ServerFileUploadDispatch{}, err
}
return domain.CopyServerFileUploadDispatch(domain.ServerFileUploadDispatch{Status: "queued", ServerInstanceID: ctx.Instance.ID, DirectoryKey: request.DirectoryKey, RelativePath: relativePath, ArtifactID: artifact.ID, InputRef: inputRef, SizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Job: job}), nil
}
func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, request domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) {
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
return domain.ServerFileDownloadResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true)
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key)
job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key)
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
if hasJob && job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
if strings.HasPrefix(job.ResultRef, "artifact://") {
reference, openErr := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: strings.TrimPrefix(job.ResultRef, "artifact://")})
if openErr != nil {
return domain.ServerFileDownloadResult{}, openErr
}
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil
}
if job.ExecutionResult.Content != "" {
content := redactDeclaredFileReadContent(job.ExecutionResult.Content)
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil
}
}
if hasJob && !isTerminalJobState(job.State) {
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: job, Reason: "文件读取任务仍在执行。"}), nil
}
dispatch, err := svc.ReadServerFileForSession(sessionID, domain.ServerFileReadRequest{ServerInstanceID: request.ServerInstanceID, PluginID: ctx.Plugin.ID, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: dispatch.Job, Reason: "文件读取任务已派发;完成后可再次下载。"}), nil
}
func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) {
request = domain.CopyRunFileInputChunkRequest(request)
if err := validator.ValidateRunFileInputChunkRequest(request); err != nil {
return domain.RunFileInputChunk{}, err
}
session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken)
if err != nil {
return domain.RunFileInputChunk{}, err
}
svc.jobMu.Lock()
job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, err
}
if job.Capability != domain.JobCapabilityFilesWrite || !strings.HasPrefix(job.InputRef, "artifact://") {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, validationError("job does not reference a file input artifact")
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, validationError("job is not active")
}
artifactID := strings.TrimPrefix(job.InputRef, "artifact://")
serverInstanceID := job.ServerInstanceID
svc.jobMu.Unlock()
artifact, err := svc.store.Artifacts().Get(artifactID)
if err != nil {
return domain.RunFileInputChunk{}, err
}
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable {
return domain.RunFileInputChunk{}, ErrForbidden
}
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.RunFileInputChunk{}, err
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return domain.RunFileInputChunk{}, validationError("file input artifact checksum mismatch")
}
if request.Offset >= artifact.SizeBytes {
return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content")
}
length := request.Length
remaining := artifact.SizeBytes - request.Offset
if int64(length) > remaining {
length = int(remaining)
}
end := int(request.Offset) + length
chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):end], Complete: int64(end) == artifact.SizeBytes}
return domain.CopyRunFileInputChunk(chunk), nil
}
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return serverFileContext{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return serverFileContext{}, err
}
if requireRuntime {
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil {
return serverFileContext{}, err
}
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return serverFileContext{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled {
return serverFileContext{}, validationError("plugin must be installed")
}
if requireWrite {
if !containsString(plugin.DeclaredPermissions, "server.files.write") {
return serverFileContext{}, ErrForbidden
}
} else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
return serverFileContext{}, ErrForbidden
}
directory := domain.PluginLogicalDirectory{}
if directoryKey != "" {
var found bool
for _, candidate := range plugin.FileWorkspace.Directories {
if candidate.Key == directoryKey {
directory = candidate
found = true
break
}
}
if !found && len(plugin.FileWorkspace.Directories) > 0 {
return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory")
}
}
return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil
}
func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest {
request.DirectoryKey = strings.TrimSpace(request.DirectoryKey)
request.Path = cleanServerFileRelativePath(request.Path)
request.Query = strings.TrimSpace(request.Query)
return request
}
func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace, directoryKey string) []domain.ServerFileEntry {
entries := make([]domain.ServerFileEntry, 0)
for _, directory := range workspace.Directories {
if directoryKey == "" || directory.Key == directoryKey {
continue
}
entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: false, Downloadable: false, Remark: "插件声明目录"})
}
for _, file := range workspace.Files {
if directoryKey != "" && file.DirectoryKey != directoryKey {
continue
}
entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: file.Editable, Downloadable: true, Remark: fileRemark(file)})
}
sort.SliceStable(entries, func(i int, j int) bool {
if entries[i].Kind == entries[j].Kind {
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
}
return entries[i].Kind == domain.ServerFileEntryDirectory
})
return entries
}
func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, fallbackPath string) ([]domain.ServerFileEntry, error) {
var envelope runFileListEnvelope
if err := json.Unmarshal([]byte(content), &envelope); err != nil {
return nil, err
}
entries := make([]domain.ServerFileEntry, 0, len(envelope.Entries))
for _, entry := range envelope.Entries {
kind := domain.ServerFileEntryFile
if entry.Kind == string(domain.ServerFileEntryDirectory) {
kind = domain.ServerFileEntryDirectory
}
modifiedAt := time.Time{}
if entry.ModifiedAt != "" {
modifiedAt, _ = time.Parse(time.RFC3339, entry.ModifiedAt)
}
directoryKey := entry.DirectoryKey
if directoryKey == "" {
directoryKey = fallbackDirectoryKey
}
relativePath := cleanServerFileRelativePath(entry.RelativePath)
if relativePath == "" {
relativePath = fallbackPath
}
entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark})
}
return entries, nil
}
func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []domain.ServerFileEntry {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return domain.CopyServerFileEntries(entries)
}
filtered := make([]domain.ServerFileEntry, 0, len(entries))
for _, entry := range entries {
if strings.Contains(strings.ToLower(entry.Name+" "+entry.RelativePath+" "+entry.LogicalKey+" "+entry.Remark), query) {
filtered = append(filtered, entry)
}
}
return filtered
}
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey)
}
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesRead, key)
}
func (svc *CoreService) latestServerFileJob(serverInstanceID string, capability string, targetKey string) (domain.Job, bool, error) {
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return domain.Job{}, false, err
}
var latest domain.Job
found := false
for _, job := range jobs {
if job.Capability != capability || job.TargetKey != targetKey {
continue
}
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
latest = job
found = true
}
}
return domain.CopyJob(latest), found, nil
}
func (svc *CoreService) putBrowserFileArtifact(artifact domain.Artifact, payload []byte) error {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if existing, err := svc.store.Artifacts().Get(artifact.ID); err == nil {
if existing.OwnerKind != artifact.OwnerKind || existing.OwnerID != artifact.OwnerID || existing.SizeBytes != artifact.SizeBytes || existing.Checksum != artifact.Checksum || existing.State != domain.ArtifactStateAvailable {
return validationError("file upload idempotency key conflicts with existing artifact")
}
} else if !errors.Is(err, repo.ErrNotFound) {
return err
} else {
if err := validator.ValidateArtifact(artifact); err != nil {
return err
}
if err := svc.store.Artifacts().Create(artifact); err != nil {
return err
}
}
if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil {
return err
}
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
return nil
}
func cleanServerFileRelativePath(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
if value == "" || value == "." {
return ""
}
cleaned := path.Clean(value)
if cleaned == "." {
return ""
}
return strings.TrimPrefix(cleaned, "/")
}
func serverFileUploadArtifactID(serverInstanceID string, idempotencyKey string, relativePath string) string {
raw := serverInstanceID + ":" + idempotencyKey + ":" + relativePath
return "artifact-file-upload-" + sanitizeIDPart(serverInstanceID) + "-" + fmt.Sprint(stableStringNumber(raw))
}
func fileManagerInlineInputRef(serverInstanceID string, key string, idempotencyKey string) string {
return "input://server-files/" + sanitizeIDPart(serverInstanceID) + "/" + sanitizeIDPart(key) + "/" + fmt.Sprint(stableStringNumber(idempotencyKey))
}
func fileRemark(file domain.PluginLogicalFile) string {
if file.StreamKey != "" {
return "日志流 " + file.StreamKey
}
if file.Editable {
return "可编辑配置"
}
return "插件声明文件"
}
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
for _, file := range workspace.Files {
if file.Key == key && strings.TrimSpace(file.Label) != "" {
return file.Label
}
}
name := path.Base(key)
if name == "." || name == "/" || name == "" {
return "server-file.txt"
}
return name
}
+114 -2
View File
@@ -1421,6 +1421,104 @@ func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRe
return finish(violations)
}
func ValidateServerFileListRequest(request domain.ServerFileListRequest) error {
var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
violations = appendRequired(violations, "directoryKey", request.DirectoryKey)
if !validLogicalFileKey(request.DirectoryKey) {
violations = append(violations, "directoryKey is not allowed")
}
if request.Path != "" && !validLogicalFileKey(request.Path) {
violations = append(violations, "path is not allowed")
}
if len([]rune(request.Query)) > 80 || containsUnsafeRuntimeSecret(request.Query) || looksLikeRawHostPath(request.Query) {
violations = append(violations, "query is not allowed")
}
if request.IdempotencyKey != "" && (containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey)) {
violations = append(violations, "idempotencyKey is not allowed")
}
return finish(violations)
}
func ValidateServerFileReadRequest(request domain.ServerFileReadRequest) error {
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
}
func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error {
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
}
func ValidateServerFileUploadRequest(request domain.ServerFileUploadRequest) error {
request = domain.CopyServerFileUploadRequest(request)
var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
violations = appendRequired(violations, "directoryKey", request.DirectoryKey)
violations = appendRequired(violations, "filename", request.Filename)
violations = appendRequired(violations, "checksum", request.Checksum)
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
if !validLogicalFileKey(request.DirectoryKey) {
violations = append(violations, "directoryKey is not allowed")
}
if request.RelativePath != "" && !validLogicalFileKey(request.RelativePath) {
violations = append(violations, "relativePath is not allowed")
}
if !validUploadFilename(request.Filename) {
violations = append(violations, "filename is not allowed")
}
if len(request.Payload) == 0 {
violations = append(violations, "payload is required")
}
if int64(len(request.Payload)) > MaxArtifactBytes {
violations = append(violations, fmt.Sprintf("payload must not exceed %d", MaxArtifactBytes))
}
if request.Checksum != "" {
if !validSHA256Checksum(request.Checksum) {
violations = append(violations, "checksum must be sha256:<hex>")
} else if request.Checksum != BytesChecksum(request.Payload) {
violations = append(violations, "checksum does not match payload")
}
}
for _, value := range []string{request.ServerInstanceID, request.DirectoryKey, request.RelativePath, request.Filename, request.IdempotencyKey} {
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "unix://") {
violations = append(violations, "request contains unsafe content")
break
}
}
return finish(violations)
}
func ValidateServerFileDownloadRequest(request domain.ServerFileDownloadRequest) error {
var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
violations = appendRequired(violations, "key", request.Key)
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
if !validLogicalFileKey(request.Key) {
violations = append(violations, "key is not allowed")
}
if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) {
violations = append(violations, "idempotencyKey is not allowed")
}
return finish(violations)
}
func ValidateRunFileInputChunkRequest(request domain.RunFileInputChunkRequest) error {
var violations []string
violations = appendRequired(violations, "runEndpointId", request.RunEndpointID)
violations = appendRequired(violations, "sessionToken", request.SessionToken)
violations = appendRequired(violations, "jobId", request.JobID)
violations = appendRequired(violations, "leaseToken", request.LeaseToken)
if request.Attempt <= 0 {
violations = append(violations, "attempt must be positive")
}
if request.Offset < 0 {
violations = append(violations, "offset must not be negative")
}
if request.Length <= 0 || request.Length > MaxArtifactDownloadBytes {
violations = append(violations, fmt.Sprintf("length must be between 1 and %d", MaxArtifactDownloadBytes))
}
return finish(violations)
}
func appendPercentViolation(violations []string, field string, value float64) []string {
if value < 0 || value > 100 {
return append(violations, field+" must be between 0 and 100")
@@ -1588,7 +1686,7 @@ func ValidateJob(job domain.Job) error {
if len(job.ExecutionResult.Summary) > maxSummaryLength {
violations = append(violations, "executionResult.summary is too long")
}
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesList || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
if job.ServerInstanceID == "" {
violations = append(violations, "serverInstanceId is required for scoped file jobs")
}
@@ -2305,13 +2403,27 @@ func validRemoteDatabaseEngine(engine string) bool {
func validFileOperationKind(operation domain.FileOperationKind) bool {
switch operation {
case domain.FileOperationRead, domain.FileOperationWrite:
case domain.FileOperationList, domain.FileOperationRead, domain.FileOperationWrite:
return true
default:
return false
}
}
func validUploadFilename(name string) bool {
trimmed := strings.TrimSpace(name)
if trimmed == "" || trimmed != name || len([]rune(name)) > 120 || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "..") || strings.Contains(name, "://") || looksLikeRawHostPath(name) || containsUnsafeRuntimeSecret(name) {
return false
}
for _, char := range name {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ' ' || char == '(' || char == ')' {
continue
}
return false
}
return true
}
func validConfigFileKey(key string) bool {
switch key {
case "server.properties", "config/server.properties":