diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 379a33e8..7e7c87e5 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -101,13 +101,16 @@ jobs: run: | $ErrorActionPreference = 'Stop' $runtimeTestAgentDockBinary = Join-Path $env:RUNNER_TEMP 'agentdock-runtime-launch-test.exe' + $runtimeTestHiddenHostBinary = Join-Path $env:RUNNER_TEMP 'agentdock-runtime-host-test.exe' $env:CGO_ENABLED = '0' $env:GOOS = 'windows' $env:GOARCH = 'amd64' go build -trimpath -o $runtimeTestAgentDockBinary .\cmd\agentdock + go build -trimpath -ldflags '-H=windowsgui' -o $runtimeTestHiddenHostBinary .\cmd\agentdock-shim & .\scripts\test\test-windows-runtime-launch-diagnostics.ps1 ` -LauncherPath .\scripts\install\launch-windows-process.ps1 ` - -AgentDockBinary $runtimeTestAgentDockBinary + -AgentDockBinary $runtimeTestAgentDockBinary ` + -HiddenHostBinary $runtimeTestHiddenHostBinary - name: Test Task Scheduler session selection shell: powershell diff --git a/cmd/agentdock-shim/main_windows.go b/cmd/agentdock-shim/main_windows.go index 3d40c33d..3e419560 100644 --- a/cmd/agentdock-shim/main_windows.go +++ b/cmd/agentdock-shim/main_windows.go @@ -4,6 +4,7 @@ package main import ( "bytes" + "encoding/json" "errors" "fmt" "os" @@ -19,7 +20,26 @@ import ( "github.com/uvwt/agentdock/internal/updateengine" ) +const ( + setupRuntimeHostFlag = "--setup-runtime-host" + taskCoreHostFlag = "--task-core-host" +) + func main() { + if len(os.Args) > 1 && strings.EqualFold(strings.TrimSpace(os.Args[1]), taskCoreHostFlag) { + exitCode, err := runTaskCoreHost(os.Args[2:]) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + os.Exit(exitCode) + } + if len(os.Args) > 1 && strings.EqualFold(strings.TrimSpace(os.Args[1]), setupRuntimeHostFlag) { + exitCode, err := runSetupRuntimeHost(os.Args[2:]) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + os.Exit(exitCode) + } if err := run(); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -109,6 +129,59 @@ func coreLaunchRequiresParentLifetime(args []string) bool { strings.EqualFold(strings.TrimSpace(args[1]), "launch-core") } +type installerTrialTransaction struct { + TransactionID string `json:"transaction_id"` + Platform string `json:"platform"` + Action string `json:"action"` + SourceVersion string `json:"source_version"` + TargetVersion string `json:"target_version"` + State updateengine.State `json:"state"` + InstallRoot string `json:"install_root"` +} + +func installerOwnsActiveTrial(root string, active updateengine.ActiveVersion) (bool, error) { + data, err := os.ReadFile(filepath.Join(root, "install", "transaction.json")) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("read installer transaction: %w", err) + } + var transaction installerTrialTransaction + if err := json.Unmarshal(data, &transaction); err != nil { + return false, fmt.Errorf("parse installer transaction: %w", err) + } + if transaction.Platform != "windows" || + (transaction.Action != "install" && transaction.Action != "repair") || + transaction.State != updateengine.StateTrial || + transaction.TransactionID != active.TransactionID || + updateengine.NormalizeVersion(transaction.TargetVersion) != updateengine.NormalizeVersion(active.ActiveVersion) || + updateengine.NormalizeVersion(transaction.SourceVersion) != updateengine.NormalizeVersion(active.FallbackVersion) || + !sameWindowsPath(transaction.InstallRoot, root) { + return false, nil + } + + // Installer 持有这个独占锁贯穿 stage、trial 启动和健康检查。只有活着的事务 owner + // 才能临时授权 stable shim 路由到未提交 generation;崩溃后锁释放,陈旧 trial 会被拒绝。 + lock, acquired, err := processlock.TryAcquire(filepath.Join(root, "install", "transaction.lock")) + if err != nil { + return false, fmt.Errorf("probe installer transaction lock: %w", err) + } + if acquired { + if err := lock.Release(); err != nil { + return false, fmt.Errorf("release installer transaction probe lock: %w", err) + } + return false, nil + } + return true, nil +} + +func sameWindowsPath(left, right string) bool { + left, leftErr := filepath.Abs(strings.TrimSpace(left)) + right, rightErr := filepath.Abs(strings.TrimSpace(right)) + return leftErr == nil && rightErr == nil && strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) +} + func resolveActiveWithRecovery(root string, store *updateengine.Store, layout updateengine.WindowsLayout) (updateengine.ActiveVersion, error) { active, err := store.ReadActive() if err != nil { @@ -118,9 +191,17 @@ func resolveActiveWithRecovery(root string, store *updateengine.Store, layout up transaction, transactionErr := store.ReadTransaction() if transactionErr != nil { if active.State == updateengine.StateTrial { - // Installer fresh bootstrap 把 pointer 停在 trial,直到 install commit。 - // shim 恢复只认 update/transaction.json;没有这份 journal 就不能把未完成安装当 committed 启动。 - return updateengine.ActiveVersion{}, fmt.Errorf("active generation is still a trial and no update transaction is present; refusing to launch an uncommitted installer generation: %w", transactionErr) + if !os.IsNotExist(transactionErr) { + return updateengine.ActiveVersion{}, fmt.Errorf("read pending update transaction: %w", transactionErr) + } + owned, err := installerOwnsActiveTrial(root, active) + if err != nil { + return updateengine.ActiveVersion{}, err + } + if owned { + return active, nil + } + return updateengine.ActiveVersion{}, errors.New("active generation is still a trial without a live update or installer transaction") } return active, nil } diff --git a/cmd/agentdock-shim/main_windows_test.go b/cmd/agentdock-shim/main_windows_test.go index 5b5a0a44..e2d79349 100644 --- a/cmd/agentdock-shim/main_windows_test.go +++ b/cmd/agentdock-shim/main_windows_test.go @@ -2,7 +2,18 @@ package main -import "testing" +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/uvwt/agentdock/internal/fs/processlock" + "github.com/uvwt/agentdock/internal/updateengine" +) func TestTrayRequiresWaitOnlyDetachesNormalBackgroundLaunches(t *testing.T) { tests := []struct { @@ -49,3 +60,218 @@ func TestCoreLaunchRequiresParentLifetimeOnlyForServiceHost(t *testing.T) { }) } } + +func TestSetupRuntimeHostPreservesExitCodeAndDiagnostics(t *testing.T) { + comspec := os.Getenv("COMSPEC") + if strings.TrimSpace(comspec) == "" { + comspec = `C:\Windows\System32\cmd.exe` + } + stdoutPath := filepath.Join(t.TempDir(), "stdout.log") + stderrPath := filepath.Join(t.TempDir(), "stderr.log") + errorPath := filepath.Join(t.TempDir(), "launcher-error.log") + encode := func(value string) string { + return base64.StdEncoding.EncodeToString([]byte(value)) + } + + exitCode, err := runSetupRuntimeHost([]string{ + "--file-b64", encode(comspec), + "--args-b64", encode(`/d /s /c "echo runtime-host-stdout & echo runtime-host-stderr 1>&2 & exit 7"`), + "--wait", + "--stdout-b64", encode(stdoutPath), + "--stderr-b64", encode(stderrPath), + "--error-b64", encode(errorPath), + }) + if err != nil { + t.Fatalf("runSetupRuntimeHost() error = %v", err) + } + if exitCode != 7 { + t.Fatalf("runSetupRuntimeHost() exit code = %d, want 7", exitCode) + } + stdout, err := os.ReadFile(stdoutPath) + if err != nil { + t.Fatal(err) + } + stderr, err := os.ReadFile(stderrPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(stdout), "runtime-host-stdout") { + t.Fatalf("stdout = %q", stdout) + } + if !strings.Contains(string(stderr), "runtime-host-stderr") { + t.Fatalf("stderr = %q", stderr) + } + if _, err := os.Stat(errorPath); !os.IsNotExist(err) { + t.Fatalf("launcher error file exists after a normal child exit: %v", err) + } +} + +func TestReplaceWindowsEnvironmentIsCaseInsensitive(t *testing.T) { + environment := replaceWindowsEnvironment( + []string{"Path=C:\\Windows", "agentdock_home=old", "OTHER=value"}, + "AGENTDOCK_HOME", + `C:\Users\Test\.agentdock`, + ) + joined := strings.Join(environment, "\n") + if strings.Contains(strings.ToLower(joined), "agentdock_home=old") { + t.Fatalf("old environment value survived: %q", environment) + } + if !strings.Contains(joined, `AGENTDOCK_HOME=C:\Users\Test\.agentdock`) { + t.Fatalf("replacement environment value missing: %q", environment) + } +} + +func TestInstallerOwnsActiveTrialOnlyWhileMatchingTransactionIsLive(t *testing.T) { + root := t.TempDir() + active := updateengine.ActiveVersion{ + SchemaVersion: updateengine.SchemaVersion, + ActiveVersion: "v0.8.2", + FallbackVersion: "v0.8.3", + State: updateengine.StateTrial, + TransactionID: "installer-trial", + } + transaction := installerTrialTransaction{ + TransactionID: "installer-trial", + Platform: "windows", + Action: "install", + SourceVersion: "v0.8.3", + TargetVersion: "v0.8.2", + State: updateengine.StateTrial, + InstallRoot: root, + } + writeInstallerTrialTransaction(t, root, transaction) + + lock, err := processlock.Acquire(context.Background(), filepath.Join(root, "install", "transaction.lock")) + if err != nil { + t.Fatal(err) + } + owned, err := installerOwnsActiveTrial(root, active) + if err != nil { + t.Fatal(err) + } + if !owned { + t.Fatal("live matching Installer transaction should own the trial generation") + } + if err := lock.Release(); err != nil { + t.Fatal(err) + } + + owned, err = installerOwnsActiveTrial(root, active) + if err != nil { + t.Fatal(err) + } + if owned { + t.Fatal("stale Installer transaction without the live lock must not own the trial generation") + } +} + +func TestInstallerOwnsActiveTrialRejectsMismatchedAuthority(t *testing.T) { + tests := []struct { + name string + mutate func(*installerTrialTransaction) + }{ + {name: "transaction", mutate: func(tx *installerTrialTransaction) { tx.TransactionID = "other" }}, + {name: "platform", mutate: func(tx *installerTrialTransaction) { tx.Platform = "linux" }}, + {name: "action", mutate: func(tx *installerTrialTransaction) { tx.Action = "uninstall" }}, + {name: "state", mutate: func(tx *installerTrialTransaction) { tx.State = updateengine.StateCommitted }}, + {name: "target", mutate: func(tx *installerTrialTransaction) { tx.TargetVersion = "v0.8.4" }}, + {name: "source", mutate: func(tx *installerTrialTransaction) { tx.SourceVersion = "v0.8.1" }}, + {name: "root", mutate: func(tx *installerTrialTransaction) { tx.InstallRoot = filepath.Join(tx.InstallRoot, "other") }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + active := updateengine.ActiveVersion{ + SchemaVersion: updateengine.SchemaVersion, + ActiveVersion: "v0.8.2", + FallbackVersion: "v0.8.3", + State: updateengine.StateTrial, + TransactionID: "installer-trial", + } + transaction := installerTrialTransaction{ + TransactionID: "installer-trial", + Platform: "windows", + Action: "install", + SourceVersion: "v0.8.3", + TargetVersion: "v0.8.2", + State: updateengine.StateTrial, + InstallRoot: root, + } + test.mutate(&transaction) + writeInstallerTrialTransaction(t, root, transaction) + lock, err := processlock.Acquire(context.Background(), filepath.Join(root, "install", "transaction.lock")) + if err != nil { + t.Fatal(err) + } + defer lock.Release() + + owned, err := installerOwnsActiveTrial(root, active) + if err != nil { + t.Fatal(err) + } + if owned { + t.Fatal("mismatched Installer transaction must not authorize the trial generation") + } + }) + } +} + +func TestResolveActiveAllowsLiveInstallerTrialWithoutUpdateTransaction(t *testing.T) { + root := t.TempDir() + store, err := updateengine.NewStore(root) + if err != nil { + t.Fatal(err) + } + active := updateengine.ActiveVersion{ + SchemaVersion: updateengine.SchemaVersion, + ActiveVersion: "v0.8.2", + FallbackVersion: "v0.8.3", + State: updateengine.StateTrial, + TransactionID: "installer-trial", + } + if err := store.WriteActive(active); err != nil { + t.Fatal(err) + } + writeInstallerTrialTransaction(t, root, installerTrialTransaction{ + TransactionID: "installer-trial", + Platform: "windows", + Action: "install", + SourceVersion: "v0.8.3", + TargetVersion: "v0.8.2", + State: updateengine.StateTrial, + InstallRoot: root, + }) + lock, err := processlock.Acquire(context.Background(), filepath.Join(root, "install", "transaction.lock")) + if err != nil { + t.Fatal(err) + } + defer lock.Release() + layout, err := updateengine.NewWindowsLayout(root) + if err != nil { + t.Fatal(err) + } + + got, err := resolveActiveWithRecovery(root, store, layout) + if err != nil { + t.Fatal(err) + } + if got.ActiveVersion != active.ActiveVersion || got.TransactionID != active.TransactionID { + t.Fatalf("resolved active = %#v, want %#v", got, active) + } +} + +func writeInstallerTrialTransaction(t *testing.T, root string, transaction installerTrialTransaction) { + t.Helper() + data, err := json.MarshalIndent(transaction, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "install", "transaction.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/agentdock-shim/setup_runtime_host_windows.go b/cmd/agentdock-shim/setup_runtime_host_windows.go new file mode 100644 index 00000000..4b17cac8 --- /dev/null +++ b/cmd/agentdock-shim/setup_runtime_host_windows.go @@ -0,0 +1,182 @@ +//go:build windows + +package main + +import ( + "encoding/base64" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "unicode/utf8" + + "golang.org/x/sys/windows" +) + +func runSetupRuntimeHost(args []string) (int, error) { + flags := flag.NewFlagSet("setup-runtime-host", flag.ContinueOnError) + flags.SetOutput(io.Discard) + fileEncoded := flags.String("file-b64", "", "base64 encoded runtime executable path") + argumentsEncoded := flags.String("args-b64", "", "base64 encoded raw Windows command line arguments") + homeEncoded := flags.String("agentdock-home-b64", "", "base64 encoded AGENTDOCK_HOME") + defaultDirEncoded := flags.String("agentdock-default-dir-b64", "", "base64 encoded AGENTDOCK_DEFAULT_DIR") + stdoutEncoded := flags.String("stdout-b64", "", "base64 encoded stdout path") + stderrEncoded := flags.String("stderr-b64", "", "base64 encoded stderr path") + errorEncoded := flags.String("error-b64", "", "base64 encoded launcher error path") + waitForExit := flags.Bool("wait", false, "wait for the runtime process to exit") + if err := flags.Parse(args); err != nil || flags.NArg() != 0 { + if err == nil { + err = errors.New("setup runtime host received unexpected positional arguments") + } + return 1, err + } + + filePath, err := decodeSetupRuntimeValue(*fileEncoded, true) + if err != nil { + return 1, fmt.Errorf("decode setup runtime executable: %w", err) + } + arguments, err := decodeSetupRuntimeValue(*argumentsEncoded, false) + if err != nil { + return 1, fmt.Errorf("decode setup runtime arguments: %w", err) + } + agentDockHome, err := decodeSetupRuntimeValue(*homeEncoded, false) + if err != nil { + return 1, fmt.Errorf("decode AGENTDOCK_HOME: %w", err) + } + agentDockDefaultDir, err := decodeSetupRuntimeValue(*defaultDirEncoded, false) + if err != nil { + return 1, fmt.Errorf("decode AGENTDOCK_DEFAULT_DIR: %w", err) + } + stdoutPath, err := decodeSetupRuntimeValue(*stdoutEncoded, false) + if err != nil { + return 1, fmt.Errorf("decode setup runtime stdout path: %w", err) + } + stderrPath, err := decodeSetupRuntimeValue(*stderrEncoded, false) + if err != nil { + return 1, fmt.Errorf("decode setup runtime stderr path: %w", err) + } + errorPath, err := decodeSetupRuntimeValue(*errorEncoded, false) + if err != nil { + return 1, fmt.Errorf("decode setup runtime error path: %w", err) + } + if *waitForExit && (stdoutPath == "" || stderrPath == "") { + return 1, errors.New("setup runtime host requires stdout and stderr paths when waiting") + } + + exitCode, runErr := launchSetupRuntimeProcess( + filePath, + arguments, + agentDockHome, + agentDockDefaultDir, + stdoutPath, + stderrPath, + *waitForExit, + ) + if runErr != nil && errorPath != "" { + if writeErr := os.WriteFile(errorPath, []byte(runErr.Error()+"\r\n"), 0o600); writeErr != nil { + runErr = errors.Join(runErr, fmt.Errorf("write setup runtime launcher error: %w", writeErr)) + } + } + return exitCode, runErr +} + +func decodeSetupRuntimeValue(encoded string, required bool) (string, error) { + encoded = strings.TrimSpace(encoded) + if encoded == "" { + if required { + return "", errors.New("value is required") + } + return "", nil + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + if !utf8.Valid(decoded) { + return "", errors.New("value is not valid UTF-8") + } + value := string(decoded) + if required && strings.TrimSpace(value) == "" { + return "", errors.New("value is empty") + } + return value, nil +} + +func launchSetupRuntimeProcess(filePath, arguments, agentDockHome, agentDockDefaultDir, stdoutPath, stderrPath string, waitForExit bool) (int, error) { + command := exec.Command(filePath) + creationFlags := uint32(windows.CREATE_NO_WINDOW) + if !waitForExit { + creationFlags |= windows.CREATE_NEW_PROCESS_GROUP + } + command.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: creationFlags, + HideWindow: true, + } + if strings.TrimSpace(arguments) != "" { + // SysProcAttr.CmdLine preserves the already-quoted Windows argument string produced by + // Setup without routing it through cmd.exe or another console host. + command.SysProcAttr.CmdLine = syscall.EscapeArg(filePath) + " " + arguments + } + command.Env = os.Environ() + if agentDockHome != "" { + command.Env = replaceWindowsEnvironment(command.Env, "AGENTDOCK_HOME", agentDockHome) + } + if agentDockDefaultDir != "" { + command.Env = replaceWindowsEnvironment(command.Env, "AGENTDOCK_DEFAULT_DIR", agentDockDefaultDir) + } + + if waitForExit { + stdoutFile, err := os.OpenFile(stdoutPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return 1, fmt.Errorf("open setup runtime stdout: %w", err) + } + defer stdoutFile.Close() + stderrFile, err := os.OpenFile(stderrPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return 1, fmt.Errorf("open setup runtime stderr: %w", err) + } + defer stderrFile.Close() + command.Stdout = stdoutFile + command.Stderr = stderrFile + if err := command.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return 1, fmt.Errorf("run setup runtime process: %w", err) + } + return 0, nil + } + + nullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return 1, fmt.Errorf("open null device for setup runtime process: %w", err) + } + defer nullFile.Close() + command.Stdin = nullFile + command.Stdout = nullFile + command.Stderr = nullFile + if err := command.Start(); err != nil { + return 1, fmt.Errorf("start setup runtime process: %w", err) + } + if err := command.Process.Release(); err != nil { + return 1, fmt.Errorf("release setup runtime process: %w", err) + } + return 0, nil +} + +func replaceWindowsEnvironment(environment []string, name, value string) []string { + result := make([]string, 0, len(environment)+1) + for _, entry := range environment { + key, _, found := strings.Cut(entry, "=") + if found && strings.EqualFold(key, name) { + continue + } + result = append(result, entry) + } + return append(result, name+"="+value) +} diff --git a/cmd/agentdock-shim/task_core_host_windows.go b/cmd/agentdock-shim/task_core_host_windows.go new file mode 100644 index 00000000..71e8a7c0 --- /dev/null +++ b/cmd/agentdock-shim/task_core_host_windows.go @@ -0,0 +1,105 @@ +//go:build windows + +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + processctl "github.com/uvwt/agentdock/internal/process" + "github.com/uvwt/agentdock/internal/updateengine" +) + +// runTaskCoreHost 是 elevated Core 模式下长期运行的计划任务入口。 +// 由稳定 GUI shim 直接拥有 generation Core,让 Task Scheduler 只绑定稳定进程边界; +// 安装、修复、更新和回滚期间都不会长期占用可替换的 versioned WPF 可执行文件。 +func runTaskCoreHost(args []string) (int, error) { + flags := flag.NewFlagSet("task-core-host", flag.ContinueOnError) + flags.SetOutput(io.Discard) + runtimeRootFlag := flags.String("runtime-root", "", "AgentDock runtime root") + if err := flags.Parse(args); err != nil { + return 1, err + } + if flags.NArg() != 0 || strings.TrimSpace(*runtimeRootFlag) == "" { + return 1, errors.New("task core host requires --runtime-root") + } + + executable, err := os.Executable() + if err != nil { + return 1, fmt.Errorf("resolve AgentDock task host entry: %w", err) + } + executable, err = filepath.Abs(executable) + if err != nil { + return 1, fmt.Errorf("resolve AgentDock task host entry path: %w", err) + } + if !strings.EqualFold(filepath.Base(executable), updateengine.StableTrayShimName) { + return 1, fmt.Errorf("task core host requires the stable GUI entry %s", updateengine.StableTrayShimName) + } + + runtimeRoot, err := filepath.Abs(strings.TrimSpace(*runtimeRootFlag)) + if err != nil { + return 1, fmt.Errorf("resolve task core host runtime root: %w", err) + } + stableRoot := filepath.Dir(filepath.Dir(executable)) + if !sameWindowsPath(runtimeRoot, stableRoot) { + return 1, fmt.Errorf("task core host runtime root %s does not match stable entry root %s", runtimeRoot, stableRoot) + } + + store, err := updateengine.NewStore(runtimeRoot) + if err != nil { + return 1, err + } + layout, err := updateengine.NewWindowsLayout(runtimeRoot) + if err != nil { + return 1, err + } + active, err := resolveActiveWithRecovery(runtimeRoot, store, layout) + if err != nil { + return 1, err + } + coreBinary := layout.GenerationCore(active.ActiveVersion) + if info, err := os.Stat(coreBinary); err != nil || info.IsDir() { + if err == nil { + err = errors.New("path is a directory") + } + return 1, fmt.Errorf("resolve active AgentDock Core %s: %w", coreBinary, err) + } + + command := exec.Command(coreBinary, "service", "launch-core", "--runtime-root", runtimeRoot) + command.Dir = runtimeRoot + processctl.Configure(command) + nullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return 1, fmt.Errorf("open null device for task core host: %w", err) + } + defer nullFile.Close() + command.Stdin = nullFile + command.Stdout = nullFile + command.Stderr = nullFile + + if err := command.Start(); err != nil { + return 1, fmt.Errorf("start active AgentDock Core from task host: %w", err) + } + controller, err := processctl.Attach(command) + if err != nil { + _ = command.Process.Kill() + _ = command.Wait() + return 1, fmt.Errorf("attach AgentDock Core to task host Job Object: %w", err) + } + defer controller.Close() + + if err := command.Wait(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return 1, fmt.Errorf("wait for AgentDock Core task process: %w", err) + } + return 0, nil +} diff --git a/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings b/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings index de564664..84c75c16 100644 --- a/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings +++ b/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings @@ -179,7 +179,6 @@ "Saved securely · node_id=%@" = "Saved securely · node_id=%@"; "AgentDock update is missing background service recovery state." = "AgentDock update is missing background service recovery state."; "AgentDock update result was lost while restoring background services." = "AgentDock update result was lost while restoring background services."; -"Tunnel could not be restored for the current public access mode: %@" = "Tunnel could not be restored for the current public access mode: %@"; "Background services have not been restored yet. AgentDock will try again at the next launch: %@" = "Background services have not been restored yet. AgentDock will try again at the next launch: %@"; "AgentDock recovery failed" = "AgentDock recovery failed"; "Menu bar launch at sign-in will be reconciled at the next launch because the update transaction is still in progress." = "Menu bar launch at sign-in will be reconciled at the next launch because the update transaction is still in progress."; @@ -217,6 +216,7 @@ "%@ / %@" = "%@ / %@"; "Version %@ → %@" = "Version %@ → %@"; "Updated to %@" = "Updated to %@"; +"A new AgentDock version is available.\n\nCurrent version: %@\nLatest version: %@\n\nUpdate now?" = "A new AgentDock version is available.\n\nCurrent version: %@\nLatest version: %@\n\nUpdate now?"; "AgentDock is up to date" = "AgentDock is up to date"; "Done" = "Done"; "AgentDock is busy" = "AgentDock is busy"; @@ -321,7 +321,6 @@ "Failed to start the new configuration, and validation after restoring the old configuration also failed: %@" = "Failed to start the new configuration, and validation after restoring the old configuration also failed: %@"; "Failed to start the new configuration; restored the previous configuration: %@" = "Failed to start the new configuration; restored the previous configuration: %@"; "Unable to atomically replace AgentDock configuration: %@" = "Unable to atomically replace AgentDock configuration: %@"; -"AgentDock Core background registration was restored, but automatic restart still failed the health check: %@" = "AgentDock Core background registration was restored, but automatic restart still failed the health check: %@"; "%@ background registration did not become available after the update." = "%@ background registration did not become available after the update."; "%@ background registration returned an unknown state after the update." = "%@ background registration returned an unknown state after the update."; "AgentDock Core needs background-item approval in System Settings." = "AgentDock Core needs background-item approval in System Settings."; diff --git a/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings b/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings index 1b509eb0..ea2c9e2d 100644 --- a/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings +++ b/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings @@ -179,7 +179,6 @@ "Saved securely · node_id=%@" = "已安全保存 · node_id=%@"; "AgentDock update is missing background service recovery state." = "AgentDock 更新缺少后台服务恢复状态。"; "AgentDock update result was lost while restoring background services." = "AgentDock 更新结果在服务恢复过程中丢失。"; -"Tunnel could not be restored for the current public access mode: %@" = "Tunnel 未能按当前公网模式恢复:%@"; "Background services have not been restored yet. AgentDock will try again at the next launch: %@" = "后台服务尚未恢复,将在下次启动继续尝试:%@"; "AgentDock recovery failed" = "AgentDock 恢复失败"; "Menu bar launch at sign-in will be reconciled at the next launch because the update transaction is still in progress." = "菜单栏登录启动将在下次启动时重新收敛:更新事务尚未完成。"; @@ -217,6 +216,7 @@ "%@ / %@" = "%@ / %@"; "Version %@ → %@" = "版本 %@ → %@"; "Updated to %@" = "已更新到 %@"; +"A new AgentDock version is available.\n\nCurrent version: %@\nLatest version: %@\n\nUpdate now?" = "发现 AgentDock 新版本。\n\n当前版本:%@\n最新版本:%@\n\n是否立即更新?"; "AgentDock is up to date" = "AgentDock 已是最新版本"; "Done" = "完成"; "AgentDock is busy" = "AgentDock 正忙"; @@ -321,7 +321,6 @@ "Failed to start the new configuration, and validation after restoring the old configuration also failed: %@" = "新配置启动失败,而且旧配置恢复验证也失败:%@"; "Failed to start the new configuration; restored the previous configuration: %@" = "新配置启动失败,已恢复旧配置:%@"; "Unable to atomically replace AgentDock configuration: %@" = "无法原子替换 AgentDock 配置:%@"; -"AgentDock Core background registration was restored, but automatic restart still failed the health check: %@" = "AgentDock Core 已恢复后台注册,但自动重启仍未通过健康检查:%@"; "%@ background registration did not become available after the update." = "更新后 %@ 的后台注册没有变为可用状态。"; "%@ background registration returned an unknown state after the update." = "更新后 %@ 的后台注册返回了未知状态。"; "AgentDock Core needs background-item approval in System Settings." = "AgentDock Core 需要在系统设置中批准后台运行。"; diff --git a/desktop/macos/AgentDockApp/Sources/AppDelegate.swift b/desktop/macos/AgentDockApp/Sources/AppDelegate.swift index bf9d015c..6da11203 100644 --- a/desktop/macos/AgentDockApp/Sources/AppDelegate.swift +++ b/desktop/macos/AgentDockApp/Sources/AppDelegate.swift @@ -6,10 +6,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let service = ServiceController() private let menuLoginAgent = MenuLoginAgentController() private let launchedInBackground = CommandLine.arguments.contains("--background") - private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let statusItem: NSStatusItem = { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + // Keep the tray hidden until launch state has been classified. A replacement App may + // start with currentStatus=.missing while it is still finishing an update transaction. + item.isVisible = false + return item + }() private var currentStatus = ServiceStatus.missing private var timer: Timer? private var isUpdating = false + private var isCheckingForUpdate = false private var trayServiceActionInProgress = false private lazy var updateProgressWindow = UpdateProgressWindowController() private lazy var setupWindow = SetupWindowController( @@ -25,8 +32,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { let recoveryReady = DesktopUpdateTransactionRecovery.recoverIfNeeded(paths: service.paths) - let pendingUpdateResult = DesktopUpdateResult.load(from: service.paths.updateResult) - let updateResultExists = FileManager.default.fileExists(atPath: service.paths.updateResult.path) + var pendingUpdateResult = DesktopUpdateResult.load(from: service.paths.updateResult) + var updateResultExists = FileManager.default.fileExists(atPath: service.paths.updateResult.path) + + // 旧 0.8.x 更新结果没有 transaction id。若用户在更新完成后又手动替换/恢复了 App, + // 结果文件记录的 target 已不再代表当前磁盘状态;继续按“更新收尾”处理只会永久锁住 UI。 + if recoveryReady, + let pendingResult = pendingUpdateResult, + pendingResult.ok, + pendingResult.transactionID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true, + AppVersion.display(pendingResult.targetVersion) != AppVersion.current { + NSLog( + "AgentDock found a legacy update result that no longer matches the active App; reconciling the current installation." + ) + _ = DesktopUpdateResult.consume(from: service.paths.updateResult) + DesktopUpdateServiceState.remove(at: service.paths.updateServiceState) + DesktopUpdateHandoff.remove(at: service.paths.updateHandoff) + pendingUpdateResult = nil + updateResultExists = false + } + configureStatusItem() if !recoveryReady { // Do not acknowledge or clear any pending transaction when crash recovery itself @@ -56,13 +81,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { refreshStatus() } else { // 没有 pending result 时,更新协调文件只能是上一次已结束流程留下的临时状态。 + // 正常启动到这里才允许显示托盘;更新接管分支始终保持隐藏。 + setUpdateInProgress(false) configureMenuLoginAgentIfNeeded() DesktopUpdateServiceState.remove(at: service.paths.updateServiceState) DesktopUpdateHandoff.remove(at: service.paths.updateHandoff) refreshStatus(showWindow: !launchedInBackground) Task { do { - try await service.reconcileTunnelRegistrationFromConfiguration() + try service.reconcileTunnelRegistrationFromConfiguration() } catch { NSLog("AgentDock 启动时 Tunnel 状态收敛失败:%@", error.localizedDescription) } @@ -80,10 +107,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { timer?.invalidate() } - private func setUpdateInProgress(_ inProgress: Bool) { + private func setUpdateInProgress(_ inProgress: Bool, checking: Bool = false) { isUpdating = inProgress + isCheckingForUpdate = inProgress && checking + statusItem.isVisible = UpdateStatusItemVisibility.shouldShow( + isUpdating: isUpdating, + isCheckingForUpdate: isCheckingForUpdate + ) ApplicationMenu.setQuitEnabled(!inProgress) - setupWindow.setUpdateInProgress(inProgress) + setupWindow.setUpdateInProgress( + inProgress, + status: checking ? L10n.text("Checking for updates…") : nil + ) rebuildMenu() } @@ -132,24 +167,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { handoffAcknowledged = true } - let recoveryWarnings = await service.recoverBackgroundServicesAfterUpdate( - coreEnabled: serviceState.coreEnabled, - tunnelEnabled: serviceState.tunnelEnabled - ) - - // 更新事务恢复的是升级前的瞬时注册状态;公网 mode 才是 Tunnel 的长期意图。 - // 注册已恢复但服务仍在启动时只提示,不把正常的 macOS 启动延迟升级成回滚。 - var warnings = recoveryWarnings + let hasTransaction = !(transactionID?.isEmpty ?? true) + // Transaction-aware updates already gate commit on Core health + target version in + // the Arbiter. Repeating a shorter GUI health probe can only create stale warnings + // after a transaction that has already proved Core healthy. The legacy path keeps + // its bounded Core readiness warning because it has no external Arbiter. + var warnings: [String] = [] + if !hasTransaction { + warnings = await service.recoverBackgroundServicesAfterUpdate( + coreEnabled: serviceState.coreEnabled, + tunnelEnabled: serviceState.tunnelEnabled + ) + } if registration.core == "requires_approval" { warnings.append(L10n.text("AgentDock Core needs background-item approval in System Settings.")) } - if registration.tunnel == "requires_approval" { - warnings.append(L10n.text("AgentDock Tunnel needs background-item approval in System Settings.")) - } + + // Tunnel/public access is a soft dependency. Reconcile it best-effort, but surface + // readiness only in logs and the control panel; it must not gate or decorate an + // otherwise successful install/update result. do { - try await service.reconcileTunnelRegistrationFromConfiguration() + try service.reconcileTunnelRegistrationFromConfiguration() } catch { - warnings.append(L10n.format("Tunnel could not be restored for the current public access mode: %@", error.localizedDescription)) NSLog("AgentDock 更新后 Tunnel 状态收敛失败:%@", error.localizedDescription) } @@ -393,7 +432,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { if isUpdating { - updateProgressWindow.present() + if isCheckingForUpdate { + setupWindow.present(status: currentStatus) + } else { + updateProgressWindow.present() + } return true } setupWindow.present(status: currentStatus) @@ -403,15 +446,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func rebuildMenu() { let menu = NSMenu() if isUpdating { + let activity = isCheckingForUpdate ? L10n.text("Checking for updates…") : L10n.text("Updating…") let statusMenuItem = NSMenuItem( - title: L10n.format("AgentDock: %@", L10n.text("Updating…")), + title: L10n.format("AgentDock: %@", activity), action: nil, keyEquivalent: "" ) statusMenuItem.isEnabled = false menu.addItem(statusMenuItem) menu.addItem(.separator()) - menu.addItem(item(L10n.text("Show update progress"), #selector(showUpdateProgress))) + if !isCheckingForUpdate { + menu.addItem(item(L10n.text("Show update progress"), #selector(showUpdateProgress))) + } if currentStatus.installed { menu.addItem(item(L10n.text("Open logs folder"), #selector(openLogs))) } @@ -497,7 +543,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func startUpdate() { guard !isUpdating else { - updateProgressWindow.present() + if isCheckingForUpdate { + setupWindow.present(status: currentStatus) + } else { + updateProgressWindow.present() + } return } guard !trayServiceActionInProgress, !setupWindow.hasActiveServiceOperation else { @@ -507,11 +557,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) return } - setUpdateInProgress(true) - updateProgressWindow.presentChecking() + + // “检查更新”只做版本检查。下载、停服务和 App 替换必须等用户明确确认。 + setUpdateInProgress(true, checking: true) Task { do { - _ = try await service.update { [weak self] event in + let check = try await service.checkForUpdates() + let shouldApply = await MainActor.run { + guard check.updateAvailable else { + self.setUpdateInProgress(false) + self.presentAlert( + title: L10n.text("AgentDock is up to date"), + message: check.message + ) + self.refreshStatus() + return false + } + guard self.confirmUpdate(check) else { + self.setUpdateInProgress(false) + self.refreshStatus() + return false + } + self.setUpdateInProgress(true) + self.updateProgressWindow.presentChecking() + return true + } + guard shouldApply else { return } + + _ = try await service.applyUpdate { [weak self] event in Task { @MainActor in self?.updateProgressWindow.apply(event) } @@ -522,14 +595,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } catch { await MainActor.run { + let failedWhileChecking = self.isCheckingForUpdate self.setUpdateInProgress(false) - self.updateProgressWindow.showFailure(error.localizedDescription) + if failedWhileChecking { + self.presentAlert( + title: L10n.text("Check for updates"), + message: error.localizedDescription, + style: .warning + ) + } else { + self.updateProgressWindow.showFailure(error.localizedDescription) + } self.refreshStatus() } } } } + private func confirmUpdate(_ check: DesktopUpdateCheck) -> Bool { + let currentVersion = check.currentVersion ?? L10n.text("Unknown version") + let latestVersion = check.latestVersion ?? L10n.text("Unknown version") + let alert = NSAlert() + alert.messageText = L10n.text("AgentDock Update") + alert.informativeText = L10n.format( + "A new AgentDock version is available.\n\nCurrent version: %@\nLatest version: %@\n\nUpdate now?", + currentVersion, + latestVersion + ) + alert.alertStyle = .informational + alert.addButton(withTitle: L10n.text("Update")) + alert.addButton(withTitle: L10n.text("Cancel")) + return alert.runModal() == .alertFirstButtonReturn + } + private func performServiceAction(_ action: String, operation: @escaping () async throws -> Void) { guard !isUpdating else { updateProgressWindow.present() diff --git a/desktop/macos/AgentDockApp/Sources/InstallerRunner.swift b/desktop/macos/AgentDockApp/Sources/InstallerRunner.swift index 6cc64be8..607e6564 100644 --- a/desktop/macos/AgentDockApp/Sources/InstallerRunner.swift +++ b/desktop/macos/AgentDockApp/Sources/InstallerRunner.swift @@ -82,15 +82,12 @@ final class InstallerRunner { try await service.start() if request.mode != .local { - try service.setTunnelEnabled(true) - if !(await service.waitForTunnelProcess()) { - // App Bundle 被原子替换后,SMAppService 可能仍显示已注册, - // 但实际的 Tunnel job 没有随之启动。对 Quick Tunnel 也 - // 需要与 Named Tunnel 相同的自愈,否则会一直等到 URL 超时。 - try service.restartTunnel() - guard await service.waitForTunnelProcess() else { - throw ValidationError(L10n.text("AgentDock Tunnel was re-registered, but cloudflared did not run reliably.")) - } + do { + try service.setTunnelEnabled(true) + } catch { + // Core health is the install boundary. Tunnel/public access depends on + // ServiceManagement policy and external network state, so keep it best-effort. + NSLog("AgentDock Tunnel registration did not complete during install: %@", error.localizedDescription) } } @@ -101,11 +98,9 @@ final class InstallerRunner { case .named: publicURL = serverURL ?? "" case .quick: - publicURL = try await waitForQuickTunnelURL(timeout: 35) - guard let configuration = ServiceConfiguration.load(from: paths.environment), - await service.waitForHealth(configuration: configuration) else { - throw ValidationError(L10n.text("A temporary public address was generated, but AgentDock Core did not recover to a healthy state.")) - } + // Quick Tunnel readiness is asynchronous. Do not hold install completion open for + // Cloudflare provisioning; the control panel will expose the URL when it appears. + publicURL = currentQuickTunnelURL() } let finalConfiguration = ServiceConfiguration.load(from: paths.environment) @@ -338,21 +333,15 @@ final class InstallerRunner { return Data(text.utf8) } - private func waitForQuickTunnelURL(timeout: TimeInterval) async throws -> String { - try await service.runInBackground { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = try? Data(contentsOf: self.paths.quickTunnelURL), - let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), - let url = URL(string: value), - url.scheme == "https", - url.host?.hasSuffix(".trycloudflare.com") == true { - return value - } - Thread.sleep(forTimeInterval: 0.25) - } - throw ValidationError(L10n.text("cloudflared did not generate a temporary public address before the timeout.")) + private func currentQuickTunnelURL() -> String { + guard let data = try? Data(contentsOf: paths.quickTunnelURL), + let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + let url = URL(string: value), + url.scheme == "https", + url.host?.hasSuffix(".trycloudflare.com") == true else { + return "" } + return value } private func validPort(_ value: String?) -> Int? { diff --git a/desktop/macos/AgentDockApp/Sources/ServiceController.swift b/desktop/macos/AgentDockApp/Sources/ServiceController.swift index 8733f5a5..310575f1 100644 --- a/desktop/macos/AgentDockApp/Sources/ServiceController.swift +++ b/desktop/macos/AgentDockApp/Sources/ServiceController.swift @@ -227,25 +227,17 @@ final class ServiceController: @unchecked Sendable { return TunnelMode(rawValue: rawMode) ?? .local } - func reconcileTunnelRegistrationFromConfiguration() async throws { + func reconcileTunnelRegistrationFromConfiguration() throws { // 旧结构仍存在时必须先走迁移事务,不能在旁边提前注册第二套 Tunnel。 guard !LegacyDesktopRuntimeMigration.isPresent(paths: paths) else { return } + // 这里只收敛“是否应注册”的长期配置,不等待 cloudflared 或公网 ready。 + // 更新 handoff 已负责重新绑定目标 App;普通启动也不应因短暂网络状态重建 SMAppService。 switch try configuredTunnelMode() { case .local: try setTunnelEnabled(false) case .quick, .named: try setTunnelEnabled(true) - if tunnelService.status == .enabled, !(await waitForTunnelProcess()) { - // App Bundle 被原子替换后,macOS 偶尔仍把旧 SMAppService 注册显示为 enabled, - // 但 launchd 保存的 Bundle 关联已经失效。此时单纯再次 register 会直接 no-op; - // 必须完整注销并重新注册,效果等同于用户手动“仅本地 → 公网”但无需人工介入。 - NSLog("AgentDock Tunnel 注册显示 enabled 但进程未稳定,开始自动重新注册。") - try restartTunnel() - guard await waitForTunnelProcess() else { - throw ValidationError(L10n.text("AgentDock Tunnel was re-registered, but the background process did not start reliably.")) - } - } } } @@ -261,10 +253,6 @@ final class ServiceController: @unchecked Sendable { } } - func restartTunnel() throws { - try reregister(service: tunnelService, label: Self.tunnelLabel, displayName: "AgentDock Tunnel") - } - func restoreBackgroundServiceRegistrations(coreEnabled: Bool, tunnelEnabled: Bool) throws { if coreEnabled { try restoreRegistration(service: coreService, label: Self.coreLabel, displayName: "AgentDock Core") @@ -298,9 +286,8 @@ final class ServiceController: @unchecked Sendable { ) } catch { // Tunnel availability depends on ServiceManagement policy plus external/network state. - // A broken Tunnel must not turn an otherwise healthy App/Core update into a rollback. - // Report an explicit non-ready state to the Arbiter; it commits with a warning, then - // AppDelegate's post-handoff reconciliation gets one more bounded recovery attempt. + // Record the handoff state for diagnostics, but do not turn it into an update gate or + // completion warning; the control panel owns eventual Tunnel/public readiness. NSLog("AgentDock Tunnel registration could not be restored during update handoff: %@", error.localizedDescription) tunnelState = "unavailable" } @@ -308,31 +295,15 @@ final class ServiceController: @unchecked Sendable { } func recoverBackgroundServicesAfterUpdate(coreEnabled: Bool, tunnelEnabled: Bool) async -> [String] { - // App Bundle 刚替换后,SMAppService 的注册状态可能已经生效,但 launchd 真正拉起 - // Core/Tunnel 仍需要更长时间。先给系统一个正常传播窗口,再做一次有界自愈; - // 自愈仍失败时只提示,不把已经完成 handoff 的 App 更新回滚掉。 + // Legacy 更新没有 Arbiter 的 Core health/version gate,因此这里只对 Core 做一次有界等待。 + // Tunnel/public readiness 是外部 soft dependency,只由面板和日志展示,不阻塞更新收尾。 var warnings: [String] = [] - if tunnelEnabled, - tunnelService.status == .enabled, - !(await waitForTunnelProcess()) { - warnings.append(L10n.text("AgentDock Tunnel background registration was restored, but the process is still starting.")) - } + _ = tunnelEnabled if coreEnabled, coreService.status == .enabled, let configuration = ServiceConfiguration.load(from: paths.environment), !(await waitForHealth(configuration: configuration, timeout: 10)) { - // 实机更新后可能出现“SMAppService 显示 enabled,但 Core 进程没有真正拉起”的状态。 - // 控制面板“重启”之所以能恢复,是因为它会完整 unregister/register;这里复用同一路径, - // 避免用户在每次 App 更新后手动点击重启。 - NSLog("AgentDock Core 注册显示 enabled 但健康检查未通过,开始自动重新注册。") - do { - try await restart() - } catch { - warnings.append(L10n.format( - "AgentDock Core background registration was restored, but automatic restart still failed the health check: %@", - error.localizedDescription - )) - } + warnings.append(L10n.text("AgentDock background service is enabled, but the health check did not pass.")) } return warnings } @@ -346,17 +317,6 @@ final class ServiceController: @unchecked Sendable { SMAppService.openSystemSettingsLoginItems() } - func waitForTunnelProcess(timeout: TimeInterval = 10) async -> Bool { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume(returning: self.waitForStableLaunchdProcess( - label: Self.tunnelLabel, - timeout: timeout - )) - } - } - } - func isLoaded() -> Bool { isLoaded(label: Self.coreLabel) } @@ -369,10 +329,8 @@ final class ServiceController: @unchecked Sendable { } } - func update(onProgress: @escaping (UpdateProgressEvent) -> Void) async throws -> String { - try validateServiceManagementReadiness() - - let check = try await runInBackground { + func checkForUpdates() async throws -> DesktopUpdateCheck { + try await runInBackground { let result = try runProcess( executable: self.paths.binary.path, arguments: ["update", "--check"], @@ -383,16 +341,12 @@ final class ServiceController: @unchecked Sendable { } return try DesktopUpdateCheck.decode(result.output) } - guard check.updateAvailable else { - // 没有 pending update result 时这只能是上一次未完成流程留下的临时状态。 - DesktopUpdateServiceState.remove(at: paths.updateServiceState) - onProgress(.local( - type: .completed, - currentVersion: check.currentVersion, - targetVersion: check.latestVersion - )) - return check.message - } + } + + func applyUpdate(onProgress: @escaping (UpdateProgressEvent) -> Void) async throws -> String { + // 用户确认之后才检查后台服务写入能力并进入停服/替换阶段。 + // 纯版本检查不应该产生任何服务状态或更新事务副作用。 + try validateServiceManagementReadiness() let currentStatus = await status() let serviceState = DesktopUpdateServiceState( @@ -620,43 +574,6 @@ final class ServiceController: @unchecked Sendable { ).status) == 0 } - private func launchdProcessID(label: String) -> Int? { - guard let result = try? runProcess( - executable: "/bin/launchctl", - arguments: ["print", "\(serviceDomain)/\(label)"] - ), result.status == 0 else { return nil } - for rawLine in result.output.split(whereSeparator: \.isNewline) { - let line = rawLine.trimmingCharacters(in: .whitespaces) - guard line.hasPrefix("pid = "), - let pid = Int(line.dropFirst("pid = ".count)), - pid > 0 else { continue } - return pid - } - return nil - } - - private func waitForStableLaunchdProcess(label: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - var previousPID: Int? - var stableChecks = 0 - while Date() < deadline { - if let pid = launchdProcessID(label: label) { - if pid == previousPID { - stableChecks += 1 - } else { - previousPID = pid - stableChecks = 1 - } - if stableChecks >= 4 { return true } - } else { - previousPID = nil - stableChecks = 0 - } - Thread.sleep(forTimeInterval: 0.25) - } - return false - } - private func waitUntilUnregistered(service: SMAppService, label: String, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { diff --git a/desktop/macos/AgentDockApp/Sources/SetupWindowController.swift b/desktop/macos/AgentDockApp/Sources/SetupWindowController.swift index b58ccfe1..bc55a7e2 100644 --- a/desktop/macos/AgentDockApp/Sources/SetupWindowController.swift +++ b/desktop/macos/AgentDockApp/Sources/SetupWindowController.swift @@ -160,13 +160,14 @@ final class SetupWindowController: NSWindowController, NSWindowDelegate { updateWindowHeight() } - func setUpdateInProgress(_ inProgress: Bool) { + func setUpdateInProgress(_ inProgress: Bool, status: String? = nil) { isUpdateInProgress = inProgress setBusy(isBusy) advancedSettings?.setUpdateInProgress(inProgress) if inProgress { - showStatus(L10n.text("Updating AgentDock…"), isError: false) - } else if statusLabel.stringValue == L10n.text("Updating AgentDock…") { + showStatus(status ?? L10n.text("Updating AgentDock…"), isError: false) + } else if statusLabel.stringValue == L10n.text("Updating AgentDock…") || + statusLabel.stringValue == L10n.text("Checking for updates…") { statusLabel.isHidden = true } } diff --git a/desktop/macos/AgentDockApp/Sources/UpdateStatusItemVisibility.swift b/desktop/macos/AgentDockApp/Sources/UpdateStatusItemVisibility.swift new file mode 100644 index 00000000..f3bf899b --- /dev/null +++ b/desktop/macos/AgentDockApp/Sources/UpdateStatusItemVisibility.swift @@ -0,0 +1,6 @@ +enum UpdateStatusItemVisibility { + // 检查更新时保留菜单栏入口;真正替换/重启 App 时隐藏,避免暴露中间态。 + static func shouldShow(isUpdating: Bool, isCheckingForUpdate: Bool) -> Bool { + !isUpdating || isCheckingForUpdate + } +} diff --git a/desktop/macos/AgentDockApp/Tests/ServiceControllerValidationTests.swift b/desktop/macos/AgentDockApp/Tests/ServiceControllerValidationTests.swift index dd95d6c6..f3be96b7 100644 --- a/desktop/macos/AgentDockApp/Tests/ServiceControllerValidationTests.swift +++ b/desktop/macos/AgentDockApp/Tests/ServiceControllerValidationTests.swift @@ -63,6 +63,7 @@ struct ServiceControllerValidationTests { testServiceRegistrationStatusClassification() try testNexusConnectionStateResolution(root: root) try testDesktopUpdateCheckDecoding() + testStatusItemVisibilityPolicy() try testUpdateProgressEventDecoding() try testStreamingUpdateProcess(root: root) @@ -210,15 +211,23 @@ struct ServiceControllerValidationTests { precondition(current.latestVersion == "v0.7.2") let available = try DesktopUpdateCheck.decode( - #"{"update_available":true,"message":"发现 AgentDock App 更新"}"# + #"{"current_version":"v0.8.2","latest_version":"v0.8.3","update_available":true,"message":"发现 AgentDock App 更新"}"# ) precondition(available.updateAvailable) + precondition(available.currentVersion == "v0.8.2") + precondition(available.latestVersion == "v0.8.3") expectFailure(L10n.text("Unable to parse the AgentDock update check result.")) { _ = try DesktopUpdateCheck.decode("not-json") } } + private static func testStatusItemVisibilityPolicy() { + precondition(UpdateStatusItemVisibility.shouldShow(isUpdating: false, isCheckingForUpdate: false)) + precondition(UpdateStatusItemVisibility.shouldShow(isUpdating: true, isCheckingForUpdate: true)) + precondition(!UpdateStatusItemVisibility.shouldShow(isUpdating: true, isCheckingForUpdate: false)) + } + private static func testUpdateProgressEventDecoding() throws { let progress = try JSONDecoder().decode( UpdateProgressEvent.self, diff --git a/desktop/windows/control-panel/App.xaml.cs b/desktop/windows/control-panel/App.xaml.cs index 83a3255e..412245d9 100644 --- a/desktop/windows/control-panel/App.xaml.cs +++ b/desktop/windows/control-panel/App.xaml.cs @@ -74,12 +74,18 @@ protected override void OnStartup(StartupEventArgs e) return; } + var background = e.Args.Any(arg => string.Equals(arg, "--background", StringComparison.OrdinalIgnoreCase)); _singleInstanceMutex = new Mutex(true, MutexName, out var createdNew); _ownsSingleInstanceMutex = createdNew; if (!createdNew) { - using var existingEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ShowEventName); - existingEvent.Set(); + // 后台启动只保证 Tray 常驻,不能把已经运行的控制面板主动弹到前台。 + // 用户从开始菜单、快捷方式或安装完成页显式打开时才发送 ShowEvent。 + if (!background) + { + using var existingEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ShowEventName); + existingEvent.Set(); + } Shutdown(); return; } @@ -92,7 +98,6 @@ protected override void OnStartup(StartupEventArgs e) CreateNotifyIcon(); StartShowEventListener(); - var background = e.Args.Any(arg => string.Equals(arg, "--background", StringComparison.OrdinalIgnoreCase)); if (!background) { ShowControlPanel(); diff --git a/desktop/windows/control-panel/Services/RuntimeService.cs b/desktop/windows/control-panel/Services/RuntimeService.cs index 1e3f9b31..4aa8af86 100644 --- a/desktop/windows/control-panel/Services/RuntimeService.cs +++ b/desktop/windows/control-panel/Services/RuntimeService.cs @@ -739,7 +739,7 @@ private async Task RunTaskAdminTransitionAsync( throw new InvalidOperationException(UiText.Get("CurrentWindowsIdentityUnavailable")); } arguments.AddRange([ - "--launcher-path", stableCoreEntry, + "--launcher-path", trayBinary, "--user-sid", userSid, "--user-name", identity.Name ]); diff --git a/desktop/windows/control-panel/Services/TaskAdminService.cs b/desktop/windows/control-panel/Services/TaskAdminService.cs index 89c06d6e..7558ed59 100644 --- a/desktop/windows/control-panel/Services/TaskAdminService.cs +++ b/desktop/windows/control-panel/Services/TaskAdminService.cs @@ -446,7 +446,7 @@ private static void CreateElevatedTask(dynamic service, dynamic root, TaskAdminR dynamic action = definition.Actions.Create(TaskActionExec); action.Path = Path.GetFullPath(request.LauncherPath); - action.Arguments = $"service launch-core --runtime-root \"{Path.GetFullPath(request.RuntimeRoot)}\""; + action.Arguments = $"--task-core-host --runtime-root \"{Path.GetFullPath(request.RuntimeRoot)}\""; dynamic task = root.RegisterTaskDefinition( request.TaskName, diff --git a/internal/desktopruntime/task_com_windows.go b/internal/desktopruntime/task_com_windows.go index 15f05fe5..0049348b 100644 --- a/internal/desktopruntime/task_com_windows.go +++ b/internal/desktopruntime/task_com_windows.go @@ -64,12 +64,15 @@ type iDispatch struct { lpVtbl *iDispatchVtbl } +// Windows x64 VARIANT 的 value union 必须保留 16 字节,因为 BRECORD 含两个指针; +// 因此完整结构为 24 字节,不能按只容纳 int64 的 16 字节结构传给 COM。 type variant struct { VT uint16 wReserved1 uint16 wReserved2 uint16 wReserved3 uint16 Val int64 + unionTail int64 } type dispParams struct { diff --git a/internal/desktopruntime/task_com_windows_test.go b/internal/desktopruntime/task_com_windows_test.go index 24abb2d1..79ccef4f 100644 --- a/internal/desktopruntime/task_com_windows_test.go +++ b/internal/desktopruntime/task_com_windows_test.go @@ -2,7 +2,10 @@ package desktopruntime -import "testing" +import ( + "testing" + "unsafe" +) func TestTaskCOMProceduresResolve(t *testing.T) { for _, test := range []struct { @@ -23,3 +26,12 @@ func TestTaskCOMProceduresResolve(t *testing.T) { }) } } + +func TestVariantMatchesWindows64BitABI(t *testing.T) { + if got := unsafe.Sizeof(variant{}); got != 24 { + t.Fatalf("VARIANT size = %d, want 24 bytes on 64-bit Windows", got) + } + if got := unsafe.Offsetof(variant{}.Val); got != 8 { + t.Fatalf("VARIANT value union offset = %d, want 8", got) + } +} diff --git a/internal/installer/engine.go b/internal/installer/engine.go index 34a0c82a..aede73a5 100644 --- a/internal/installer/engine.go +++ b/internal/installer/engine.go @@ -334,18 +334,17 @@ func (engine Engine) install(ctx context.Context, store *Store, request Request) } } - if request.StartService && (request.TunnelMode == "quick" || request.TunnelMode == "named") { + if shouldStartTunnelInTransaction(request) { transaction.Phase = PhaseTunnel if err := store.WriteTransaction(transaction); err != nil { return fail(PhaseTunnel, err, staged) } - if err := startTunnelServices(ctx, request, staged.Journal); err != nil { - return fail(PhaseTunnel, err, staged) - } - if err := waitTunnelReady(ctx, request, 45*time.Second); err != nil { - return fail(PhaseTunnel, err, staged) - } - if request.TunnelMode == "quick" { + // Core health is the install/update commit boundary. Tunnel/public access depends on + // external network state and Cloudflare policy, so readiness must never roll back a + // healthy Core installation. Start Tunnel best-effort but do not add a second readiness + // wait to the transaction; the control panel reports eventual state and Tunnel logs retain + // the concrete failure evidence. + if err := startTunnelServices(ctx, request, staged.Journal); err == nil && request.TunnelMode == "quick" { if publicURL := readQuickTunnelURL(request.RuntimeRoot); publicURL != "" { result.PublicURL = publicURL } @@ -380,6 +379,13 @@ func (engine Engine) install(ctx context.Context, store *Store, request Request) return commitPreparedInstall(store, transaction, result) } +func shouldStartTunnelInTransaction(request Request) bool { + if !request.StartService || request.DeferCommit { + return false + } + return request.TunnelMode == "quick" || request.TunnelMode == "named" +} + func installPhaseMayHaveMutatedFiles(phase Phase) bool { switch phase { case PhasePrepare, PhaseVerify, "": diff --git a/internal/installer/engine_test.go b/internal/installer/engine_test.go index 29f1e24f..6e4e5c04 100644 --- a/internal/installer/engine_test.go +++ b/internal/installer/engine_test.go @@ -1505,6 +1505,27 @@ func TestExistingVersionReadsCommittedGenerationPointer(t *testing.T) { } } +func TestShouldStartTunnelInTransaction(t *testing.T) { + for _, test := range []struct { + name string + req Request + want bool + }{ + {name: "quick direct", req: Request{StartService: true, TunnelMode: "quick"}, want: true}, + {name: "named direct", req: Request{StartService: true, TunnelMode: "named"}, want: true}, + {name: "deferred quick belongs to adapter", req: Request{StartService: true, TunnelMode: "quick", DeferCommit: true}, want: false}, + {name: "deferred named belongs to adapter", req: Request{StartService: true, TunnelMode: "named", DeferCommit: true}, want: false}, + {name: "core only", req: Request{StartService: true, TunnelMode: "none"}, want: false}, + {name: "service not started", req: Request{TunnelMode: "quick"}, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := shouldStartTunnelInTransaction(test.req); got != test.want { + t.Fatalf("shouldStartTunnelInTransaction()=%v, want %v", got, test.want) + } + }) + } +} + func TestWaitNamedTunnelReadyRequiresRunningProcess(t *testing.T) { root := t.TempDir() request := Request{ diff --git a/internal/installer/service.go b/internal/installer/service.go index e5c70635..6d530d66 100644 --- a/internal/installer/service.go +++ b/internal/installer/service.go @@ -360,17 +360,15 @@ func startWindowsServices(ctx context.Context, request Request, journal *rollbac }) } -func startWindowsTunnel(ctx context.Context, request Request, journal *rollbackJournal) error { - binary := windowsServiceBinary(request) - if binary == "" { - return fmt.Errorf("Windows Tunnel 启动找不到 agentdock 二进制") - } +func startWindowsTunnel(_ context.Context, request Request, journal *rollbackJournal) error { if !journal.hasService("agentdock-tunnel") { if err := journal.NoteService(journalService{Manager: "windows", Name: "agentdock-tunnel"}); err != nil { return err } } - if err := runCmd(ctx, binary, "tunnel", "start", "--runtime-root", request.RuntimeRoot); err != nil { + // Windows Tunnel startup is intentionally detached. The WinExe proxy owns the potentially + // slow Cloudflare readiness loop; Installer Engine must not block Core commit on it. + if err := launchWindowsTunnelProxy(request.RuntimeRoot); err != nil { return err } return journal.updateService("agentdock-tunnel", func(service *journalService) { diff --git a/internal/installer/tunnel_start_nonwindows.go b/internal/installer/tunnel_start_nonwindows.go new file mode 100644 index 00000000..36d7b2cb --- /dev/null +++ b/internal/installer/tunnel_start_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package installer + +import "errors" + +func launchWindowsTunnelProxy(string) error { + return errors.New("Windows Tunnel proxy is unavailable on this platform") +} diff --git a/internal/installer/tunnel_start_windows.go b/internal/installer/tunnel_start_windows.go new file mode 100644 index 00000000..aa15741a --- /dev/null +++ b/internal/installer/tunnel_start_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package installer + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "github.com/uvwt/agentdock/internal/desktopruntime" + "golang.org/x/sys/windows" +) + +func launchWindowsTunnelProxy(runtimeRoot string) error { + manifest, err := desktopruntime.Load(filepath.Join(runtimeRoot, "runtime.json")) + if err != nil { + return fmt.Errorf("load Windows runtime for Tunnel startup: %w", err) + } + trayBinary := strings.TrimSpace(manifest.TrayBinary) + if trayBinary == "" { + return fmt.Errorf("Windows runtime manifest is missing tray_binary") + } + command := exec.Command(trayBinary, "--start-tunnel", "--runtime-root", runtimeRoot) + command.Dir = runtimeRoot + command.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS, + } + if err := command.Start(); err != nil { + return fmt.Errorf("start Windows Tunnel proxy: %w", err) + } + if err := command.Process.Release(); err != nil { + return fmt.Errorf("release Windows Tunnel proxy: %w", err) + } + return nil +} diff --git a/internal/selfupdate/desktop_update_darwin.go b/internal/selfupdate/desktop_update_darwin.go index 3bc3c386..19597bfa 100644 --- a/internal/selfupdate/desktop_update_darwin.go +++ b/internal/selfupdate/desktop_update_darwin.go @@ -206,14 +206,26 @@ func validateMacOSDesktopRuntime(ctx context.Context, appPath, targetVersion str menuHelper := filepath.Join(appPath, "Contents", "Helpers", "AgentDockLoginHelper") menuAgent := filepath.Join(appPath, "Contents", "Library", "LaunchAgents", "com.uvwt.agentdock.menu-login.plist") skillManifest := filepath.Join(appPath, "Contents", "Resources", "core-skills", "manifest.json") - for _, path := range []string{core, cloudflared, arbiter, menuHelper, menuAgent, skillManifest} { + for _, path := range []string{core, cloudflared, menuHelper, menuAgent, skillManifest} { info, err := os.Lstat(path) if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("macOS App 缺少有效运行组件: %s", path) } } - if !executableRegularFile(core) || !executableRegularFile(cloudflared) || !executableRegularFile(arbiter) || !executableRegularFile(menuHelper) { - return errors.New("macOS App 内置 Core、cloudflared、Arbiter 或菜单栏登录组件不可执行") + if !executableRegularFile(core) || !executableRegularFile(cloudflared) || !executableRegularFile(menuHelper) { + return errors.New("macOS App 内置 Core、cloudflared 或菜单栏登录组件不可执行") + } + if info, err := os.Lstat(arbiter); err == nil { + // 早期已发布的 0.8.x App 没有 Arbiter,可以作为 legacy 更新目标;但新格式 + // 一旦声明了这个能力,就必须提供可执行、非符号链接且签名有效的 helper。 + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("macOS App 内置 Arbiter 无效: %s", arbiter) + } + if output, verifyErr := exec.CommandContext(ctx, "codesign", "--verify", "--strict", "--verbose=2", arbiter).CombinedOutput(); verifyErr != nil { + return fmt.Errorf("macOS App 内置 Arbiter 签名验证失败: %w: %s", verifyErr, strings.TrimSpace(string(output))) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("检查 macOS App 内置 Arbiter 失败: %w", err) } for _, expected := range []struct { key string @@ -241,9 +253,6 @@ func validateMacOSDesktopRuntime(ctx context.Context, appPath, targetVersion str if err := verifyBinaryVersion(ctx, core, targetVersion); err != nil { return fmt.Errorf("macOS App 内置 Core 版本不匹配: %w", err) } - if output, err := exec.CommandContext(ctx, "codesign", "--verify", "--strict", "--verbose=2", arbiter).CombinedOutput(); err != nil { - return fmt.Errorf("macOS App 内置 Arbiter 签名验证失败: %w: %s", err, strings.TrimSpace(string(output))) - } if output, err := exec.CommandContext(ctx, cloudflared, "--version").CombinedOutput(); err != nil { return fmt.Errorf("macOS App 内置 cloudflared 无法运行: %w: %s", err, strings.TrimSpace(string(output))) } diff --git a/internal/selfupdate/desktop_update_darwin_test.go b/internal/selfupdate/desktop_update_darwin_test.go index 31ae9a50..9b0c16ab 100644 --- a/internal/selfupdate/desktop_update_darwin_test.go +++ b/internal/selfupdate/desktop_update_darwin_test.go @@ -42,6 +42,43 @@ func TestExtractDesktopUpdateArchiveValidatesSignedApp(t *testing.T) { } } +func TestExtractDesktopUpdateArchiveAcceptsPreArbiterApp(t *testing.T) { + dir := t.TempDir() + appPath := writeSignedMacOSAppWithArbiter(t, filepath.Join(dir, "source"), "0.7.1", false) + archivePath := filepath.Join(dir, macOSDesktopArchiveName) + runTestCommand(t, "/usr/bin/ditto", "-c", "-k", "--keepParent", appPath, archivePath) + archiveData, err := os.ReadFile(archivePath) + if err != nil { + t.Fatal(err) + } + + extracted, err := extractDesktopUpdateArchive( + context.Background(), + archiveData, + filepath.Join(dir, "extract"), + "v0.7.1", + ) + if err != nil { + t.Fatalf("pre-Arbiter App was rejected as an update target: %v", err) + } + if _, err := os.Stat(filepath.Join(extracted, "Contents", "Helpers", "agentdock-arbiter")); !os.IsNotExist(err) { + t.Fatalf("pre-Arbiter fixture unexpectedly contains Arbiter: %v", err) + } +} + +func TestValidateMacOSDesktopRuntimeRejectsInvalidPresentArbiter(t *testing.T) { + appPath := writeSignedMacOSApp(t, t.TempDir(), "0.7.1") + arbiter := filepath.Join(appPath, "Contents", "Helpers", "agentdock-arbiter") + if err := os.Chmod(arbiter, 0o644); err != nil { + t.Fatal(err) + } + + err := validateMacOSDesktopRuntime(context.Background(), appPath, "v0.7.1") + if err == nil || !strings.Contains(err.Error(), "内置 Arbiter 无效") { + t.Fatalf("invalid present Arbiter was not rejected: %v", err) + } +} + func TestValidateMacOSDesktopRuntimeRejectsUnsafeMenuAgent(t *testing.T) { tests := []struct { name string @@ -275,6 +312,11 @@ func writeHandoffTestMacOSApp(t *testing.T, root string) string { } func writeSignedMacOSApp(t *testing.T, root, version string) string { + t.Helper() + return writeSignedMacOSAppWithArbiter(t, root, version, true) +} + +func writeSignedMacOSAppWithArbiter(t *testing.T, root, version string, includeArbiter bool) string { t.Helper() appPath := filepath.Join(root, "AgentDock.app") contents := filepath.Join(appPath, "Contents") @@ -308,8 +350,10 @@ func writeSignedMacOSApp(t *testing.T, root, version string) string { if err := os.WriteFile(filepath.Join(helpersDir, "cloudflared"), cloudflaredBinary, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(helpersDir, "agentdock-arbiter"), cloudflaredBinary, 0o755); err != nil { - t.Fatal(err) + if includeArbiter { + if err := os.WriteFile(filepath.Join(helpersDir, "agentdock-arbiter"), cloudflaredBinary, 0o755); err != nil { + t.Fatal(err) + } } if err := os.WriteFile(filepath.Join(helpersDir, "AgentDockLoginHelper"), cloudflaredBinary, 0o755); err != nil { t.Fatal(err) @@ -347,7 +391,9 @@ func writeSignedMacOSApp(t *testing.T, root, version string) string { runTestCommand(t, "/usr/bin/codesign", "--force", "--sign", "-", "--identifier", "com.uvwt.agentdock.login-helper", filepath.Join(helpersDir, "AgentDockLoginHelper")) runTestCommand(t, "/usr/bin/codesign", "--force", "--sign", "-", "--identifier", "com.uvwt.agentdock.core", filepath.Join(helpersDir, "agentdock")) runTestCommand(t, "/usr/bin/codesign", "--force", "--sign", "-", "--identifier", "com.uvwt.agentdock.cloudflared", filepath.Join(helpersDir, "cloudflared")) - runTestCommand(t, "/usr/bin/codesign", "--force", "--sign", "-", "--identifier", "com.uvwt.agentdock.arbiter", filepath.Join(helpersDir, "agentdock-arbiter")) + if includeArbiter { + runTestCommand(t, "/usr/bin/codesign", "--force", "--sign", "-", "--identifier", "com.uvwt.agentdock.arbiter", filepath.Join(helpersDir, "agentdock-arbiter")) + } runTestCommand(t, "/usr/bin/codesign", "--force", "--deep", "--sign", "-", "--identifier", "com.uvwt.agentdock", appPath) if err := validateMacOSDesktopRuntime(context.Background(), appPath, version); err != nil { t.Fatal(err) diff --git a/internal/selfupdate/managed_desktop_darwin.go b/internal/selfupdate/managed_desktop_darwin.go index 23f2a9ba..bd34ea2f 100644 --- a/internal/selfupdate/managed_desktop_darwin.go +++ b/internal/selfupdate/managed_desktop_darwin.go @@ -31,10 +31,12 @@ func applyManagedDesktopOnlyUpdate(ctx context.Context, request applyRequest) (a } sourceArbiter := filepath.Join(request.DesktopTargetPath, "Contents", "Helpers", "agentdock-arbiter") - if !executableRegularFile(sourceArbiter) { - // A pre-Arbiter App cannot manufacture a known-good source Arbiter after the fact. - // Preserve the existing updater for exactly this bootstrap transition; the target App - // embeds the Arbiter, so every following update uses the durable engine below. + targetArbiter := filepath.Join(request.DesktopStagedPath, "Contents", "Helpers", "agentdock-arbiter") + if !executableRegularFile(sourceArbiter) || !executableRegularFile(targetArbiter) { + // Arbiter was introduced after the early 0.8.x App update protocol. If either side of + // the transition predates it, use the already-established legacy atomic App updater. + // New Release packages are required to embed Arbiter by the macOS packaging test, so + // this capability fallback cannot silently excuse a newly produced incomplete package. return applyResult{}, false, nil } diff --git a/internal/selfupdate/managed_desktop_darwin_test.go b/internal/selfupdate/managed_desktop_darwin_test.go index 29b0cb4f..c79db792 100644 --- a/internal/selfupdate/managed_desktop_darwin_test.go +++ b/internal/selfupdate/managed_desktop_darwin_test.go @@ -3,6 +3,7 @@ package selfupdate import ( + "context" "os" "path/filepath" "testing" @@ -10,6 +11,41 @@ import ( "github.com/uvwt/agentdock/internal/updateengine" ) +func TestManagedDesktopUpdateFallsBackForPreArbiterTarget(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + coordinationDir := filepath.Join(home, "Library", "Application Support", "AgentDock") + if err := os.MkdirAll(coordinationDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(coordinationDir, "update-services.json"), []byte(`{"schema_version":1,"core_enabled":false,"tunnel_enabled":false}`), 0o600); err != nil { + t.Fatal(err) + } + + sourceApp := filepath.Join(t.TempDir(), "AgentDock.app") + targetApp := filepath.Join(t.TempDir(), "AgentDock.app") + for _, app := range []string{sourceApp, targetApp} { + if err := os.MkdirAll(filepath.Join(app, "Contents", "Helpers"), 0o755); err != nil { + t.Fatal(err) + } + } + sourceArbiter := filepath.Join(sourceApp, "Contents", "Helpers", "agentdock-arbiter") + if err := os.WriteFile(sourceArbiter, []byte("source arbiter"), 0o700); err != nil { + t.Fatal(err) + } + + _, handled, err := applyManagedDesktopOnlyUpdate(context.Background(), applyRequest{ + DesktopTargetPath: sourceApp, + DesktopStagedPath: targetApp, + }) + if err != nil { + t.Fatalf("capability fallback returned an error: %v", err) + } + if handled { + t.Fatal("pre-Arbiter target unexpectedly entered the managed transaction engine") + } +} + func TestCleanupMacOSUpdateArtifactsKeepsDurableTerminalResult(t *testing.T) { root := t.TempDir() trialPath := filepath.Join(root, ".AgentDock.app.trial.tx-test") diff --git a/internal/updateplatform/darwin.go b/internal/updateplatform/darwin.go index cfff2a4b..225d9123 100644 --- a/internal/updateplatform/darwin.go +++ b/internal/updateplatform/darwin.go @@ -124,16 +124,9 @@ func (driver *DarwinDriver) VerifyTrial(ctx context.Context, transaction updatee return nil, fmt.Errorf("target Core registration did not become usable: %s", handoff.CoreRegistration) } } - if plan.TunnelEnabled { - switch handoff.TunnelRegistration { - case "requires_approval": - warnings = append(warnings, "AgentDock Tunnel requires background-item approval in System Settings.") - case "enabled": - // Tunnel readiness depends on credentials/network/external Cloudflare state and is intentionally soft. - default: - warnings = append(warnings, "AgentDock Tunnel registration was not ready after update: "+handoff.TunnelRegistration) - } - } + // Tunnel/public access is intentionally outside the update result boundary. Handoff records + // its registration state for recovery/diagnostics, but Cloudflare/network/background-item + // readiness must not decorate or block a successful Core update. return warnings, nil } diff --git a/internal/updateplatform/darwin_test.go b/internal/updateplatform/darwin_test.go index 136dae4f..1e3d8f7e 100644 --- a/internal/updateplatform/darwin_test.go +++ b/internal/updateplatform/darwin_test.go @@ -79,13 +79,9 @@ func TestDarwinVerifyTrialTreatsRequiresApprovalAsWarning(t *testing.T) { if err != nil { t.Fatalf("VerifyTrial() error = %v", err) } - for _, want := range []string{ - "AgentDock Core requires background-item approval in System Settings.", - "AgentDock Tunnel requires background-item approval in System Settings.", - } { - if !slices.Contains(warnings, want) { - t.Fatalf("VerifyTrial() warnings = %q, want %q", warnings, want) - } + want := []string{"AgentDock Core requires background-item approval in System Settings."} + if !slices.Equal(warnings, want) { + t.Fatalf("VerifyTrial() warnings = %q, want only Core warning %q", warnings, want) } } @@ -114,7 +110,7 @@ func TestMacOSDesignatedRequirementClassification(t *testing.T) { } } -func TestDarwinVerifyTrialTreatsUnavailableTunnelAsWarning(t *testing.T) { +func TestDarwinVerifyTrialDoesNotWarnForUnavailableTunnel(t *testing.T) { root := t.TempDir() driver, err := NewDarwinDriver(root) if err != nil { @@ -146,9 +142,8 @@ func TestDarwinVerifyTrialTreatsUnavailableTunnelAsWarning(t *testing.T) { if err != nil { t.Fatalf("VerifyTrial() error = %v", err) } - want := "AgentDock Tunnel registration was not ready after update: unavailable" - if !slices.Contains(warnings, want) { - t.Fatalf("VerifyTrial() warnings = %q, want %q", warnings, want) + if len(warnings) != 0 { + t.Fatalf("VerifyTrial() warnings = %q, Tunnel/public readiness must not decorate update completion", warnings) } } diff --git a/internal/updateplatform/windows.go b/internal/updateplatform/windows.go index 68d0208d..4092dbbb 100644 --- a/internal/updateplatform/windows.go +++ b/internal/updateplatform/windows.go @@ -10,10 +10,12 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "time" "github.com/uvwt/agentdock/internal/desktopruntime" "github.com/uvwt/agentdock/internal/updateengine" + "golang.org/x/sys/windows" ) type WindowsDriver struct { @@ -98,25 +100,31 @@ func (driver *WindowsDriver) VerifyTrial(ctx context.Context, transaction update } } - var warnings []string - if plan.TunnelWasRunning { - if err := driver.runStableCore(ctx, "tunnel", "start", "--runtime-root", driver.root); err != nil { - warnings = append(warnings, "Tunnel could not be restored after update: "+err.Error()) - } - } - return warnings, nil + // Tunnel/public access is a soft dependency and is intentionally excluded from trial + // verification. It is restarted asynchronously only after the active pointer is committed. + return nil, nil } func (driver *WindowsDriver) Commit(_ context.Context, transaction updateengine.Transaction) error { + plan, err := driver.plan(transaction) + if err != nil { + return err + } // source generation 必须保留到 terminal result 落盘以后;这里仅把 pointer // 从 trial 收敛成 committed,不做任何不可逆清理。 - return driver.store.WriteActive(updateengine.ActiveVersion{ + if err := driver.store.WriteActive(updateengine.ActiveVersion{ SchemaVersion: updateengine.SchemaVersion, ActiveVersion: transaction.TargetVersion, FallbackVersion: transaction.SourceVersion, State: updateengine.StateCommitted, UpdatedAt: time.Now().UTC(), - }) + }); err != nil { + return err + } + if plan.TunnelWasRunning { + _ = driver.startTunnelAfterCommit() + } + return nil } func (driver *WindowsDriver) Rollback(ctx context.Context, transaction updateengine.Transaction) error { @@ -155,9 +163,7 @@ func (driver *WindowsDriver) Rollback(ctx context.Context, transaction updateeng } } if plan.TunnelWasRunning { - if err := driver.runStableCore(ctx, "tunnel", "start", "--runtime-root", driver.root); err != nil { - rollbackErrors = append(rollbackErrors, fmt.Errorf("restart source tunnel: %w", err)) - } + _ = driver.startTunnelAfterCommit() } return errors.Join(rollbackErrors...) } @@ -206,6 +212,22 @@ func (driver *WindowsDriver) runStableCore(ctx context.Context, args ...string) return nil } +func (driver *WindowsDriver) startTunnelAfterCommit() error { + command := exec.Command(driver.layout.TrayShim(), "--start-tunnel", "--runtime-root", driver.root) + command.Dir = driver.root + command.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS, + } + if err := command.Start(); err != nil { + return fmt.Errorf("start Tunnel recovery proxy: %w", err) + } + if err := command.Process.Release(); err != nil { + return fmt.Errorf("release Tunnel recovery proxy: %w", err) + } + return nil +} + func (driver *WindowsDriver) startStableTray(ctx context.Context) error { command := exec.CommandContext(ctx, driver.layout.TrayShim(), "--background") command.Dir = driver.root diff --git a/packaging/windows/includes/code.iss b/packaging/windows/includes/code.iss index 2e657a2a..1e7c98e0 100644 --- a/packaging/windows/includes/code.iss +++ b/packaging/windows/includes/code.iss @@ -241,7 +241,8 @@ begin '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ' + QuoteArgument(ExpandConstant('{tmp}\launch-windows-process.ps1')) + ' -FilePath ' + QuoteArgument(Filename) + - ' -AgentDockBinary ' + QuoteArgument(ExpandConstant('{app}\bin\agentdock.exe')); + ' -AgentDockBinary ' + QuoteArgument(ExpandConstant('{app}\bin\agentdock.exe')) + + ' -HiddenHostBinary ' + QuoteArgument(ExpandConstant('{app}\bin\agentdock-tray.exe')); if Arguments <> '' then Parameters := Parameters + ' -Arguments ' + QuoteArgument(Arguments); diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 6ce1723d..a865823b 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -50,7 +50,8 @@ function Invoke-SetupRuntimeProcess { # while Setup keeps RedirectionGuard enabled for install-time filesystem work. & $setupRuntimeLauncherPath ` -FilePath $FilePath ` - -AgentDockBinary $destinationBinary ` + -AgentDockBinary $sourceBinary ` + -HiddenHostBinary $destinationTrayBinary ` -Arguments $Arguments ` -WaitForExit:$WaitForExit } @@ -900,69 +901,6 @@ function Wait-AgentDockHealth { throw "AgentDock was installed, but health check failed at $healthUrl" } -function Wait-CloudflaredRunning { - param([string] $BinaryPath) - - $deadline = [DateTime]::UtcNow.AddSeconds(20) - do { - Start-Sleep -Milliseconds 500 - if (@(Get-CloudflaredProcesses -BinaryPath $BinaryPath).Count -gt 0) { - return - } - } while ([DateTime]::UtcNow -lt $deadline) - throw "cloudflared did not stay running: $BinaryPath" -} - -function Wait-QuickTunnelUrl { - param([string[]] $LogPaths) - - $deadline = [DateTime]::UtcNow.AddSeconds(30) - do { - Start-Sleep -Milliseconds 500 - foreach ($logPath in $LogPaths) { - try { - if (Test-Path -LiteralPath $logPath -PathType Leaf) { - $content = Get-Content -LiteralPath $logPath -Raw -ErrorAction Stop - # Provisioning failures also print the trycloudflare API URL; require the creation marker first. - $match = [Regex]::Match( - $content, - '(?s)Your quick Tunnel has been created! Visit it at.*?(https://[A-Za-z0-9-]+\.trycloudflare\.com)' - ) - if ($match.Success) { - return $match.Groups[1].Value - } - } - } catch { - } - } - } while ([DateTime]::UtcNow -lt $deadline) - throw "cloudflared started, but no temporary trycloudflare.com URL appeared in: $($LogPaths -join ', ')" -} - -function Wait-QuickTunnelReady { - param( - [string] $Path, - [string] $ExpectedUrl - ) - - $deadline = [DateTime]::UtcNow.AddSeconds(35) - do { - Start-Sleep -Milliseconds 500 - try { - if ((Test-Path -LiteralPath $Path -PathType Leaf) -and - [string]::Equals( - [IO.File]::ReadAllText($Path).Trim(), - $ExpectedUrl, - [StringComparison]::OrdinalIgnoreCase - )) { - return - } - } catch { - } - } while ([DateTime]::UtcNow -lt $deadline) - throw "Quick Tunnel generated $ExpectedUrl, but AgentDock did not finish adopting it." -} - function Backup-FileState { param( [string] $Path, @@ -1565,7 +1503,7 @@ try { -Action $taskAction ` -BackupDirectory $taskBackupDirectory ` -AdminLauncherPath $sourceTrayBinary ` - -LauncherPath $destinationBinary ` + -LauncherPath $destinationTrayBinary ` -RuntimeRoot $runtimeDir ` -TaskUser $taskUser if (-not $taskActionResult.Started) { @@ -1877,10 +1815,9 @@ exit `$LASTEXITCODE if ($engineOwnsActivation -and $RegisterStartup) { $healthStatus = 'healthy' if ($resolvedTunnelMode -eq 'quick') { + # Tunnel/public readiness is a soft dependency. Record a URL only if it is already + # available; the control panel will show eventual readiness after install/update. $publicUrl = Read-TextFile -Path $quickTunnelUrlPath - if ([string]::IsNullOrWhiteSpace($publicUrl)) { - throw 'Installer Engine finished trial without a Quick Tunnel public address.' - } } elseif ($resolvedTunnelMode -eq 'named') { $publicUrl = $ServerUrl } @@ -1901,29 +1838,10 @@ exit `$LASTEXITCODE Wait-AgentDockHealth -HealthPort $Port $healthStatus = 'healthy' - if ($resolvedTunnelMode -ne 'none') { - if ($InstallChannel -eq 'setup') { - Invoke-SetupRuntimeProcess ` - -FilePath $destinationBinary ` - -Arguments "tunnel start --runtime-root `"$runtimeDir`"" ` - -WaitForExit - } else { - & $destinationBinary tunnel start --runtime-root $runtimeDir - if ($LASTEXITCODE -ne 0) { - throw "AgentDock native Tunnel start failed with exit code $LASTEXITCODE." - } - } - - if ($resolvedTunnelMode -eq 'quick') { - $publicUrl = Read-TextFile -Path $quickTunnelUrlPath - if ([string]::IsNullOrWhiteSpace($publicUrl)) { - $publicUrl = Wait-QuickTunnelUrl -LogPaths @($cloudflaredStdoutLogPath, $cloudflaredStderrLogPath) - } - Wait-QuickTunnelReady -Path $quickTunnelUrlPath -ExpectedUrl $publicUrl - } else { - $publicUrl = $ServerUrl - Wait-CloudflaredRunning -BinaryPath $cloudflaredBinary - } + if ($resolvedTunnelMode -eq 'quick') { + $publicUrl = Read-TextFile -Path $quickTunnelUrlPath + } elseif ($resolvedTunnelMode -eq 'named') { + $publicUrl = $ServerUrl } } elseif ($mustRestartExistingProcess) { if ($InstallChannel -eq 'setup') { @@ -1976,6 +1894,33 @@ exit `$LASTEXITCODE $engineCommitted = $true } + # Core is authoritative for install/update success. Start Tunnel only after commit and do it + # asynchronously through the existing WinExe startup proxy so Cloudflare/network readiness + # cannot hold the transaction or its success UI open. + if ($RegisterStartup -and $resolvedTunnelMode -ne 'none') { + try { + $tunnelStartupArguments = "--start-tunnel --runtime-root `"$runtimeDir`"" + if ($InstallChannel -eq 'setup') { + Invoke-SetupRuntimeProcess ` + -FilePath $destinationTrayBinary ` + -Arguments $tunnelStartupArguments + } else { + Start-Process ` + -FilePath $destinationTrayBinary ` + -ArgumentList $tunnelStartupArguments ` + -WindowStyle Hidden | Out-Null + } + } catch { + # Tunnel/public readiness is shown by the control panel and retained in Tunnel logs. + # Do not turn this soft dependency into an install/update warning or rollback. + } + if ($resolvedTunnelMode -eq 'quick') { + $publicUrl = Read-TextFile -Path $quickTunnelUrlPath + } elseif ($resolvedTunnelMode -eq 'named') { + $publicUrl = $ServerUrl + } + } + $taskTransactionCommitted = $taskTransactionStarted $publicMCPUrl = '' if (-not [string]::IsNullOrWhiteSpace($publicUrl)) { @@ -2171,22 +2116,30 @@ exit `$LASTEXITCODE Wait-AgentDockHealth -HealthPort $Port } if ($cloudflaredProcessWasRunning) { - if (Test-Path -LiteralPath $destinationBinary -PathType Leaf) { - # Native tunnel start has authoritative Quick/Named readiness. Wait for it before - # abandon so a regenerated Quick URL is projected into the final rollback result. - if ($InstallChannel -eq 'setup') { - Invoke-SetupRuntimeProcess ` - -FilePath $destinationBinary ` - -Arguments "tunnel start --runtime-root `"$runtimeDir`"" ` - -WaitForExit - } else { - & $destinationBinary tunnel start --runtime-root $runtimeDir - if ($LASTEXITCODE -ne 0) { - throw "AgentDock rollback Tunnel start failed with exit code $LASTEXITCODE." + # Rollback success is anchored to the restored source Core + local health. Tunnel/public + # recovery is best-effort and must not turn Cloudflare/network delay into rollback_failed. + if (Test-Path -LiteralPath $destinationTrayBinary -PathType Leaf) { + try { + $rollbackTunnelArguments = "--start-tunnel --runtime-root `"$runtimeDir`"" + if ($InstallChannel -eq 'setup') { + Invoke-SetupRuntimeProcess ` + -FilePath $destinationTrayBinary ` + -Arguments $rollbackTunnelArguments + } else { + Start-Process ` + -FilePath $destinationTrayBinary ` + -ArgumentList $rollbackTunnelArguments ` + -WindowStyle Hidden | Out-Null } + } catch { + # Tunnel diagnostics remain available through panel/runtime logs. } } elseif (Test-Path -LiteralPath $cloudflaredLauncherPath -PathType Leaf) { - Start-CloudflaredLauncher -LauncherPath $cloudflaredLauncherPath + try { + Start-CloudflaredLauncher -LauncherPath $cloudflaredLauncherPath + } catch { + # Legacy launcher recovery is also a soft dependency. + } } } if ($trayProcessWasRunning -and (Test-Path -LiteralPath $destinationTrayBinary -PathType Leaf)) { diff --git a/scripts/install/launch-windows-process.ps1 b/scripts/install/launch-windows-process.ps1 index 1b39c2c7..07d54543 100644 --- a/scripts/install/launch-windows-process.ps1 +++ b/scripts/install/launch-windows-process.ps1 @@ -6,6 +6,9 @@ param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $AgentDockBinary, + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $HiddenHostBinary, [string] $Arguments = '', [switch] $WaitForExit, [ValidateRange(1, 120)] @@ -78,6 +81,9 @@ if ($null -eq $identity -or $null -eq $identity.User -or [string]::IsNullOrWhite if (-not (Test-Path -LiteralPath $AgentDockBinary -PathType Leaf)) { throw "AgentDock native task launcher was not found: $AgentDockBinary" } +if (-not (Test-Path -LiteralPath $HiddenHostBinary -PathType Leaf)) { + throw "AgentDock hidden runtime host was not found: $HiddenHostBinary" +} $taskName = 'AgentDock Setup Runtime ' + [Guid]::NewGuid().ToString('N') $diagnosticRoot = '' @@ -92,71 +98,41 @@ if ($WaitForExit) { New-Item -ItemType Directory -Path $diagnosticRoot -Force | Out-Null } -$wrapperLines = @("`$ErrorActionPreference = 'Stop'") -foreach ($name in @('AGENTDOCK_HOME', 'AGENTDOCK_DEFAULT_DIR')) { - $value = [Environment]::GetEnvironmentVariable($name, 'Process') - if ($null -ne $value) { - $encodedValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($value)) - $wrapperLines += "`$env:$name = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedValue'))" - } +function ConvertTo-RuntimeHostArgument { + param([AllowEmptyString()][string] $Value) + return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value)) } -$encodedFilePath = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($FilePath)) -$wrapperLines += "`$filePath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedFilePath'))" + +# The scheduled task itself must be a GUI-subsystem process. Starting powershell.exe as the +# interactive task action can create a console before -WindowStyle Hidden takes effect. +# The WinExe tray shim is therefore a narrow host that creates the real child with CREATE_NO_WINDOW. +$hostArguments = @( + '--setup-runtime-host', + '--file-b64', (ConvertTo-RuntimeHostArgument -Value $FilePath) +) if (-not [string]::IsNullOrWhiteSpace($Arguments)) { - $encodedArguments = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Arguments)) - $wrapperLines += "`$arguments = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedArguments'))" + $hostArguments += @('--args-b64', (ConvertTo-RuntimeHostArgument -Value $Arguments)) } -if ($WaitForExit) { - $encodedStdoutPath = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($stdoutPath)) - $encodedStderrPath = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($stderrPath)) - $encodedWrapperErrorPath = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($wrapperErrorPath)) - $wrapperLines += "`$stdoutPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedStdoutPath'))" - $wrapperLines += "`$stderrPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedStderrPath'))" - $wrapperLines += "`$wrapperErrorPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedWrapperErrorPath'))" - $wrapperLines += 'try {' - - # Windows PowerShell 5.1 loses ExitCode when Start-Process combines PassThru with redirected streams - # unless -Wait is used. -Wait can also follow descendant processes, which would change the runtime lifecycle. - # ProcessStartInfo keeps the original direct-child wait semantics while capturing both diagnostic streams. - $wrapperLines += ' $startInfo = New-Object Diagnostics.ProcessStartInfo' - $wrapperLines += ' $startInfo.FileName = $filePath' - if (-not [string]::IsNullOrWhiteSpace($Arguments)) { - $wrapperLines += ' $startInfo.Arguments = $arguments' - } - $wrapperLines += ' $startInfo.UseShellExecute = $false' - $wrapperLines += ' $startInfo.CreateNoWindow = $true' - $wrapperLines += ' $startInfo.RedirectStandardOutput = $true' - $wrapperLines += ' $startInfo.RedirectStandardError = $true' - $wrapperLines += ' $process = New-Object Diagnostics.Process' - $wrapperLines += ' $process.StartInfo = $startInfo' - $wrapperLines += " if (-not `$process.Start()) { throw 'Runtime process could not be started.' }" - $wrapperLines += ' $stdoutTask = $process.StandardOutput.ReadToEndAsync()' - $wrapperLines += ' $stderrTask = $process.StandardError.ReadToEndAsync()' - $wrapperLines += ' $process.WaitForExit()' - $wrapperLines += ' $stdout = $stdoutTask.GetAwaiter().GetResult()' - $wrapperLines += ' $stderr = $stderrTask.GetAwaiter().GetResult()' - $wrapperLines += ' [IO.File]::WriteAllText($stdoutPath, $stdout, (New-Object Text.UTF8Encoding($false)))' - $wrapperLines += ' [IO.File]::WriteAllText($stderrPath, $stderr, (New-Object Text.UTF8Encoding($false)))' - $wrapperLines += ' exit $process.ExitCode' - $wrapperLines += '} catch {' - $wrapperLines += ' [IO.File]::WriteAllText($wrapperErrorPath, ($_ | Out-String), (New-Object Text.UTF8Encoding($false)))' - $wrapperLines += ' exit 1' - $wrapperLines += '}' -} else { - if ([string]::IsNullOrWhiteSpace($Arguments)) { - $wrapperLines += '$process = Start-Process -FilePath $filePath -PassThru' - } else { - $wrapperLines += '$process = Start-Process -FilePath $filePath -ArgumentList $arguments -PassThru' +foreach ($environment in @( + @{ Name = 'AGENTDOCK_HOME'; Flag = '--agentdock-home-b64' }, + @{ Name = 'AGENTDOCK_DEFAULT_DIR'; Flag = '--agentdock-default-dir-b64' } +)) { + $value = [Environment]::GetEnvironmentVariable($environment.Name, 'Process') + if ($null -ne $value) { + $hostArguments += @($environment.Flag, (ConvertTo-RuntimeHostArgument -Value $value)) } - $wrapperLines += 'exit 0' } -$encodedCommand = [Convert]::ToBase64String( - [Text.Encoding]::Unicode.GetBytes(($wrapperLines -join "`r`n")) -) -$powerShellPath = Join-Path $PSHOME 'powershell.exe' +if ($WaitForExit) { + $hostArguments += @( + '--wait', + '--stdout-b64', (ConvertTo-RuntimeHostArgument -Value $stdoutPath), + '--stderr-b64', (ConvertTo-RuntimeHostArgument -Value $stderrPath), + '--error-b64', (ConvertTo-RuntimeHostArgument -Value $wrapperErrorPath) + ) +} $action = New-ScheduledTaskAction ` - -Execute $powerShellPath ` - -Argument "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand $encodedCommand" + -Execute $HiddenHostBinary ` + -Argument ($hostArguments -join ' ') $principal = New-ScheduledTaskPrincipal ` -UserId $identity.Name ` -LogonType Interactive ` diff --git a/scripts/test/desktop_windows_test.go b/scripts/test/desktop_windows_test.go index 289b1fa6..df2db290 100644 --- a/scripts/test/desktop_windows_test.go +++ b/scripts/test/desktop_windows_test.go @@ -138,6 +138,7 @@ func TestWindowsControlPanelCanSwitchCorePrivilegeMode(t *testing.T) { "prepare-elevated", "prepare-standard", "RunTaskAdminTransitionAsync(\"restore\"", + `"--launcher-path", trayBinary`, "WritePrivilegeModeAsync", "SetStandardCoreStartup", "snapshot.CoreStartupEnabled", @@ -477,3 +478,31 @@ func TestWindowsControlPanelResolvesRuntimeRootFromExecutableDirectory(t *testin t.Fatal("RuntimeService must not resolve the parent from a trailing AppContext.BaseDirectory string") } } + +func TestWindowsBackgroundTrayStartupDoesNotShowExistingControlPanel(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "desktop", "windows", "control-panel", "App.xaml.cs")) + if err != nil { + t.Fatalf("read App.xaml.cs: %v", err) + } + app := strings.ReplaceAll(string(data), "\r\n", "\n") + backgroundDeclaration := `var background = e.Args.Any(arg => string.Equals(arg, "--background", StringComparison.OrdinalIgnoreCase));` + backgroundIndex := strings.Index(app, backgroundDeclaration) + singletonIndex := strings.Index(app, "if (!createdNew)") + if backgroundIndex < 0 || singletonIndex < 0 || backgroundIndex > singletonIndex { + t.Fatal("Windows tray must resolve --background before handling the singleton instance") + } + branchEnd := strings.Index(app[singletonIndex:], "Shutdown();") + if branchEnd < 0 { + t.Fatal("Windows tray singleton branch is incomplete") + } + singletonBranch := app[singletonIndex : singletonIndex+branchEnd] + if !strings.Contains(singletonBranch, "if (!background)") || !strings.Contains(singletonBranch, "existingEvent.Set();") { + t.Fatal("only an explicit foreground launch may ask an existing tray instance to show the control panel") + } + if strings.Count(app, backgroundDeclaration) != 1 { + t.Fatal("Windows tray should have one authoritative --background startup decision") + } + if !strings.Contains(app, "if (!background)\n {\n ShowControlPanel();") { + t.Fatal("an explicit foreground launch must still show the control panel") + } +} diff --git a/scripts/test/install_windows_test.go b/scripts/test/install_windows_test.go index 714a0710..7cf596ea 100644 --- a/scripts/test/install_windows_test.go +++ b/scripts/test/install_windows_test.go @@ -100,14 +100,14 @@ func TestInstallWindowsUsesChecksumsDPAPIAndCurrentUserStartup(t *testing.T) { "service launch-core --runtime-root", "--start-core --runtime-root", "& $destinationBinary service start --runtime-root $runtimeDir", - "& $destinationBinary tunnel start --runtime-root $runtimeDir", "--start-tunnel --runtime-root", + "$tunnelStartupArguments = \"--start-tunnel --runtime-root", + "-FilePath $destinationTrayBinary", + "-Arguments $tunnelStartupArguments", "-AdminLauncherPath $sourceTrayBinary", - "-LauncherPath $destinationBinary", + "-LauncherPath $destinationTrayBinary", "-FilePath $AdminLauncherPath", "Start-CloudflaredLauncher -LauncherPath $cloudflaredLauncherPath", - "Wait-QuickTunnelUrl -LogPaths @($cloudflaredStdoutLogPath, $cloudflaredStderrLogPath)", - "Wait-QuickTunnelReady -Path $quickTunnelUrlPath -ExpectedUrl $publicUrl", "quick-tunnel-url.txt", "& '$escapedBinaryPath' tunnel launch --runtime-root '$escapedRuntimeDir'", "RuntimeInformation]::OSArchitecture", @@ -140,6 +140,17 @@ func TestInstallWindowsUsesChecksumsDPAPIAndCurrentUserStartup(t *testing.T) { t.Fatalf("install.ps1 missing %q", want) } } + for _, forbidden := range []string{ + "Wait-CloudflaredRunning", + "Wait-QuickTunnelUrl", + "Wait-QuickTunnelReady", + "Installer Engine finished trial without a Quick Tunnel public address.", + "& $destinationBinary tunnel start --runtime-root $runtimeDir", + } { + if strings.Contains(script, forbidden) { + t.Fatalf("install.ps1 must not gate install/update completion on Tunnel/public readiness: %q", forbidden) + } + } for _, forbidden := range []string{"[string] $RuntimeVersion", "version = $RuntimeVersion"} { if strings.Contains(script, forbidden) { t.Fatalf("install.ps1 must not persist the AgentDock version in runtime.json: %q", forbidden) @@ -215,9 +226,10 @@ func TestInstallWindowsUsesChecksumsDPAPIAndCurrentUserStartup(t *testing.T) { } tunnelArg := strings.Index(script, "'--tunnel-mode', $resolvedTunnelMode") coreStartCall := strings.Index(script, "& $destinationBinary service start --runtime-root $runtimeDir") - tunnelStartCall := strings.Index(script, "& $destinationBinary tunnel start --runtime-root $runtimeDir") - if tunnelArg < 0 || coreStartCall < 0 || tunnelStartCall < 0 || tunnelArg > coreStartCall || tunnelArg > tunnelStartCall { - t.Fatal("Installer Engine must receive the resolved tunnel mode before any adapter fallback activation") + tunnelProxyCall := strings.Index(script, "$tunnelStartupArguments = \"--start-tunnel --runtime-root") + tunnelCommitCall := strings.LastIndex(script, "install commit --install-root $runtimeDir --runtime-root $runtimeDir --transaction-id $engineTransactionId") + if tunnelArg < 0 || coreStartCall < 0 || tunnelProxyCall < 0 || tunnelCommitCall < 0 || tunnelArg > coreStartCall || tunnelCommitCall > tunnelProxyCall { + t.Fatal("Installer must pass tunnel intent to the Engine, commit the Core transaction, then launch Tunnel asynchronously") } if strings.Contains(script, "$manifestTunnelMode = 'none'") { t.Fatal("Quick Tunnel must not rewrite Engine tunnel-mode to none") @@ -257,12 +269,12 @@ func TestInstallWindowsUsesChecksumsDPAPIAndCurrentUserStartup(t *testing.T) { } rollbackServiceStart := strings.LastIndex(script, "& $destinationBinary service start --runtime-root $runtimeDir") rollbackHealthWait := strings.LastIndex(script, "Wait-AgentDockHealth -HealthPort $Port") - rollbackTunnelStart := strings.LastIndex(script, "& $destinationBinary tunnel start --runtime-root $runtimeDir") - if rollbackServiceStart < rollbackRestore || rollbackHealthWait < rollbackServiceStart || rollbackTunnelStart < rollbackHealthWait { - t.Fatal("Engine rollback must synchronously restore source Core health and Tunnel readiness after adapter state restoration") + rollbackTunnelProxy := strings.LastIndex(script, "$rollbackTunnelArguments = \"--start-tunnel --runtime-root") + if rollbackServiceStart < rollbackRestore || rollbackHealthWait < rollbackServiceStart || rollbackTunnelProxy < rollbackHealthWait { + t.Fatal("Engine rollback must restore source Core health before scheduling best-effort Tunnel recovery") } - if abandonCall < rollbackTunnelStart { - t.Fatal("install abandon must run only after restored Tunnel readiness is confirmed") + if abandonCall < rollbackTunnelProxy { + t.Fatal("install abandon must run after best-effort Tunnel recovery is scheduled") } if !strings.Contains(script, "--rollback-failed") { t.Fatal("adapter rollback failure must be recorded as failed/rollback_failed, not rolled_back") @@ -335,12 +347,22 @@ func TestWindowsInstallerUsesNativeTaskStartBridge(t *testing.T) { "service task-start", "--task-name", "--expected-user-sid", - "-AgentDockBinary $destinationBinary", + "-AgentDockBinary $sourceBinary", } { if !strings.Contains(combined, want) { t.Fatalf("Windows native task bridge missing %q", want) } } + installScript := strings.ReplaceAll(string(installData), "\r\n", "\n") + bridgeStart := strings.Index(installScript, "function Invoke-SetupRuntimeProcess") + bridgeEnd := strings.Index(installScript, "function Get-AgentDockArchitecture") + if bridgeStart < 0 || bridgeEnd <= bridgeStart { + t.Fatal("Setup runtime task-start bridge function boundary is missing") + } + bridge := installScript[bridgeStart:bridgeEnd] + if !strings.Contains(bridge, "-AgentDockBinary $sourceBinary") || strings.Contains(bridge, "-AgentDockBinary $destinationBinary") { + t.Fatal("Setup runtime task-start bridge must use the verified payload Core instead of the stable shim while Installer commit is deferred") + } if strings.Contains(string(brokerData), "manage-windows.ps1") || strings.Contains(combined, "task-run-session") { t.Fatal("Windows runtime launch paths must not depend on the removed manage-windows compatibility shim") } @@ -471,7 +493,7 @@ func TestWindowsTaskAdminUsesNativeAgentDockHelper(t *testing.T) { "Schedule.Service", "TaskRunLevelHighest", "TaskLogonInteractiveToken", - "service launch-core --runtime-root", + "--task-core-host --runtime-root", "SetSecurityDescriptor", "prepare-elevated", "prepare-standard", @@ -495,6 +517,7 @@ func TestWindowsTaskAdminUsesNativeAgentDockHelper(t *testing.T) { for _, forbidden := range []string{ "powershell.exe", "File.Exists(request.LauncherPath)", + "service launch-core --runtime-root", } { if strings.Contains(source, forbidden) { t.Fatalf("TaskAdminService.cs must not depend on %q", forbidden) @@ -786,9 +809,12 @@ func TestWindowsSetupLaunchesRuntimeOutsideRedirectionGuardTree(t *testing.T) { "$setupRuntimeLauncherPath = Join-Path $PSScriptRoot 'launch-windows-process.ps1'", "function Invoke-SetupRuntimeProcess", "-Arguments \"service start --runtime-root", - "-Arguments \"tunnel start --runtime-root", + "$tunnelStartupArguments = \"--start-tunnel --runtime-root", + "-FilePath $destinationTrayBinary", + "-Arguments $tunnelStartupArguments", "Invoke-SetupRuntimeProcess -FilePath $BinaryPath -Arguments '--background'", "Invoke-SetupRuntimeProcess -FilePath (Join-Path $PSHOME 'powershell.exe') -Arguments $arguments", + "-HiddenHostBinary $destinationTrayBinary", } { if !strings.Contains(installScript, want) { t.Fatalf("install.ps1 must route Setup-owned long-lived launches through the runtime broker; missing %q", want) @@ -804,15 +830,21 @@ func TestWindowsSetupLaunchesRuntimeOutsideRedirectionGuardTree(t *testing.T) { "& $AgentDockBinary service task-start", "--task-name $taskName", "--expected-user-sid $identity.User.Value", + "[string] $HiddenHostBinary", "if ($WaitForExit) {", - "$process.WaitForExit()", - "RedirectStandardOutput = $true", - "RedirectStandardError = $true", + "ConvertTo-RuntimeHostArgument", + "--setup-runtime-host", + "--file-b64", + "--wait", + "--stdout-b64", + "--stderr-b64", + "--error-b64", + "-Execute $HiddenHostBinary", + "-Argument ($hostArguments -join ' ')", "Get-RuntimeFailureMessage", "Task Scheduler result: $rawResult", "Read-RuntimeDiagnosticTail", "Remove-Item -LiteralPath $diagnosticRoot -Recurse -Force", - "$wrapperLines += 'exit 0'", "Unregister-ScheduledTask", "AGENTDOCK_HOME", "AGENTDOCK_DEFAULT_DIR", @@ -824,11 +856,17 @@ func TestWindowsSetupLaunchesRuntimeOutsideRedirectionGuardTree(t *testing.T) { if !strings.Contains(brokerScript, "finally {") || !strings.Contains(brokerScript, "Unregister-ScheduledTask") { t.Fatal("runtime launch broker must remove its temporary task even when launch fails") } + for _, forbidden := range []string{"-Execute $powerShellPath", "-EncodedCommand $encodedCommand"} { + if strings.Contains(brokerScript, forbidden) { + t.Fatalf("runtime launch broker must not use a console-subsystem PowerShell task action: %q", forbidden) + } + } for _, want := range []string{ "Source: \"..\\..\\scripts\\install\\launch-windows-process.ps1\"; Flags: dontcopy", "ExtractTemporaryFile('launch-windows-process.ps1')", "-AgentDockBinary ", + "-HiddenHostBinary ", "function LaunchRuntimeProcess(", "LaunchRuntimeProcess(ExpandConstant('{app}\\bin\\agentdock-tray.exe'), '')", } { @@ -859,8 +897,11 @@ func TestWindowsRuntimeDiagnosticsPassesNativeTaskLauncher(t *testing.T) { workflow := strings.ReplaceAll(string(workflowData), "\r\n", "\n") for _, want := range []string{ "[string] $AgentDockBinary", + "[string] $HiddenHostBinary", "$resolvedAgentDockBinary = (Resolve-Path -LiteralPath $AgentDockBinary).Path", + "$resolvedHiddenHostBinary = (Resolve-Path -LiteralPath $HiddenHostBinary).Path", "-AgentDockBinary $resolvedAgentDockBinary", + "-HiddenHostBinary $resolvedHiddenHostBinary", } { if !strings.Contains(diagnostics, want) { t.Fatalf("runtime diagnostics test must pass the native task launcher; missing %q", want) @@ -868,8 +909,11 @@ func TestWindowsRuntimeDiagnosticsPassesNativeTaskLauncher(t *testing.T) { } for _, want := range []string{ "$runtimeTestAgentDockBinary = Join-Path $env:RUNNER_TEMP 'agentdock-runtime-launch-test.exe'", + "$runtimeTestHiddenHostBinary = Join-Path $env:RUNNER_TEMP 'agentdock-runtime-host-test.exe'", "go build -trimpath -o $runtimeTestAgentDockBinary .\\cmd\\agentdock", + "go build -trimpath -ldflags '-H=windowsgui' -o $runtimeTestHiddenHostBinary .\\cmd\\agentdock-shim", "-AgentDockBinary $runtimeTestAgentDockBinary", + "-HiddenHostBinary $runtimeTestHiddenHostBinary", } { if !strings.Contains(workflow, want) { t.Fatalf("Windows Installer workflow must build and pass the native task launcher; missing %q", want) diff --git a/scripts/test/test-install-windows.ps1 b/scripts/test/test-install-windows.ps1 index b2ca7828..642f00fc 100644 --- a/scripts/test/test-install-windows.ps1 +++ b/scripts/test/test-install-windows.ps1 @@ -120,7 +120,7 @@ foreach ($required in @( '--user-sid', '--user-name', '-AdminLauncherPath $sourceTrayBinary', - '-LauncherPath $destinationBinary', + '-LauncherPath $destinationTrayBinary', '$effectivePrivilegeMode -eq ''elevated'' -and -not $taskState.Exists', '$installWarningCode = ''elevated-mode-fallback''', '$installWarningCode = "$installWarningCode,runtime-launch-deferred"', @@ -144,8 +144,9 @@ foreach ($required in @( 'Initialize-OAuthCredentials', 'named-server-url.txt', 'cloudflared-windows-$Architecture.exe', - 'Wait-QuickTunnelUrl -LogPaths @($cloudflaredStdoutLogPath, $cloudflaredStderrLogPath)', - 'Wait-QuickTunnelReady -Path $quickTunnelUrlPath -ExpectedUrl $publicUrl', + '$tunnelStartupArguments = "--start-tunnel --runtime-root', + '-FilePath $destinationTrayBinary', + '-Arguments $tunnelStartupArguments', 'quick-tunnel-url.txt', '& ''$escapedBinaryPath'' tunnel launch --runtime-root ''$escapedRuntimeDir''', 'Write-ProtectedText -Path $PasswordPath', @@ -180,6 +181,17 @@ foreach ($forbidden in @( throw "$InstallerPath must route current-user startup writes through Set-RunValue instead of: $forbidden" } } +foreach ($forbidden in @( + 'Wait-CloudflaredRunning', + 'Wait-QuickTunnelUrl', + 'Wait-QuickTunnelReady', + 'Installer Engine finished trial without a Quick Tunnel public address.', + '& $destinationBinary tunnel start --runtime-root $runtimeDir' +)) { + if ($content.Contains($forbidden)) { + throw "$InstallerPath must not gate install/update/rollback completion on Tunnel/public readiness: $forbidden" + } +} $setRunValueCallCount = [regex]::Matches( $content, [regex]::Escape('Set-RunValue -RegistryPath $runKey') @@ -522,7 +534,7 @@ foreach ($required in @( 'EnsureSameWindowsUser(request.UserSid)', 'RegisterTaskDefinition(', 'SetSecurityDescriptor(', - 'service launch-core --runtime-root', + '--task-core-host --runtime-root', 'prepare-elevated', 'prepare-standard', 'restore', diff --git a/scripts/test/test-macos-app.sh b/scripts/test/test-macos-app.sh index a6655a35..a41f8355 100755 --- a/scripts/test/test-macos-app.sh +++ b/scripts/test/test-macos-app.sh @@ -56,6 +56,7 @@ swiftc \ "$ROOT_DIR/desktop/macos/AgentDockApp/Sources/TunnelTokenStore.swift" \ "$ROOT_DIR/desktop/macos/AgentDockApp/Sources/LegacyDesktopRuntimeMigration.swift" \ "$ROOT_DIR/desktop/macos/AgentDockApp/Sources/UpdateProgressEvent.swift" \ + "$ROOT_DIR/desktop/macos/AgentDockApp/Sources/UpdateStatusItemVisibility.swift" \ "$ROOT_DIR/desktop/macos/AgentDockApp/Sources/ServiceController.swift" \ "$ROOT_DIR/desktop/macos/AgentDockApp/Sources/InstallerRunner.swift" \ "$ROOT_DIR/desktop/macos/AgentDockApp/Tests/ServiceControllerValidationTests.swift" \ @@ -140,11 +141,13 @@ test ! -e "$APP/Contents/Resources/offline-payload" test -f "$APP/Contents/Resources/AgentDock.icns" CORE_HELPER="$APP/Contents/Helpers/agentdock" CLOUDFLARED_HELPER="$APP/Contents/Helpers/cloudflared" +ARBITER_HELPER="$APP/Contents/Helpers/agentdock-arbiter" CORE_AGENT_PLIST="$APP/Contents/Library/LaunchAgents/com.uvwt.agentdock.core.plist" TUNNEL_AGENT_PLIST="$APP/Contents/Library/LaunchAgents/com.uvwt.agentdock.tunnel.plist" MENU_AGENT_PLIST="$APP/Contents/Library/LaunchAgents/com.uvwt.agentdock.menu-login.plist" test -x "$CORE_HELPER" test -x "$CLOUDFLARED_HELPER" +test -x "$ARBITER_HELPER" test -f "$APP/Contents/Resources/core-skills/manifest.json" test -f "$CORE_AGENT_PLIST" test -f "$TUNNEL_AGENT_PLIST" @@ -177,12 +180,15 @@ cloudflared_helper_version="$("$CLOUDFLARED_HELPER" --version)" codesign --verify --strict --verbose=2 "$MENU_LOGIN_HELPER" codesign --verify --strict --verbose=2 "$CORE_HELPER" codesign --verify --strict --verbose=2 "$CLOUDFLARED_HELPER" +codesign --verify --strict --verbose=2 "$ARBITER_HELPER" menu_helper_signature="$(codesign -dv --verbose=4 "$MENU_LOGIN_HELPER" 2>&1)" core_signature="$(codesign -dv --verbose=4 "$CORE_HELPER" 2>&1)" cloudflared_signature="$(codesign -dv --verbose=4 "$CLOUDFLARED_HELPER" 2>&1)" +arbiter_signature="$(codesign -dv --verbose=4 "$ARBITER_HELPER" 2>&1)" grep -q '^Identifier=com.uvwt.agentdock.login-helper$' <<< "$menu_helper_signature" grep -q '^Identifier=com.uvwt.agentdock.core$' <<< "$core_signature" grep -q '^Identifier=com.uvwt.agentdock.cloudflared$' <<< "$cloudflared_signature" +grep -q '^Identifier=com.uvwt.agentdock.arbiter$' <<< "$arbiter_signature" test -f "$DMG" test -f "$DMG.sha256" test -f "$ZIP" @@ -218,6 +224,7 @@ cmp "$MENU_LOGIN_HELPER" "$zip_extract/AgentDock.app/Contents/Helpers/AgentDockL cmp "$MENU_AGENT_PLIST" "$zip_extract/AgentDock.app/Contents/Library/LaunchAgents/com.uvwt.agentdock.menu-login.plist" cmp "$CORE_HELPER" "$zip_extract/AgentDock.app/Contents/Helpers/agentdock" cmp "$CLOUDFLARED_HELPER" "$zip_extract/AgentDock.app/Contents/Helpers/cloudflared" +cmp "$ARBITER_HELPER" "$zip_extract/AgentDock.app/Contents/Helpers/agentdock-arbiter" mkdir -p "$MOUNT_POINT" hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT_POINT" "$DMG" >/dev/null @@ -233,6 +240,7 @@ cmp "$MENU_LOGIN_HELPER" "$MOUNT_POINT/AgentDock.app/Contents/Helpers/AgentDockL cmp "$MENU_AGENT_PLIST" "$MOUNT_POINT/AgentDock.app/Contents/Library/LaunchAgents/com.uvwt.agentdock.menu-login.plist" cmp "$CORE_HELPER" "$MOUNT_POINT/AgentDock.app/Contents/Helpers/agentdock" cmp "$CLOUDFLARED_HELPER" "$MOUNT_POINT/AgentDock.app/Contents/Helpers/cloudflared" +cmp "$ARBITER_HELPER" "$MOUNT_POINT/AgentDock.app/Contents/Helpers/agentdock-arbiter" cmp \ "$APP/Contents/Resources/core-skills/manifest.json" \ "$MOUNT_POINT/AgentDock.app/Contents/Resources/core-skills/manifest.json" diff --git a/scripts/test/test-windows-runtime-launch-diagnostics.ps1 b/scripts/test/test-windows-runtime-launch-diagnostics.ps1 index 14b6fb97..7cf2e129 100644 --- a/scripts/test/test-windows-runtime-launch-diagnostics.ps1 +++ b/scripts/test/test-windows-runtime-launch-diagnostics.ps1 @@ -1,16 +1,50 @@ [CmdletBinding()] param( - [string] $LauncherPath = (Join-Path $PSScriptRoot '..\install\launch-windows-process.ps1'), + [string] $LauncherPath = '', [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] - [string] $AgentDockBinary + [string] $AgentDockBinary, + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $HiddenHostBinary ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($LauncherPath)) { + $LauncherPath = Join-Path $PSScriptRoot '..\install\launch-windows-process.ps1' +} + $resolvedLauncher = (Resolve-Path -LiteralPath $LauncherPath).Path $resolvedAgentDockBinary = (Resolve-Path -LiteralPath $AgentDockBinary).Path +$resolvedHiddenHostBinary = (Resolve-Path -LiteralPath $HiddenHostBinary).Path + +function Get-PeSubsystem { + param([Parameter(Mandatory = $true)][string] $Path) + + $stream = [IO.File]::OpenRead($Path) + $reader = New-Object IO.BinaryReader($stream) + try { + $stream.Position = 0x3c + $peOffset = $reader.ReadInt32() + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550) { + throw "Invalid PE signature: $Path" + } + # IMAGE_OPTIONAL_HEADER.Subsystem is at offset 68 for both PE32 and PE32+. + $stream.Position = $peOffset + 4 + 20 + 68 + return $reader.ReadUInt16() + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +if ((Get-PeSubsystem -Path $resolvedHiddenHostBinary) -ne 2) { + throw 'Setup runtime hidden host must use the Windows GUI subsystem so Task Scheduler cannot create a console window.' +} + $testRoot = Join-Path ([IO.Path]::GetTempPath()) ('agentdock runtime diagnostics test ' + [Guid]::NewGuid().ToString('N')) $childScript = Join-Path $testRoot 'child.ps1' $taskPrefix = 'AgentDock Setup Runtime ' @@ -34,6 +68,7 @@ try { & $resolvedLauncher ` -FilePath (Join-Path $PSHOME 'powershell.exe') ` -AgentDockBinary $resolvedAgentDockBinary ` + -HiddenHostBinary $resolvedHiddenHostBinary ` -Arguments $arguments ` -WaitForExit ` -TimeoutSeconds 30 @@ -63,10 +98,43 @@ try { & $resolvedLauncher ` -FilePath (Join-Path $PSHOME 'powershell.exe') ` -AgentDockBinary $resolvedAgentDockBinary ` + -HiddenHostBinary $resolvedHiddenHostBinary ` -Arguments $arguments ` -WaitForExit ` -TimeoutSeconds 30 + # The non-wait path is used for Tray/background launch. The child must survive after the + # temporary Task action exits and must not own a console window of its own. + $detachedMarker = Join-Path $testRoot 'detached-marker.txt' + $encodedMarker = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($detachedMarker)) + [IO.File]::WriteAllText( + $childScript, + "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class AgentDockConsoleProbe { [DllImport(`"kernel32.dll`")] public static extern IntPtr GetConsoleWindow(); }'`r`n" + + "`$marker = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedMarker'))`r`n" + + "`$state = if ([AgentDockConsoleProbe]::GetConsoleWindow() -eq [IntPtr]::Zero) { 'hidden' } else { 'visible' }`r`n" + + "[IO.File]::WriteAllText(`$marker, `$state, [Text.UTF8Encoding]::new(`$false))`r`n" + + "Start-Sleep -Milliseconds 750`r`nexit 0`r`n", + [Text.UTF8Encoding]::new($false) + ) + & $resolvedLauncher ` + -FilePath (Join-Path $PSHOME 'powershell.exe') ` + -AgentDockBinary $resolvedAgentDockBinary ` + -HiddenHostBinary $resolvedHiddenHostBinary ` + -Arguments $arguments ` + -TimeoutSeconds 30 + + $markerDeadline = [DateTime]::UtcNow.AddSeconds(5) + while (-not (Test-Path -LiteralPath $detachedMarker -PathType Leaf) -and [DateTime]::UtcNow -lt $markerDeadline) { + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path -LiteralPath $detachedMarker -PathType Leaf)) { + throw 'Detached runtime child did not survive the temporary Task host.' + } + $detachedState = (Get-Content -LiteralPath $detachedMarker -Raw).Trim() + if ($detachedState -ne 'hidden') { + throw "Detached runtime child unexpectedly owns a console window: $detachedState" + } + $afterTasks = @(Get-ScheduledTask -ErrorAction Stop | Where-Object { $_.TaskName.StartsWith($taskPrefix) } | ForEach-Object TaskName) $newTasks = @($afterTasks | Where-Object { $_ -notin $beforeTasks }) if ($newTasks.Count -gt 0) { diff --git a/scripts/test/test-windows-setup-e2e.ps1 b/scripts/test/test-windows-setup-e2e.ps1 index 19198abb..ee316286 100644 --- a/scripts/test/test-windows-setup-e2e.ps1 +++ b/scripts/test/test-windows-setup-e2e.ps1 @@ -102,25 +102,25 @@ function Assert-ElevatedAgentDockTask { $nativeActionMatch = @($task.Actions | Where-Object { $executeMatches = [string]::Equals( [IO.Path]::GetFullPath($_.Execute), - [IO.Path]::GetFullPath($binaryPath), + [IO.Path]::GetFullPath($trayPath), [StringComparison]::OrdinalIgnoreCase ) $argumentsMatch = $_.Arguments -and - $_.Arguments.Contains('service launch-core') -and + $_.Arguments.Contains('--task-core-host') -and $_.Arguments.Contains('--runtime-root') -and $_.Arguments.Contains($InstallRoot) $executeMatches -and $argumentsMatch }).Count -eq 1 if (-not $nativeActionMatch) { $actions = ($task.Actions | ForEach-Object { "$($_.Execute) $($_.Arguments)" }) -join '; ' - throw "AgentDock task does not launch the stable CUI service entry: $actions" + throw "AgentDock task does not launch the stable GUI Core host: $actions" } if (@($task.Actions | Where-Object { $_.Execute.Contains('powershell.exe') -or - [string]::Equals($_.Execute, $trayPath, [StringComparison]::OrdinalIgnoreCase) -or - ($_.Arguments -and ($_.Arguments.Contains('--run-core-task') -or $_.Arguments.Contains('--start-core'))) + [string]::Equals($_.Execute, $binaryPath, [StringComparison]::OrdinalIgnoreCase) -or + ($_.Arguments -and ($_.Arguments.Contains('--run-core-task') -or $_.Arguments.Contains('--start-core') -or $_.Arguments.Contains('service launch-core'))) }).Count -gt 0) { - throw 'AgentDock elevated task must use the stable CUI shim without PowerShell or the legacy tray host.' + throw 'AgentDock elevated task must use the stable GUI Core host without PowerShell, CUI, or the versioned tray host.' } } @@ -137,10 +137,10 @@ function Assert-CoreRunsWithoutConsole { $core = $coreProcesses[0] $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$($core.ParentProcessId)" -ErrorAction Stop - if ($parent.Name -ne 'agentdock.exe' -or + if ($parent.Name -ne 'agentdock-tray.exe' -or [string]::IsNullOrWhiteSpace($parent.ExecutablePath) -or - -not [string]::Equals([IO.Path]::GetFullPath($parent.ExecutablePath), [IO.Path]::GetFullPath($binaryPath), [StringComparison]::OrdinalIgnoreCase)) { - throw "Elevated generation Core is not supervised by the stable CUI shim: $($parent.Name) $($parent.ExecutablePath)" + -not [string]::Equals([IO.Path]::GetFullPath($parent.ExecutablePath), [IO.Path]::GetFullPath($trayPath), [StringComparison]::OrdinalIgnoreCase)) { + throw "Elevated generation Core is not supervised by the stable GUI shim: $($parent.Name) $($parent.ExecutablePath)" } $consoleHosts = @(Get-CimInstance Win32_Process | Where-Object { diff --git a/scripts/test/tunnel_test.go b/scripts/test/tunnel_test.go index bafe1c89..c8986139 100644 --- a/scripts/test/tunnel_test.go +++ b/scripts/test/tunnel_test.go @@ -7,16 +7,15 @@ import ( "testing" ) -func TestQuickTunnelParsersRequireCloudflaredSuccessMarker(t *testing.T) { +func TestQuickTunnelParsingStaysInRuntime(t *testing.T) { const marker = "Your quick Tunnel has been created! Visit it at" tests := []struct { path string wantCount int }{ - // Windows 兼容 launcher 已委托原生 desktopruntime,不再维护第二份 Quick URL parser。 - {path: "../install/install.ps1", wantCount: 1}, - // Linux/macOS 平台脚本已删除 legacy Quick URL parser;解析权威在 desktopruntime。 - // Windows 当前运行链路由原生 desktopruntime 解析,manage-windows 只保留委托入口。 + // Installer no longer waits for or parses public readiness; it starts Tunnel asynchronously. + {path: "../install/install.ps1", wantCount: 0}, + // Runtime remains the single authority for Quick URL parsing and requires the success marker. {path: "../../internal/desktopruntime/quick_tunnel_log.go", wantCount: 1}, } @@ -27,7 +26,7 @@ func TestQuickTunnelParsersRequireCloudflaredSuccessMarker(t *testing.T) { t.Fatalf("read %s: %v", tt.path, err) } if got := strings.Count(string(data), marker); got != tt.wantCount { - t.Fatalf("%s must gate Quick Tunnel URL parsing on cloudflared success marker; marker count = %d, want %d", tt.path, got, tt.wantCount) + t.Fatalf("%s Quick Tunnel parser marker count = %d, want %d", tt.path, got, tt.wantCount) } }) }