Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/windows-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 84 additions & 3 deletions cmd/agentdock-shim/main_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
228 changes: 227 additions & 1 deletion cmd/agentdock-shim/main_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Loading
Loading