Implement app update orchestration (T-402)

This commit is contained in:
ila
2026-07-19 21:39:33 +08:00
parent fda52a57ca
commit 339beaa9b3
19 changed files with 1095 additions and 11 deletions
+3
View File
@@ -1,7 +1,9 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
) )
@@ -22,6 +24,7 @@ type Platform interface {
Edition() Edition Edition() Edition
IsCompatible(minOS string) (bool, error) IsCompatible(minOS string) (bool, error)
IsRunning(appID, entrypoint string) (bool, error) IsRunning(appID, entrypoint string) (bool, error)
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
Start(command launch.Command) (int, error) Start(command launch.Command) (int, error)
} }
@@ -3,7 +3,9 @@
package windows package windows
import ( import (
"context"
"runtime" "runtime"
"time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
) )
@@ -30,6 +32,10 @@ func (platformStub) IsRunning(string, string) (bool, error) {
return false, ErrUnsupported return false, ErrUnsupported
} }
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
return ErrUnsupported
}
func (platformStub) Start(launch.Command) (int, error) { func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported return 0, ErrUnsupported
} }
@@ -3,8 +3,10 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"testing" "testing"
"time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
) )
@@ -17,6 +19,9 @@ func TestPlatformStubFailsClosed(t *testing.T) {
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) { if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err) t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
} }
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
}
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) { if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Start() error = %v, want ErrUnsupported", err) t.Fatalf("Start() error = %v, want ErrUnsupported", err)
} }
@@ -1,9 +1,11 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func TestPlatformStubContract(t *testing.T) { func TestPlatformStubContract(t *testing.T) {
@@ -78,6 +80,71 @@ func TestVersionSupports(t *testing.T) {
} }
} }
func TestWaitForExit(t *testing.T) {
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
errSnapshot := errors.New("snapshot failed")
tests := []struct {
name string
ctx context.Context
running []bool
runErr error
timeout time.Duration
wantErr error
wantSleeps int
}{
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
clock := &fakeExitWaitClock{now: initial}
index := 0
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
if test.runErr != nil {
return false, test.runErr
}
if index >= len(test.running) {
return test.running[len(test.running)-1], nil
}
running := test.running[index]
index++
return running, nil
}, clock)
if !errors.Is(err, test.wantErr) {
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
}
if clock.sleeps != test.wantSleeps {
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
}
})
}
}
func canceledWaitContext() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}
type fakeExitWaitClock struct {
now time.Time
sleeps int
}
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
clock.sleeps++
clock.now = clock.now.Add(duration)
return nil
}
type snapshotItem struct { type snapshotItem struct {
path string path string
err error err error
@@ -3,11 +3,13 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"unsafe" "unsafe"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
@@ -49,6 +51,12 @@ func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
}) })
} }
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
return waitForExit(ctx, timeout, func() (bool, error) {
return target.IsRunning(appID, entrypoint)
}, systemExitWaitClock{})
}
func (platform) Start(command launch.Command) (int, error) { func (platform) Start(command launch.Command) (int, error) {
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) { if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
return 0, fmt.Errorf("launch command must contain absolute paths") return 0, fmt.Errorf("launch command must contain absolute paths")
+64
View File
@@ -0,0 +1,64 @@
package windows
import (
"context"
"fmt"
"time"
)
const exitPollInterval = 250 * time.Millisecond
type exitWaitClock interface {
Now() time.Time
Wait(context.Context, time.Duration) error
}
type systemExitWaitClock struct{}
func (systemExitWaitClock) Now() time.Time {
return time.Now()
}
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
if ctx == nil {
return fmt.Errorf("wait context is required")
}
if timeout <= 0 {
return fmt.Errorf("exit timeout must be positive")
}
if err := ctx.Err(); err != nil {
return err
}
deadline := clock.Now().Add(timeout)
for {
isRunning, err := running()
if err != nil {
return err
}
if !isRunning {
return nil
}
remaining := deadline.Sub(clock.Now())
if remaining <= 0 {
return context.DeadlineExceeded
}
interval := exitPollInterval
if remaining < interval {
interval = remaining
}
if err := clock.Wait(ctx, interval); err != nil {
return err
}
}
}
+3
View File
@@ -1,7 +1,9 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
) )
@@ -22,6 +24,7 @@ type Platform interface {
Edition() Edition Edition() Edition
IsCompatible(minOS string) (bool, error) IsCompatible(minOS string) (bool, error)
IsRunning(appID, entrypoint string) (bool, error) IsRunning(appID, entrypoint string) (bool, error)
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
Start(command launch.Command) (int, error) Start(command launch.Command) (int, error)
} }
@@ -3,7 +3,9 @@
package windows package windows
import ( import (
"context"
"runtime" "runtime"
"time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
) )
@@ -30,6 +32,10 @@ func (platformStub) IsRunning(string, string) (bool, error) {
return false, ErrUnsupported return false, ErrUnsupported
} }
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
return ErrUnsupported
}
func (platformStub) Start(launch.Command) (int, error) { func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported return 0, ErrUnsupported
} }
@@ -3,8 +3,10 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"testing" "testing"
"time"
"softbox.local/core/application/launch" "softbox.local/core/application/launch"
) )
@@ -17,6 +19,9 @@ func TestPlatformStubFailsClosed(t *testing.T) {
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) { if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err) t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
} }
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
}
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) { if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Start() error = %v, want ErrUnsupported", err) t.Fatalf("Start() error = %v, want ErrUnsupported", err)
} }
@@ -1,9 +1,11 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func TestPlatformStubContract(t *testing.T) { func TestPlatformStubContract(t *testing.T) {
@@ -78,6 +80,71 @@ func TestVersionSupports(t *testing.T) {
} }
} }
func TestWaitForExit(t *testing.T) {
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
errSnapshot := errors.New("snapshot failed")
tests := []struct {
name string
ctx context.Context
running []bool
runErr error
timeout time.Duration
wantErr error
wantSleeps int
}{
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
clock := &fakeExitWaitClock{now: initial}
index := 0
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
if test.runErr != nil {
return false, test.runErr
}
if index >= len(test.running) {
return test.running[len(test.running)-1], nil
}
running := test.running[index]
index++
return running, nil
}, clock)
if !errors.Is(err, test.wantErr) {
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
}
if clock.sleeps != test.wantSleeps {
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
}
})
}
}
func canceledWaitContext() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}
type fakeExitWaitClock struct {
now time.Time
sleeps int
}
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
clock.sleeps++
clock.now = clock.now.Add(duration)
return nil
}
type snapshotItem struct { type snapshotItem struct {
path string path string
err error err error
@@ -3,11 +3,13 @@
package windows package windows
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"unsafe" "unsafe"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
@@ -49,6 +51,12 @@ func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
}) })
} }
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
return waitForExit(ctx, timeout, func() (bool, error) {
return target.IsRunning(appID, entrypoint)
}, systemExitWaitClock{})
}
func (platform) Start(command launch.Command) (int, error) { func (platform) Start(command launch.Command) (int, error) {
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) { if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
return 0, fmt.Errorf("launch command must contain absolute paths") return 0, fmt.Errorf("launch command must contain absolute paths")
+64
View File
@@ -0,0 +1,64 @@
package windows
import (
"context"
"fmt"
"time"
)
const exitPollInterval = 250 * time.Millisecond
type exitWaitClock interface {
Now() time.Time
Wait(context.Context, time.Duration) error
}
type systemExitWaitClock struct{}
func (systemExitWaitClock) Now() time.Time {
return time.Now()
}
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
if ctx == nil {
return fmt.Errorf("wait context is required")
}
if timeout <= 0 {
return fmt.Errorf("exit timeout must be positive")
}
if err := ctx.Err(); err != nil {
return err
}
deadline := clock.Now().Add(timeout)
for {
isRunning, err := running()
if err != nil {
return err
}
if !isRunning {
return nil
}
remaining := deadline.Sub(clock.Now())
if remaining <= 0 {
return context.DeadlineExceeded
}
interval := exitPollInterval
if remaining < interval {
interval = remaining
}
if err := clock.Wait(ctx, interval); err != nil {
return err
}
}
}
+322
View File
@@ -0,0 +1,322 @@
// Package update contains the core-only application update orchestration.
// It never obtains downloads, controls a window, or terminates a process.
package update
import (
"context"
"errors"
"fmt"
"os"
"regexp"
"time"
"softbox.local/core/application/install"
"softbox.local/core/domain"
"softbox.local/core/internal/safepath"
"softbox.local/core/storage"
)
const (
minCloseTimeout = time.Second
maxCloseTimeout = 10 * time.Minute
)
var (
ErrUpdateConfig = errors.New("invalid update service configuration")
ErrUpdateRequest = errors.New("invalid update request")
ErrAppNotInstalled = errors.New("app is not installed for update")
ErrUpdateMetadata = errors.New("installed update metadata is invalid")
ErrUpdateTargetUnsafe = errors.New("installed update target is unsafe")
ErrUpdateNotAvailable = errors.New("catalog target is not newer than installed version")
ErrTargetStateCheck = errors.New("update target state check failed")
ErrCloseConfirmation = errors.New("update close confirmation failed")
ErrCloseDeclined = errors.New("update close request was declined")
ErrExitWait = errors.New("wait for update target exit failed")
updateAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
)
// FailureCode is the stable, non-localized result of a failed update attempt.
type FailureCode string
const (
FailureCodeNotInstalled FailureCode = "not_installed"
FailureCodeUpdateMetadataInvalid FailureCode = "update_metadata_invalid"
FailureCodeUpdateTargetUnsafe FailureCode = "update_target_unsafe"
FailureCodeUpdateNotAvailable FailureCode = "update_not_available"
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
FailureCodeCloseConfirmationFailed FailureCode = "close_confirmation_unavailable"
FailureCodeCloseDeclined FailureCode = "close_declined"
FailureCodeExitWaitCanceled FailureCode = "exit_wait_canceled"
FailureCodeExitWaitTimedOut FailureCode = "exit_wait_timeout"
FailureCodeExitWaitFailed FailureCode = "exit_wait_unavailable"
FailureCodeInstallFailed FailureCode = "install_failed"
)
// Error preserves a stable update code and the diagnostic cause. Installation
// errors remain in the unwrap chain so their stage and code stay observable.
type Error struct {
Code FailureCode
Err error
}
func (err *Error) Error() string {
return fmt.Sprintf("update (%s): %v", err.Code, err.Err)
}
func (err *Error) Unwrap() error {
return err.Err
}
// Request carries the trusted Catalog selection and completed-file candidate
// already required by install.InstallService. It accepts no process path,
// command, URL, or UI-provided version.
type Request struct {
Install install.InstallRequest
}
// Result reports the version committed by the existing installer.
type Result struct {
AppID string
Version string
}
// InstalledAppResolver supplies one verified record and its real current root.
type InstalledAppResolver interface {
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
}
// TargetStateChecker observes the precise old entrypoint before a close
// request. It must not turn a query failure into a stopped result.
type TargetStateChecker interface {
IsRunning(appID string, entrypointPath string) (bool, error)
}
// CloseConfirmer obtains the user's decision outside of Gio Layout. It must
// not close or terminate the process itself.
type CloseConfirmer interface {
ConfirmClose(ctx context.Context, appID string) (bool, error)
}
// ExitWaiter waits only for natural exit of one precise entrypoint. A timeout
// is reported as context.DeadlineExceeded and cancellation retains ctx.Err().
type ExitWaiter interface {
WaitForExit(ctx context.Context, appID, entrypointPath string, timeout time.Duration) error
}
// InstallRunner is the verified installation boundary. The update service
// delegates all staging, switch, rollback and second running-state checks to it.
type InstallRunner interface {
Install(request install.InstallRequest) (install.InstallResult, error)
}
// ServiceConfig makes the close confirmation and exit-wait policy explicit.
type ServiceConfig struct {
Records InstalledAppResolver
TargetState TargetStateChecker
Confirmation CloseConfirmer
ExitWaiter ExitWaiter
Installer InstallRunner
CloseTimeout time.Duration
}
// Service implements a safe, non-destructive update flow.
type Service struct {
records InstalledAppResolver
targetState TargetStateChecker
confirmation CloseConfirmer
exitWaiter ExitWaiter
installer InstallRunner
closeTimeout time.Duration
}
// NewService validates every dependency. There is deliberately no default
// confirmation, wait policy, or installer implementation.
func NewService(config ServiceConfig) (*Service, error) {
if config.Records == nil {
return nil, fmt.Errorf("%w: installed app resolver is required", ErrUpdateConfig)
}
if config.TargetState == nil {
return nil, fmt.Errorf("%w: target state checker is required", ErrUpdateConfig)
}
if config.Confirmation == nil {
return nil, fmt.Errorf("%w: close confirmer is required", ErrUpdateConfig)
}
if config.ExitWaiter == nil {
return nil, fmt.Errorf("%w: exit waiter is required", ErrUpdateConfig)
}
if config.Installer == nil {
return nil, fmt.Errorf("%w: install runner is required", ErrUpdateConfig)
}
if config.CloseTimeout < minCloseTimeout || config.CloseTimeout > maxCloseTimeout {
return nil, fmt.Errorf("%w: close timeout must be between %s and %s", ErrUpdateConfig, minCloseTimeout, maxCloseTimeout)
}
return &Service{
records: config.Records,
targetState: config.TargetState,
confirmation: config.Confirmation,
exitWaiter: config.ExitWaiter,
installer: config.Installer,
closeTimeout: config.CloseTimeout,
}, nil
}
// Update confirms a running app may be closed, waits for its natural exit, and
// then delegates to the verified installer. The installer repeats target-state
// checks before extraction and immediately before replacing current.
func (service *Service) Update(ctx context.Context, request Request) (Result, error) {
if ctx == nil {
return Result{}, updateError(ErrUpdateRequest)
}
if err := ctx.Err(); err != nil {
return Result{}, updateError(err)
}
appID := request.Install.Entry.App.ID
if !updateAppIDPattern.MatchString(appID) {
return Result{}, updateError(ErrUpdateRequest)
}
record, current, err := service.records.ResolveCurrent(appID)
if err != nil {
return Result{}, updateResolverError(err)
}
entrypoint, err := updateEntrypoint(record, current)
if err != nil {
return Result{}, updateError(err)
}
if err := validateTargetVersion(record, request.Install); err != nil {
return Result{}, updateError(err)
}
running, err := service.targetState.IsRunning(record.ID, entrypoint)
if err != nil {
return Result{}, updateError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
}
if running {
confirmed, err := service.confirmation.ConfirmClose(ctx, record.ID)
if err != nil {
return Result{}, updateError(fmt.Errorf("%w: %w", ErrCloseConfirmation, err))
}
if !confirmed {
return Result{}, updateError(ErrCloseDeclined)
}
if err := service.exitWaiter.WaitForExit(ctx, record.ID, entrypoint, service.closeTimeout); err != nil {
return Result{}, updateError(fmt.Errorf("%w: %w", ErrExitWait, err))
}
}
installed, err := service.installer.Install(request.Install)
if err != nil {
return Result{}, updateError(err)
}
return Result{AppID: installed.AppID, Version: installed.Version}, nil
}
func validateTargetVersion(record storage.InstalledApp, request install.InstallRequest) error {
app := request.Entry.App
if request.DownloadPath == "" || app.ID != record.ID || request.Entry.Package == nil || !request.Entry.Installable {
return ErrUpdateRequest
}
if request.Architecture != "386" && request.Architecture != "amd64" {
return ErrUpdateRequest
}
publishedPackage, exists := app.Packages[request.Architecture]
if !exists || publishedPackage != *request.Entry.Package {
return ErrUpdateRequest
}
comparison, err := domain.CompareSemVer(app.Version, record.Version)
if err != nil {
return fmt.Errorf("%w: %v", ErrUpdateRequest, err)
}
if comparison <= 0 {
return ErrUpdateNotAvailable
}
return nil
}
func updateEntrypoint(record storage.InstalledApp, current string) (string, error) {
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
return "", ErrUpdateMetadata
}
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateMetadata, err)
}
if record.WorkingDirectory != "." {
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
return "", fmt.Errorf("%w: working directory: %v", ErrUpdateMetadata, err)
}
}
if !validMinOS(record.MinOS) {
return "", ErrUpdateMetadata
}
if !containsEntrypoint(record.Files, record.Entrypoint) {
return "", fmt.Errorf("%w: entrypoint is not in installed files", ErrUpdateMetadata)
}
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
if err != nil {
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateTargetUnsafe, err)
}
info, err := os.Lstat(entrypoint)
if os.IsNotExist(err) {
return "", fmt.Errorf("%w: entrypoint is missing", ErrUpdateTargetUnsafe)
}
if err != nil {
return "", fmt.Errorf("%w: inspect entrypoint: %w", ErrUpdateTargetUnsafe, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return "", fmt.Errorf("%w: entrypoint is not a regular file", ErrUpdateTargetUnsafe)
}
return entrypoint, nil
}
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
for _, file := range files {
if file.Path == entrypoint {
return true
}
}
return false
}
func validMinOS(minOS string) bool {
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
}
func updateResolverError(err error) error {
if errors.Is(err, os.ErrNotExist) {
return updateError(ErrAppNotInstalled)
}
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
return updateError(fmt.Errorf("%w: %w", ErrUpdateTargetUnsafe, err))
}
return updateError(fmt.Errorf("resolve installed app: %w", err))
}
func updateError(err error) error {
return &Error{Code: failureCodeFor(err), Err: err}
}
func failureCodeFor(err error) FailureCode {
switch {
case errors.Is(err, ErrAppNotInstalled):
return FailureCodeNotInstalled
case errors.Is(err, ErrUpdateMetadata):
return FailureCodeUpdateMetadataInvalid
case errors.Is(err, ErrUpdateTargetUnsafe):
return FailureCodeUpdateTargetUnsafe
case errors.Is(err, ErrUpdateNotAvailable):
return FailureCodeUpdateNotAvailable
case errors.Is(err, ErrTargetStateCheck):
return FailureCodeTargetStateUnavailable
case errors.Is(err, ErrCloseDeclined):
return FailureCodeCloseDeclined
case errors.Is(err, context.Canceled):
return FailureCodeExitWaitCanceled
case errors.Is(err, context.DeadlineExceeded):
return FailureCodeExitWaitTimedOut
case errors.Is(err, ErrCloseConfirmation):
return FailureCodeCloseConfirmationFailed
case errors.Is(err, ErrExitWait):
return FailureCodeExitWaitFailed
default:
return FailureCodeInstallFailed
}
}
+435
View File
@@ -0,0 +1,435 @@
package update
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"softbox.local/core/application/install"
"softbox.local/core/catalog"
"softbox.local/core/storage"
)
func TestServiceUpdatesAfterNaturalExit(t *testing.T) {
store, appRoot := seedInstalledApp(t, "1.0.0")
confirmation := &recordingConfirmation{confirmed: true}
waiter := &recordingExitWaiter{}
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
return true, nil
}), confirmation, waiter, installer)
result, err := service.Update(context.Background(), updateRequest("1.1.0"))
if err != nil {
t.Fatalf("Update() error = %v", err)
}
if result != (Result{AppID: "test-app", Version: "1.1.0"}) {
t.Fatalf("result = %#v", result)
}
if confirmation.calls != 1 || waiter.calls != 1 || installer.calls != 1 {
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 1 each", confirmation.calls, waiter.calls, installer.calls)
}
if waiter.appID != "test-app" || waiter.entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") || waiter.timeout != 30*time.Second {
t.Fatalf("waiter input = %#v", waiter)
}
if installer.request.Entry.App.Version != "1.1.0" {
t.Fatalf("installer request = %#v", installer.request)
}
}
func TestServiceSkipsCloseFlowWhenTargetAlreadyStopped(t *testing.T) {
store, _ := seedInstalledApp(t, "1.0.0")
confirmation := &recordingConfirmation{confirmed: true}
waiter := &recordingExitWaiter{}
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
return false, nil
}), confirmation, waiter, installer)
if _, err := service.Update(context.Background(), updateRequest("1.1.0")); err != nil {
t.Fatalf("Update() error = %v", err)
}
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 1 {
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 0/0/1", confirmation.calls, waiter.calls, installer.calls)
}
}
func TestServiceRejectsInvalidOrUnreadyUpdatesBeforeSideEffects(t *testing.T) {
tests := []struct {
name string
prepare func(t *testing.T) (InstalledAppResolver, Request)
wantErr error
wantCode FailureCode
}{
{
name: "not installed",
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
return storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")), updateRequest("1.1.0")
},
wantErr: ErrAppNotInstalled,
wantCode: FailureCodeNotInstalled,
},
{
name: "legacy record lacks launch metadata",
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
store, _ := seedInstalledApp(t, "1.0.0")
record, found, err := store.Read("test-app")
if err != nil || !found {
t.Fatalf("Read() found=%t err=%v", found, err)
}
record.Entrypoint = ""
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
return store, updateRequest("1.1.0")
},
wantErr: ErrUpdateMetadata,
wantCode: FailureCodeUpdateMetadataInvalid,
},
{
name: "same version",
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
store, _ := seedInstalledApp(t, "1.0.0")
return store, updateRequest("1.0.0")
},
wantErr: ErrUpdateNotAvailable,
wantCode: FailureCodeUpdateNotAvailable,
},
{
name: "catalog package mismatches selected architecture",
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
store, _ := seedInstalledApp(t, "1.0.0")
request := updateRequest("1.1.0")
different := *request.Install.Entry.Package
different.Size++
request.Install.Entry.Package = &different
return store, request
},
wantErr: ErrUpdateRequest,
wantCode: FailureCodeInstallFailed,
},
{
name: "unsafe current layout",
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
return resolverFunc(func(string) (storage.InstalledApp, string, error) {
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
}), updateRequest("1.1.0")
},
wantErr: ErrUpdateTargetUnsafe,
wantCode: FailureCodeUpdateTargetUnsafe,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
records, request := test.prepare(t)
confirmation := &recordingConfirmation{confirmed: true}
waiter := &recordingExitWaiter{}
installer := &recordingInstaller{}
service := newService(t, records, targetStateFunc(func(string, string) (bool, error) {
return true, nil
}), confirmation, waiter, installer)
_, err := service.Update(context.Background(), request)
if !errors.Is(err, test.wantErr) {
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
}
if code := updateErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
}
})
}
}
func TestServiceCloseFailuresDoNotInstallOrModifyUserRoots(t *testing.T) {
errConfirmation := errors.New("confirmation unavailable")
errWait := errors.New("snapshot unavailable")
tests := []struct {
name string
context func() context.Context
confirm recordingConfirmation
wait recordingExitWaiter
wantErr error
wantCode FailureCode
}{
{
name: "close declined",
context: context.Background,
confirm: recordingConfirmation{confirmed: false},
wantErr: ErrCloseDeclined,
wantCode: FailureCodeCloseDeclined,
},
{
name: "confirmation unavailable",
context: context.Background,
confirm: recordingConfirmation{err: errConfirmation},
wantErr: errConfirmation,
wantCode: FailureCodeCloseConfirmationFailed,
},
{
name: "wait canceled",
context: context.Background,
confirm: recordingConfirmation{confirmed: true},
wait: recordingExitWaiter{err: context.Canceled},
wantErr: context.Canceled,
wantCode: FailureCodeExitWaitCanceled,
},
{
name: "wait timed out",
context: context.Background,
confirm: recordingConfirmation{confirmed: true},
wait: recordingExitWaiter{err: context.DeadlineExceeded},
wantErr: context.DeadlineExceeded,
wantCode: FailureCodeExitWaitTimedOut,
},
{
name: "wait unavailable",
context: context.Background,
confirm: recordingConfirmation{confirmed: true},
wait: recordingExitWaiter{err: errWait},
wantErr: errWait,
wantCode: FailureCodeExitWaitFailed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store, appRoot := seedInstalledApp(t, "1.0.0")
data := filepath.Join(filepath.Dir(appRoot), "..", "data", "test-app", "data.txt")
license := filepath.Join(filepath.Dir(appRoot), "..", "licenses", "license.txt")
for _, path := range []string{data, license} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("MkdirAll(%q): %v", path, err)
}
if err := os.WriteFile(path, []byte(path), 0o600); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}
installer := &recordingInstaller{}
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
return true, nil
}), &test.confirm, &test.wait, installer)
_, err := service.Update(test.context(), updateRequest("1.1.0"))
if !errors.Is(err, test.wantErr) {
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
}
if code := updateErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if installer.calls != 0 {
t.Fatalf("installer calls = %d, want 0", installer.calls)
}
for _, path := range []string{data, license} {
contents, readErr := os.ReadFile(path)
if readErr != nil || string(contents) != path {
t.Fatalf("protected file %q = %q, err=%v", path, contents, readErr)
}
}
})
}
}
func TestServiceTargetStateFailureDoesNotRequestCloseOrInstall(t *testing.T) {
store, _ := seedInstalledApp(t, "1.0.0")
expected := errors.New("Toolhelp unavailable")
confirmation := &recordingConfirmation{confirmed: true}
waiter := &recordingExitWaiter{}
installer := &recordingInstaller{}
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
return false, expected
}), confirmation, waiter, installer)
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
if !errors.Is(err, expected) {
t.Fatalf("Update() error = %v, want %v", err, expected)
}
if code := updateErrorCode(t, err); code != FailureCodeTargetStateUnavailable {
t.Fatalf("code = %q, want %q", code, FailureCodeTargetStateUnavailable)
}
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
}
}
func TestServicePreservesInstallErrorForRestartRace(t *testing.T) {
store, _ := seedInstalledApp(t, "1.0.0")
installErr := &install.InstallError{Stage: install.InstallStagePreflight, Code: install.FailureCodeAppRunning, Err: install.ErrTargetRunning}
installer := &recordingInstaller{err: installErr}
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
return false, nil
}), &recordingConfirmation{}, &recordingExitWaiter{}, installer)
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
if !errors.Is(err, install.ErrTargetRunning) {
t.Fatalf("Update() error = %v, want preserved %v", err, install.ErrTargetRunning)
}
if code := updateErrorCode(t, err); code != FailureCodeInstallFailed {
t.Fatalf("code = %q, want %q", code, FailureCodeInstallFailed)
}
}
func TestNewServiceRequiresAllDependenciesAndBoundedTimeout(t *testing.T) {
store, _ := seedInstalledApp(t, "1.0.0")
config := ServiceConfig{
Records: store,
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
Confirmation: &recordingConfirmation{},
ExitWaiter: &recordingExitWaiter{},
Installer: &recordingInstaller{},
CloseTimeout: 30 * time.Second,
}
for _, test := range []struct {
name string
mutate func(*ServiceConfig)
}{
{"records", func(config *ServiceConfig) { config.Records = nil }},
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
{"confirmation", func(config *ServiceConfig) { config.Confirmation = nil }},
{"exit waiter", func(config *ServiceConfig) { config.ExitWaiter = nil }},
{"installer", func(config *ServiceConfig) { config.Installer = nil }},
{"timeout too short", func(config *ServiceConfig) { config.CloseTimeout = time.Millisecond }},
{"timeout too long", func(config *ServiceConfig) { config.CloseTimeout = 11 * time.Minute }},
} {
t.Run(test.name, func(t *testing.T) {
candidate := config
test.mutate(&candidate)
if _, err := NewService(candidate); !errors.Is(err, ErrUpdateConfig) {
t.Fatalf("NewService() error = %v, want %v", err, ErrUpdateConfig)
}
})
}
}
func seedInstalledApp(t *testing.T, version string) (*storage.InstalledAppStore, string) {
t.Helper()
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
record := storage.InstalledApp{
SchemaVersion: 1,
ID: "test-app",
Version: version,
Architecture: "amd64",
Channel: "stable",
Entrypoint: "bin/App.exe",
WorkingDirectory: "bin",
MinOS: "windows-10",
Files: []storage.InstalledFile{{
Path: "bin/App.exe",
Size: 1,
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
}},
}
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
appRoot := filepath.Join(appsRoot, record.ID)
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(entrypoint, []byte("old executable"), 0o700); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
return store, appRoot
}
func updateRequest(version string) Request {
pkg := catalog.Package{URL: "https://download.invalid/test-app.zip", Size: 1, SHA256: "0000000000000000000000000000000000000000000000000000000000000000", Signature: "placeholder"}
return Request{Install: install.InstallRequest{
Entry: catalog.Entry{
Installable: true,
Package: &pkg,
App: catalog.App{
ID: "test-app",
Version: version,
Packages: map[catalog.Architecture]catalog.Package{
catalog.ArchitectureAMD64: pkg,
},
},
},
Architecture: catalog.ArchitectureAMD64,
DownloadPath: "untrusted-candidate.download",
}}
}
func newService(t *testing.T, records InstalledAppResolver, target TargetStateChecker, confirmation CloseConfirmer, waiter ExitWaiter, installer InstallRunner) *Service {
t.Helper()
service, err := NewService(ServiceConfig{
Records: records,
TargetState: target,
Confirmation: confirmation,
ExitWaiter: waiter,
Installer: installer,
CloseTimeout: 30 * time.Second,
})
if err != nil {
t.Fatalf("NewService() error = %v", err)
}
return service
}
func updateErrorCode(t *testing.T, err error) FailureCode {
t.Helper()
var updateErr *Error
if !errors.As(err, &updateErr) {
t.Fatalf("error = %v, want update Error", err)
}
return updateErr.Code
}
type resolverFunc func(string) (storage.InstalledApp, string, error)
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
return resolver(appID)
}
type targetStateFunc func(string, string) (bool, error)
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
return checker(appID, entrypoint)
}
type recordingConfirmation struct {
confirmed bool
err error
calls int
}
func (confirmation *recordingConfirmation) ConfirmClose(context.Context, string) (bool, error) {
confirmation.calls++
return confirmation.confirmed, confirmation.err
}
type recordingExitWaiter struct {
err error
calls int
appID string
entrypoint string
timeout time.Duration
}
func (waiter *recordingExitWaiter) WaitForExit(_ context.Context, appID, entrypoint string, timeout time.Duration) error {
waiter.calls++
waiter.appID = appID
waiter.entrypoint = entrypoint
waiter.timeout = timeout
return waiter.err
}
type recordingInstaller struct {
result install.InstallResult
err error
calls int
request install.InstallRequest
}
func (installer *recordingInstaller) Install(request install.InstallRequest) (install.InstallResult, error) {
installer.calls++
installer.request = request
return installer.result, installer.err
}
+2 -2
View File
@@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
## 当前阶段 ## 当前阶段
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败恢复落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已正式落成,下一步实现子软件更新的确认关闭与自然退出等待编排。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。 当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败恢复落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已完成子软件更新的确认关闭、自然退出等待和可信安装器编排。下一步正式落成 T-403。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
优先路径: 优先路径:
@@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已正式落成,当前执行子软件更新编排;完成后再继续 T-403 与 Phase 5-6。T-601 仍须补真实 Windows 环境的断电/干扰注入。 5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已完成关闭确认、自然退出等待和更新编排;下一步正式落成 T-403。T-601 仍须补真实 Windows 环境的断电/干扰注入。
## 领取任务规则 ## 领取任务规则
+1 -1
View File
@@ -179,7 +179,7 @@ T-301 下载队列:
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。T-401 在 `Switcher` 的 current→backup 临界区重复精确运行状态检查,仅明确运行中映射 `app_running` 并清理 staging,checker 故障映射 `target_state_unavailable`;没有旧 current 的首次安装跳过该 hook。`core/application/launch` 保持纯 core 接口边界,按受控 current/记录、兼容、授权、运行状态、平台启动器的顺序 fail closed;Toolhelp、`RtlGetVersion` 和无参数进程创建/固定 `runas` elevation 仅在两端 `platform/windows`,并有非 Windows 不支持 stub。当前 cmd 没有可信 Catalog、许可证或下载完成文件的生产装配来源,所以没有注入 allow-all 授权或伪装端到端启动按钮;Gio Layout 仍只处理内存事件。 Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。T-401 在 `Switcher` 的 current→backup 临界区重复精确运行状态检查,仅明确运行中映射 `app_running` 并清理 staging,checker 故障映射 `target_state_unavailable`;没有旧 current 的首次安装跳过该 hook。`core/application/launch` 保持纯 core 接口边界,按受控 current/记录、兼容、授权、运行状态、平台启动器的顺序 fail closed;Toolhelp、`RtlGetVersion` 和无参数进程创建/固定 `runas` elevation 仅在两端 `platform/windows`,并有非 Windows 不支持 stub。T-402 的 `core/application/update` 先验证已装记录与严格更新版本,再在后台按“检测运行→取得关闭确认→有限自然退出等待→委托 InstallService”的顺序编排;未运行时直接委托安装器。等待器只以 Toolhelp 全路径身份轮询,context 取消、超时和检测错误均 fail closed,绝不强杀。InstallService 保留其 staging 前和 switch 临界区两次复查,覆盖等待结束后的重新启动竞态;更新 use case 从不改 transaction 或 data/licenses。当前 cmd 没有可信 Catalog、许可证或下载完成文件的生产装配来源,所以没有注入 allow-all 授权或伪装端到端启动/更新按钮;Gio Layout 仍只处理内存事件。
Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。 Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。
+18
View File
@@ -382,6 +382,24 @@ SoftBoxUpdater.exe --pid <主程序PID> --staging <暂存目录> --target <目
v1:进程快照判断运行 → 提示用户保存关闭 → 等待正常退出 → 超时取消更新,**不默认强杀**。 v1:进程快照判断运行 → 提示用户保存关闭 → 等待正常退出 → 超时取消更新,**不默认强杀**。
V1.1:命名管道 `\\.\pipe\softbox.<app-id>`,盒子发送 `{"command": "prepare_update", "request_id": "..."}`,子软件保存数据回复 `ready` 后自行退出。 V1.1:命名管道 `\\.\pipe\softbox.<app-id>`,盒子发送 `{"command": "prepare_update", "request_id": "..."}`,子软件保存数据回复 `ready` 后自行退出。
### 6.1 子软件更新编排 v1
`core/application/update` 只接受外层已经从验证/过滤 Catalog 取得的 `install.InstallRequest`,并再次要求 package 与 architecture 精确匹配。它先读取受控本地 `installed-app.json` + `current`,要求旧记录有完整且安全的启动元数据,并且 Catalog 目标版本严格高于本地 SemVer;不得由 UI 或下载事件提供 EXE、路径、参数、URL、版本或 hash。
若旧 entrypoint 未运行,编排直接委托 `InstallService`;若运行,则顺序固定为:后台取得关闭确认 → 按配置的 1 秒至 10 分钟上限等待**自然退出** → 委托 `InstallService`。等待器仅轮询 T-401 的完整 entrypoint 身份,支持 context 取消;不能调用 kill/TerminateProcess、shell、脚本或 IPC。等待完成后安装器仍在 staging 前和 `current → backup` 紧邻前复查运行状态,因此用户重新启动旧版本会安全返回既有 `app_running`,不覆盖 current。
| code | 含义 |
| --- | --- |
| `not_installed` | 没有受控安装记录 |
| `update_metadata_invalid` / `update_target_unsafe` | 历史记录缺启动元数据,或旧 entrypoint/current 布局不安全 |
| `update_not_available` | Catalog 版本与本地相同或更低 |
| `target_state_unavailable` | 无法可靠判定旧 entrypoint 是否运行 |
| `close_confirmation_unavailable` / `close_declined` | 无法取得关闭确认,或用户拒绝关闭 |
| `exit_wait_canceled` / `exit_wait_timeout` / `exit_wait_unavailable` | 等待被取消、达到上限,或进程快照等待不可用 |
| `install_failed` | 已委托安装器但安装失败;原始 `InstallError` 链仍保留其 stage/code |
更新编排自身不写 `current`、`backup`、transaction、`data/` 或 `licenses/`。production cmd 目前没有可信 Catalog、许可证和 completed-download 消费来源,也没有关闭确认 UI,因此尚未装配该用例;不得用 allow-all 授权或测试 fake 宣称更新闭环已经启用。
## 待实现时确认 ## 待实现时确认
- 清单密钥 ID/轮换字段定稿后同步 `schemas/`。 - 清单密钥 ID/轮换字段定稿后同步 `schemas/`。
+6 -5
View File
@@ -13,22 +13,23 @@
## 当前快照 ## 当前快照
- 日期:2026-07-19 - 日期:2026-07-19
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查已完成 - 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排已完成
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 - 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
- T-402 更新用例:`core/application/update` 只接收外层可信的 install selection,验证已装版本/旧 entrypoint后,运行中才请求关闭确认并以 1 秒~10 分钟上限等待自然退出,随后委托已有 `InstallService` 的双重运行复查与 rollback;取消、超时、Toolhelp 检测错误和安装错误均保留稳定 code/错误链,不强杀且不改 `data/`、`licenses/`。两端 platform 对齐 `WaitForExit` 契约,Windows 固定短轮询完整路径,非 Windows 返回明确不支持。
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 - 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:T-402 已正式落成,当前执行子软件更新的关闭确认、自然退出等待和可信安装器编排。下载 completed 文件的生产消费、许可证策略与完整端到端 cmd/UI 编排仍未装配;不得为此注入 allow-all 授权或伪装更新闭环。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入 - 当前 blocker:可正式落成 T-403。下载 completed 文件的生产消费、许可证策略与完整端到端 cmd/UI 编排仍未装配;T-402 因而没有注入 allow-all 授权或伪装更新闭环。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入
## 当前目录要点 ## 当前目录要点
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615 与 T-401 已完成;T-402 已正式落成,正在执行 | | `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615、T-401 与 T-402 已完成;下一项为 T-403 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
@@ -40,9 +41,9 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`。 - 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`、`T-402`。
- 正在进行:无。 - 正在进行:无。
- 正在进行:T-402(依赖 T-401 已完成);完成、验证并提交后才可正式落成 T-403。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 - 正在准备领取:T-403(依赖 T-402 已完成);应先正式落成并提交该任务,再开始 SoftBox 自身更新实现。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
## 当前可运行内容 ## 当前可运行内容
+5 -3
View File
@@ -3,12 +3,12 @@ id: T-402
title: 子软件更新编排与自然退出等待 title: 子软件更新编排与自然退出等待
phase: 4 phase: 4
deps: [T-401] deps: [T-401]
status: TODO status: DONE
created: 2026-07-19 created: 2026-07-19
issue: null issue: null
context_ref: null context_ref: fda52a57ca0d4c449e206c163864bdd3e354cbaf
claim_branch: null claim_branch: null
work_branch: null work_branch: agent/codex/T-402
write_paths: write_paths:
- docs/tasks/T-402.md - docs/tasks/T-402.md
- core/application/update/ - core/application/update/
@@ -58,3 +58,5 @@ T-401 已提供精确 entrypoint 的 Toolhelp 运行状态和 `InstallService`
## 执行记录 ## 执行记录
- 2026-07-19:正式落成。以 T-401 的完整路径运行检测和 Switcher 临界区复查为基础,冻结“确认关闭→有限自然退出等待→委托可信安装器→二次运行复查”的更新顺序;明确下载/授权生产装配、命名管道、强杀和 T-601 真机验证不在本任务。 - 2026-07-19:正式落成。以 T-401 的完整路径运行检测和 Switcher 临界区复查为基础,冻结“确认关闭→有限自然退出等待→委托可信安装器→二次运行复查”的更新顺序;明确下载/授权生产装配、命名管道、强杀和 T-601 真机验证不在本任务。
- 2026-07-19:领取任务,基于 `fda52a57ca0d4c449e206c163864bdd3e354cbaf` 在 `agent/codex/T-402` 执行;T-401 基线与正式任务文档校验已通过,再实现更新编排。
- 2026-07-19:完成。新增纯 core `application/update`,以受控本地记录验证严格更新版本,在运行中经关闭确认和有限自然退出等待后才委托原始可信 install request;安装器保留 staging 前与 switch 临界区复查,所有安装错误链可观察。双端 `platform/windows` 增加可取消的完整路径自然退出轮询与非 Windows fail-closed stub。production cmd 没有可信 Catalog/许可证/completed-download 消费和关闭确认 UI,故未注入 fake 或 allow-all 闭环。验证通过:`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./application/update ./application/install ./installer`、双端 Windows amd64 构建与 Linux stub test 编译、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。