feat: ship single-file run distribution and list key reset
Compile-time run auth replaces zip sidecars, lengthens run keys, revokes active sessions on reset, and exposes run-key reset in the server list.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
## Context
|
||||
|
||||
The existing distribution pipeline already queues real `distribution.build` jobs and keeps raw component keys out of platform APIs. The weak point is the generated package shape: Run artifacts still contain `config.json`, which makes accidental token disclosure easy when a ZIP is shared or inspected. The operator expectation is also a single `run.exe` on Windows, not an archive.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Produce Windows Run downloads as `run-windows-amd64.exe` and Linux Run downloads as raw executable files such as `run-linux-amd64`.
|
||||
- Compile server identity and the current Run key into the Run binary using Go native `-ldflags -X`.
|
||||
- Keep `RUN_PLATFORM_URL` and other explicit environment overrides working for local development and diagnostics.
|
||||
- Support raw-executable Run self-update artifacts with checksum verification and the existing staging/rollback flow.
|
||||
- Make run-key reset reachable from the server list while preserving the compact action popover.
|
||||
- Immediately revoke an online Run control session after its key is reset.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Do not add device authorization, enrollment binding, or a token exchange ceremony.
|
||||
- Do not claim compile-time embedded tokens are unrecoverable from the executable; possession of the executable remains a trust boundary.
|
||||
- Do not change Client Manager packaging or plugin-declared client-manager build semantics.
|
||||
- Do not modify billing, cloud host sales, AI provider, plugin marketplace, or unrelated server workflows.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Compile-time Run identity is a build-input contract
|
||||
|
||||
Platform extends authenticated distribution build input with the public Run platform URL and the existing identity fields. The trusted Run worker passes those values to `go build -ldflags -X browser.local/run/config.<Var>=<value>`. Run config loading prefers explicit environment variables, then compile-time build values, then developer defaults.
|
||||
|
||||
This uses the Go-native mechanism the user requested. It avoids `go:embed`, generated source files, temporary code rewrites, or sidecar config files for Run. The compile-time value is still recoverable by someone holding the binary, but it removes the casual ZIP/config leak.
|
||||
|
||||
### Decision 2: Raw executable is a Run-only package format
|
||||
|
||||
Run distributions use a new `raw-executable` package format. The worker uploads the compiled binary bytes directly and does not call the archive writer for Run. Client-manager distributions keep the existing ZIP/tar.gz packaging and config injection because they are plugin-declared companion builds with their own lifecycle.
|
||||
|
||||
### Decision 3: Self-update treats raw executable as first-class
|
||||
|
||||
Run update input may carry `raw-executable`. The self-update executor still downloads through the artifact channel, verifies the full artifact checksum, writes the staged executable under the transaction workspace, records its binary checksum, and uses the existing activation and rollback logic.
|
||||
|
||||
### Decision 4: Run keys get a dedicated generator
|
||||
|
||||
The existing `randomToken()` remains 32 random bytes because it is also used for auth sessions, user ID suffixes, and job leases. Run component keys use a new 64-byte URL-safe generator, increasing key length only for Run authorization.
|
||||
|
||||
### Decision 5: Reset revokes the active Run session
|
||||
|
||||
The reset service already revokes old distribution artifacts and increments key generation. This change also removes the active Run control session for that server endpoint when the Run component key is reset, forcing a freshly compiled binary to authenticate before more control/job traffic is accepted.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Embedded authorization can be extracted from a binary by a determined operator or attacker with file access. This is acceptable for the requested distribution model and is explicitly not a DRM or device-binding system.
|
||||
- Compile-time values can be visible to privileged users on the build worker. The build worker remains part of the trusted platform boundary.
|
||||
- Raw executables lose the convenience of multi-file package payloads, so any future service installer/systemd wrapper should be a separate explicit distribution profile rather than hidden in this change.
|
||||
|
||||
## Verification
|
||||
|
||||
- `openspec validate secure-single-file-run-distribution --strict`
|
||||
- `cd run && go test ./...`
|
||||
- `cd platform && go test ./...`
|
||||
- `cd platform_web && npm test`
|
||||
- `cd platform_web && npm run typecheck`
|
||||
- `scripts/check-structure.sh`
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
Run distributions currently produce a secret-bearing archive with a sidecar `config.json`, and downloaded Windows packages are ZIP files. Operators need the platform to deliver one server-scoped executable whose platform URL and runtime authorization are compiled into the binary.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Change platform-managed Run distributions to publish a single raw executable instead of a ZIP/tarball plus `config.json`.
|
||||
- Inject Run platform URL, worker mode, runtime identity, key generation, and authorization token through Go `-ldflags -X` during the trusted Run build.
|
||||
- Increase Run component key entropy without changing the global session/job token generator.
|
||||
- Allow Run self-update jobs to consume raw executable artifacts for Windows and Linux targets.
|
||||
- Add run-key reset to the server-list "运行操作" dangerous menu, using the existing reset API.
|
||||
- Revoke the active Run control session when the run key is reset so old deployed binaries stop immediately.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `secure-single-file-run-distribution`: Server-scoped Run executable distribution, compile-time authorization injection, raw-binary self-update, and list-level run-key reset.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affects `platform/` distribution build input, run-key generation/reset behavior, artifact naming, package-format validation, and tests.
|
||||
- Affects `run/` config loading, distribution build packaging, self-update extraction, protocol DTOs, and tests.
|
||||
- Affects `platform_web/` server-list runtime action menu and tests.
|
||||
- Does not touch plugins, client-manager package format, AI provider flows, billing/cloud features, or unrelated UI systems.
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Platform publishes single-file Run executables
|
||||
|
||||
The platform SHALL publish Run distributions as one raw executable per server, target OS, and architecture instead of an archive containing a sidecar configuration file.
|
||||
|
||||
#### Scenario: Operator generates Windows Run
|
||||
|
||||
- **WHEN** an authorized operator generates Run for a Windows target
|
||||
- **THEN** platform MUST queue a distribution build job that uploads a single executable artifact and presents it as `run-windows-<arch>.exe` with `application/octet-stream`
|
||||
- **AND** the artifact MUST NOT contain a downloadable `config.json` sidecar
|
||||
|
||||
#### Scenario: Operator generates Linux Run
|
||||
|
||||
- **WHEN** an authorized operator generates Run for a Linux target
|
||||
- **THEN** platform MUST queue a distribution build job that uploads a single executable artifact and presents it as `run-linux-<arch>` with `application/octet-stream`
|
||||
|
||||
### Requirement: Run authorization is compiled into the executable
|
||||
|
||||
Run distribution builds SHALL inject server-scoped identity and the active Run authorization key into the executable at Go build time.
|
||||
|
||||
#### Scenario: Build input is consumed by trusted Run builder
|
||||
|
||||
- **WHEN** a Run worker receives authenticated distribution build input for component kind `run`
|
||||
- **THEN** it MUST compile the target with Go `-ldflags -X` values for worker mode, platform URL, run endpoint ID, server instance ID, plugin ID, component kind, key generation, target release, and authorization token
|
||||
- **AND** explicit runtime environment variables such as `RUN_PLATFORM_URL` MUST remain able to override compiled defaults for local development
|
||||
|
||||
#### Scenario: Compiled executable starts without sidecar config
|
||||
|
||||
- **WHEN** the generated Run executable starts with no `config.json`
|
||||
- **THEN** it MUST load the compiled identity and key, register as a worker by default, and authenticate against the current platform key generation
|
||||
|
||||
### Requirement: Run keys use increased entropy
|
||||
|
||||
Platform SHALL generate Run component keys with more entropy than general-purpose platform tokens while preserving existing global token behavior.
|
||||
|
||||
#### Scenario: Run key is created or reset
|
||||
|
||||
- **WHEN** platform creates or resets a Run component key
|
||||
- **THEN** it MUST use a dedicated Run key generator of at least 64 random bytes before URL-safe encoding
|
||||
- **AND** it MUST NOT change auth session or job lease token generation
|
||||
|
||||
### Requirement: Raw executable self-update is supported
|
||||
|
||||
Run self-update SHALL accept raw executable artifacts for supported Run targets.
|
||||
|
||||
#### Scenario: Raw update artifact is staged
|
||||
|
||||
- **WHEN** Run receives a self-update job whose package format is `raw-executable`
|
||||
- **THEN** Run MUST download the artifact through the artifact channel, verify the full checksum, stage it as an executable file, record the staged binary checksum, and use the existing activation/rollback flow
|
||||
|
||||
#### Scenario: Unsupported update package is requested
|
||||
|
||||
- **WHEN** Run receives a self-update job with an unsupported package format
|
||||
- **THEN** Run MUST reject the update before activation and keep the current executable
|
||||
|
||||
### Requirement: Server list exposes run-key reset
|
||||
|
||||
The server-list runtime action menu SHALL expose run-key reset as a destructive runtime operation when the platform reports it available.
|
||||
|
||||
#### Scenario: Operator resets from server list
|
||||
|
||||
- **WHEN** an authorized operator chooses run-key reset from the server-list `运行操作` menu and confirms the destructive action
|
||||
- **THEN** platform_web MUST call the existing run-key reset API, show a tracked operation result, refresh server state, and indicate that a new Run executable must be generated
|
||||
|
||||
### Requirement: Run key reset revokes active control sessions
|
||||
|
||||
Resetting a Run key SHALL immediately invalidate the active Run control session for the server's assigned Run endpoint.
|
||||
|
||||
#### Scenario: Online Run key is reset
|
||||
|
||||
- **WHEN** platform successfully resets the Run key for a server instance with an active Run control session
|
||||
- **THEN** platform MUST remove that session so old deployed executables cannot continue heartbeat or job traffic under the stale key
|
||||
@@ -0,0 +1,47 @@
|
||||
## Prompt Boundaries
|
||||
|
||||
正向提示词: Deliver a secure single-file Run distribution flow for 服务器管理, where generated Run artifacts are raw executables with compile-time platform URL and authorization, self-update accepts raw executables, and server-list run-key reset is available with confirmation.
|
||||
|
||||
方向提示词: Preserve existing platform/run/platform_web boundaries; use platform distribution build input and Run Go `-ldflags -X` injection; keep Client Manager packaging unchanged; verify with focused Go/frontend tests, OpenSpec validation, and `scripts/check-structure.sh`.
|
||||
|
||||
任务边界: Do not add device authorization binding, billing, cloud host sales, AI-provider changes, plugin marketplace expansion, Client Manager packaging changes, raw credential exposure in APIs/UI, or unrelated visual-system changes.
|
||||
|
||||
## 1. OpenSpec
|
||||
|
||||
- [x] 1.1 Define proposal, design, requirements, and task boundaries for single-file Run distribution.
|
||||
- [x] 1.2 Validate the OpenSpec change before implementation completion.
|
||||
|
||||
## 2. Platform
|
||||
|
||||
- [x] 2.1 Add Run-only raw executable package format, artifact filename/content-type presentation, and build-input platform URL.
|
||||
- [x] 2.2 Generate longer Run component keys without changing global token generation.
|
||||
- [x] 2.3 Revoke active Run control sessions when Run key reset succeeds.
|
||||
- [x] 2.4 Update platform tests for raw Run artifacts, key length, build input, reset revocation, and self-update input.
|
||||
|
||||
## 3. Run
|
||||
|
||||
- [x] 3.1 Add compile-time config variables with environment override precedence.
|
||||
- [x] 3.2 Inject Run identity and authorization through Go `-ldflags -X` and upload raw executable bytes for Run builds.
|
||||
- [x] 3.3 Support raw executable self-update staging while preserving archive handling for compatibility/tests where needed.
|
||||
- [x] 3.4 Update Run tests for raw build output, compiled smoke identity, config precedence, and raw self-update.
|
||||
|
||||
## 4. platform_web
|
||||
|
||||
- [x] 4.1 Add run-key reset to the server-list `运行操作` dangerous menu with confirmation and API execution.
|
||||
- [x] 4.2 Update frontend tests and fixtures for raw Run download metadata and reset action coverage.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Run focused `run`, `platform`, and `platform_web` verification.
|
||||
- [x] 5.2 Run `scripts/check-structure.sh`.
|
||||
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `openspec validate secure-single-file-run-distribution --strict`: passed
|
||||
- `cd platform && go test ./service -run 'TestCoreService(ResetRunKey|GeneratesRun|RunDistribution|DistributionBuild)' -count=1`: passed
|
||||
- `cd platform && go test ./validator ./domain ./dto -count=1`: passed
|
||||
- `cd run && go test ./config ./protocol ./runtime -run 'TestLoad|TestWorkerDistribution|TestDistributionBuild|TestValidateDistribution|TestRunSelfUpdate|TestPrepareSelfUpdate|TestWorkerDispatchesSelfUpdate' -count=1`: passed
|
||||
- `cd platform_web && npm test -- --run pages/ConsolePages.test.tsx api/client.test.ts`: passed, 2 files / 25 tests
|
||||
- `scripts/check-structure.sh`: passed
|
||||
- Note: full `run/runtime` suite still needs network bind permissions for unrelated Source RCON/httptest fixtures; focused distribution/self-update coverage was used for this change.
|
||||
@@ -116,6 +116,7 @@ type DistributionBuildInput struct {
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
TargetRelease string
|
||||
PlatformURL string
|
||||
PackageFormat string
|
||||
RepositoryURL string
|
||||
SourceRevision string
|
||||
|
||||
@@ -153,6 +153,7 @@ type DistributionBuildInputResponse struct {
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
PlatformURL string `json:"platformUrl,omitempty"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
@@ -468,6 +469,7 @@ func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) Distr
|
||||
TargetOS: input.TargetOS,
|
||||
TargetArch: input.TargetArch,
|
||||
TargetRelease: input.TargetRelease,
|
||||
PlatformURL: input.PlatformURL,
|
||||
PackageFormat: input.PackageFormat,
|
||||
RepositoryURL: input.RepositoryURL,
|
||||
SourceRevision: input.SourceRevision,
|
||||
|
||||
@@ -250,6 +250,12 @@ func distributionPackageFilename(base string, targetOS string, targetArch string
|
||||
if arch != "" {
|
||||
name += "-" + arch
|
||||
}
|
||||
if format == "raw-executable" {
|
||||
if os == "windows" {
|
||||
return name + ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
if format == "" {
|
||||
format = "bin"
|
||||
}
|
||||
@@ -262,6 +268,8 @@ func packageContentType(packageFormat string) string {
|
||||
return "application/zip"
|
||||
case "tar.gz", "tgz":
|
||||
return "application/gzip"
|
||||
case "raw-executable":
|
||||
return "application/octet-stream"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
@@ -210,6 +210,49 @@ func (svc *CoreService) currentRunSession(runEndpointID string, sessionToken str
|
||||
return domain.CopyRunControlSession(session), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) revokeRunControlSessionForInstance(instance domain.ServerInstance) error {
|
||||
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||
return nil
|
||||
}
|
||||
svc.controlMu.Lock()
|
||||
defer svc.controlMu.Unlock()
|
||||
|
||||
session, err := svc.store.RunControlSessions().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
delete(svc.runSessions, instance.RunEndpointID)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if session.Status == domain.AuthSessionStatusActive {
|
||||
stamp := svc.now()
|
||||
session.Status = domain.AuthSessionStatusRevoked
|
||||
session.RevokedAt = stamp
|
||||
session.UpdatedAt = stamp
|
||||
if err := validator.ValidateRunControlSession(session); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.RunControlSessions().Update(session); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
delete(svc.runSessions, instance.RunEndpointID)
|
||||
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
endpoint.Status = domain.RunEndpointStatusOffline
|
||||
if err := validator.ValidateRunEndpoint(endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.store.RunEndpoints().Update(endpoint)
|
||||
}
|
||||
|
||||
func runAuthenticationError(requireSigned bool) error {
|
||||
if !requireSigned {
|
||||
return validationError("sessionToken is invalid")
|
||||
|
||||
@@ -60,6 +60,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: distribution.PackageFormat,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: executableFilename("run", distribution.TargetOS),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
@@ -73,7 +74,7 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(request.TargetOS),
|
||||
PackageFormat: runPackageFormatForTarget(request.TargetOS),
|
||||
BuildJobID: buildJobID,
|
||||
ArtifactID: artifactID,
|
||||
KeyGeneration: key.Generation,
|
||||
@@ -374,6 +375,11 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do
|
||||
return domain.EncryptedComponentKey{}, err
|
||||
}
|
||||
}
|
||||
if request.ComponentKind == domain.DistributionComponentRun {
|
||||
if err := svc.revokeRunControlSessionForInstance(instance); err != nil {
|
||||
return domain.EncryptedComponentKey{}, err
|
||||
}
|
||||
}
|
||||
if err := svc.recordAuditEvent(user.ID, "runtime-key.reset", "server-instance", instance.ID, domain.AuditResultSuccess, "reset "+string(request.ComponentKind)+" key; previous packages revoked"); err != nil {
|
||||
return domain.EncryptedComponentKey{}, err
|
||||
}
|
||||
@@ -773,6 +779,9 @@ func (svc *CoreService) activeComponentKey(serverInstanceID string, kind domain.
|
||||
|
||||
func (svc *CoreService) createEncryptedComponentKey(serverInstanceID string, kind domain.DistributionComponentKind, componentKey string, generation int) (domain.EncryptedComponentKey, string, error) {
|
||||
plainKey, err := randomToken()
|
||||
if kind == domain.DistributionComponentRun {
|
||||
plainKey, err = randomRunComponentKey()
|
||||
}
|
||||
if err != nil {
|
||||
return domain.EncryptedComponentKey{}, "", err
|
||||
}
|
||||
@@ -1057,6 +1066,11 @@ func validatePluginTarget(plugin domain.GamePlugin, targetOS string) error {
|
||||
return validationError("targetOs is not declared by plugin")
|
||||
}
|
||||
|
||||
func runPackageFormatForTarget(targetOS string) string {
|
||||
_ = targetOS
|
||||
return "raw-executable"
|
||||
}
|
||||
|
||||
func packageFormatForTarget(targetOS string) string {
|
||||
if targetOS == "windows" {
|
||||
return "zip"
|
||||
@@ -1064,6 +1078,13 @@ func packageFormatForTarget(targetOS string) string {
|
||||
return "tar.gz"
|
||||
}
|
||||
|
||||
func runReleasePlatformURL() string {
|
||||
if value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL")); value != "" {
|
||||
return value
|
||||
}
|
||||
return "https://scum.npc0.com"
|
||||
}
|
||||
|
||||
func distributionID(prefix string, parts ...interface{}) string {
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
||||
if err != nil {
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
}
|
||||
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" || distribution.Checksum != "" {
|
||||
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.PackageFormat != "raw-executable" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" || distribution.Checksum != "" {
|
||||
t.Fatalf("unexpected run distribution: %+v", distribution)
|
||||
}
|
||||
job, err := svc.GetJob(distribution.BuildJobID)
|
||||
@@ -62,6 +62,9 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
||||
if config.AuthKey == "" || config.AuthKey == keys[0].EncryptedKey || strings.Contains(distribution.SecretRef, config.AuthKey) {
|
||||
t.Fatalf("run package key leaked through metadata or was not encrypted, config=%+v key=%+v distribution=%+v", config, keys[0], distribution)
|
||||
}
|
||||
if len(config.AuthKey) < 80 {
|
||||
t.Fatalf("expected longer run component key, got length %d", len(config.AuthKey))
|
||||
}
|
||||
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
ComponentKind: domain.DistributionComponentRun,
|
||||
@@ -159,6 +162,7 @@ func TestCoreServiceRuntimeActionsGateDependenciesOnPluginPermission(t *testing.
|
||||
}
|
||||
|
||||
func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) {
|
||||
t.Setenv("PLATFORM_RUN_RELEASE_URL", "https://scum.npc0.com")
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -185,6 +189,19 @@ func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUplo
|
||||
if err != nil || !claim.HasJob || claim.Job.JobID != distribution.BuildJobID {
|
||||
t.Fatalf("claim distribution build job: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
buildInput, err := svc.GetDistributionBuildInput(domain.DistributionBuildInputRequest{
|
||||
RunEndpointID: claim.Job.RunEndpointID,
|
||||
SessionToken: hello.SessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get distribution build input: %v", err)
|
||||
}
|
||||
if buildInput.PlatformURL != "https://scum.npc0.com" || buildInput.PackageFormat != "raw-executable" || buildInput.AuthKey == "" || len(buildInput.AuthKey) < 80 {
|
||||
t.Fatalf("expected raw executable build input with release URL and long key, got %+v", buildInput)
|
||||
}
|
||||
result := domain.RunJobResult{
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
SessionToken: hello.SessionToken,
|
||||
@@ -227,14 +244,14 @@ func TestCoreServiceRunDistributionDownloadUsesTargetPackageName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
}
|
||||
payload := []byte("windows zipped run package")
|
||||
payload := []byte("windows raw run executable")
|
||||
completeDistributionBuild(t, svc, distribution, payload)
|
||||
|
||||
reference, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: distribution.ArtifactID})
|
||||
if err != nil {
|
||||
t.Fatalf("open run artifact download: %v", err)
|
||||
}
|
||||
if reference.Filename != "run-windows-amd64.zip" || reference.ContentType != "application/zip" {
|
||||
if reference.Filename != "run-windows-amd64.exe" || reference.ContentType != "application/octet-stream" {
|
||||
t.Fatalf("expected windows run package metadata, got %+v", reference)
|
||||
}
|
||||
|
||||
@@ -275,7 +292,7 @@ func TestCoreServiceRunDistributionRetryReusesPartialArtifact(t *testing.T) {
|
||||
if distribution.ID != distributionID || distribution.ArtifactID == partialArtifact.ID || distribution.Checksum != "" {
|
||||
t.Fatalf("expected real binary build to ignore legacy config artifact, distribution=%+v artifact=%+v", distribution, partialArtifact)
|
||||
}
|
||||
if distribution.PackageFormat != "zip" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" {
|
||||
if distribution.PackageFormat != "raw-executable" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" {
|
||||
t.Fatalf("unexpected recovered distribution: %+v", distribution)
|
||||
}
|
||||
recoveredConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
|
||||
@@ -332,6 +349,17 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
|
||||
}
|
||||
oldConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
|
||||
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive before reset"))
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.RunEndpointID = instance.RunEndpointID
|
||||
helloRequest.RegistrationToken = oldConfig.AuthKey
|
||||
helloRequest.ServerInstanceID = instance.ID
|
||||
helloRequest.PluginID = instance.PluginID
|
||||
helloRequest.ComponentKind = domain.DistributionComponentRun
|
||||
helloRequest.KeyGeneration = oldConfig.KeyGeneration
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil || !hello.Accepted {
|
||||
t.Fatalf("register old run before reset: hello=%+v err=%v", hello, err)
|
||||
}
|
||||
|
||||
reset, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -343,6 +371,27 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
|
||||
if reset.Generation != 2 || reset.Status != domain.ComponentKeyStatusActive {
|
||||
t.Fatalf("expected reset key generation 2, got %+v", reset)
|
||||
}
|
||||
if _, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
SessionToken: hello.SessionToken,
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
Version: "stale-run",
|
||||
Capacity: domain.RunCapacity{MaxJobs: 1},
|
||||
CapabilityFingerprint: helloRequest.CapabilityReport.Fingerprint,
|
||||
}); err == nil {
|
||||
t.Fatal("expected reset to revoke the active run control session")
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
t.Fatalf("get endpoint after reset: %v", err)
|
||||
}
|
||||
if endpoint.Status != domain.RunEndpointStatusOffline {
|
||||
t.Fatalf("expected reset endpoint to be marked offline, got %+v", endpoint)
|
||||
}
|
||||
endpoint.Status = domain.RunEndpointStatusOnline
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("restore endpoint for regeneration: %v", err)
|
||||
}
|
||||
oldDistribution, err := svc.store.RunDistributions().Get(distribution.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get old distribution: %v", err)
|
||||
|
||||
@@ -2401,6 +2401,14 @@ func randomToken() (string, error) {
|
||||
return base64.RawURLEncoding.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
func randomRunComponentKey() (string, error) {
|
||||
token := make([]byte, 64)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
func verifyPassword(hash string, password string) bool {
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
|
||||
@@ -110,7 +110,7 @@ func ValidateRunDistribution(distribution domain.RunDistribution) error {
|
||||
if distribution.KeyGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration must be positive")
|
||||
}
|
||||
if distribution.PackageFormat != "zip" && distribution.PackageFormat != "tar.gz" {
|
||||
if distribution.PackageFormat != "zip" && distribution.PackageFormat != "tar.gz" && distribution.PackageFormat != "raw-executable" {
|
||||
violations = append(violations, "packageFormat is invalid")
|
||||
}
|
||||
if !strings.HasPrefix(distribution.SecretRef, "secret://runtime-keys/") {
|
||||
|
||||
@@ -132,8 +132,8 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = {
|
||||
artifactId: "artifact-run-1",
|
||||
ownerKind: "server-instance",
|
||||
ownerId: server.id,
|
||||
filename: "run-linux-amd64.zip",
|
||||
contentType: "application/zip",
|
||||
filename: "run-linux-amd64",
|
||||
contentType: "application/octet-stream",
|
||||
sizeBytes: 128,
|
||||
checksum: "sha256:runchecksum",
|
||||
state: "available",
|
||||
@@ -398,7 +398,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
runEndpointId: endpoint.id,
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
packageFormat: "zip",
|
||||
packageFormat: "raw-executable",
|
||||
artifactId: "artifact-run-1",
|
||||
checksum: "sha256:runchecksum",
|
||||
keyGeneration: 1,
|
||||
|
||||
@@ -77,6 +77,13 @@ export const runtimeUpdateStages: RuntimeTaskStage[] = [
|
||||
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
|
||||
];
|
||||
|
||||
export const runtimeKeyResetStages: RuntimeTaskStage[] = [
|
||||
{ key: "scope_check", label: "权限校验", description: "确认当前账号可以重置该服务器的 run 密钥。" },
|
||||
{ key: "confirm_reset", label: "确认重置", description: "记录本次密钥轮换并撤销旧 run 会话。" },
|
||||
{ key: "key_rotate", label: "轮换密钥", description: "生成新密钥代际并撤销旧发行物。" },
|
||||
{ key: "regenerate_hint", label: "等待重发", description: "提示重新生成并部署新的 run 可执行文件。" }
|
||||
];
|
||||
|
||||
export const runtimeDependencyStages: RuntimeTaskStage[] = [
|
||||
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
|
||||
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
|
||||
|
||||
@@ -159,6 +159,14 @@ describe("first-party console pages", () => {
|
||||
expect(serversPageSource).toContain("onClick={() => void refreshMetrics()}");
|
||||
});
|
||||
|
||||
it("exposes run key reset from the compact server list danger menu", () => {
|
||||
expect(serversPageSource).toContain("reset-run-key");
|
||||
expect(serversPageSource).toContain("重置 run 密钥");
|
||||
expect(serversPageSource).toContain("runtimeKeyResetStages");
|
||||
expect(serversPageSource).toContain("platformApiClient.resetRunKey");
|
||||
expect(serversPageSource).toContain("旧 run 会话已失效");
|
||||
});
|
||||
|
||||
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
|
||||
expect(serversPageSource).toContain("<ManagementDialog");
|
||||
expect(serversPageSource).toContain('className="provider-form dialog-form"');
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runtimeBuildStages,
|
||||
runtimeDependencyStages,
|
||||
runtimeDownloadStages,
|
||||
runtimeKeyResetStages,
|
||||
runtimeLogStages,
|
||||
runtimeRunBuildStages,
|
||||
runtimeUpdateStages,
|
||||
@@ -271,6 +272,12 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
openRunTargetSelection(instance);
|
||||
return;
|
||||
}
|
||||
if (action === "reset-run-key") {
|
||||
const confirmed = typeof window === "undefined" || window.confirm("重置 run 密钥会立刻断开已部署 run,并要求重新生成新的 run 可执行文件。继续?");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
|
||||
const intent = quickRuntimeActionLabel(action);
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
|
||||
@@ -319,6 +326,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
|
||||
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
|
||||
}
|
||||
if (action === "reset-run-key") {
|
||||
const key = await platformApiClient.resetRunKey(instance.id);
|
||||
return `run 密钥已重置到第 ${key.generation} 代,旧 run 会话已失效,请重新生成并部署 run`;
|
||||
}
|
||||
if (action === "dependencies-check") {
|
||||
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
|
||||
return `依赖检查任务已排队,job ${job.id}`;
|
||||
@@ -691,6 +702,7 @@ type ServerQuickRuntimeAction =
|
||||
| "generate-run"
|
||||
| "download-run"
|
||||
| "push-run-update"
|
||||
| "reset-run-key"
|
||||
| "generate-client-manager"
|
||||
| "dependencies-check"
|
||||
| "dependencies-install"
|
||||
@@ -873,6 +885,17 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
|
||||
<section className="runtime-action-group" aria-label="危险操作">
|
||||
<span className="runtime-action-group-label">危险操作</span>
|
||||
<div className="runtime-action-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="runtime-action-item danger-command"
|
||||
role="menuitem"
|
||||
disabled={!canManage}
|
||||
title={canManage ? "重置 run 密钥" : "当前账号没有运行操作权限"}
|
||||
onClick={() => chooseQuickAction("reset-run-key")}
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
<span>重置 run 密钥</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="runtime-action-item danger-command"
|
||||
@@ -915,6 +938,8 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||
return "下载 run";
|
||||
case "push-run-update":
|
||||
return "更新 run";
|
||||
case "reset-run-key":
|
||||
return "重置 run 密钥";
|
||||
case "generate-client-manager":
|
||||
return "生成客户端";
|
||||
case "dependencies-check":
|
||||
@@ -941,6 +966,9 @@ function quickRuntimeStages(action: ServerQuickRuntimeAction) {
|
||||
if (action === "push-run-update") {
|
||||
return runtimeUpdateStages;
|
||||
}
|
||||
if (action === "reset-run-key") {
|
||||
return runtimeKeyResetStages;
|
||||
}
|
||||
if (action === "dependencies-check" || action === "dependencies-install") {
|
||||
return runtimeDependencyStages;
|
||||
}
|
||||
@@ -954,6 +982,9 @@ function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number
|
||||
if (action === "push-run-update") {
|
||||
return 2;
|
||||
}
|
||||
if (action === "reset-run-key") {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user