Skip to content
Merged
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
10 changes: 9 additions & 1 deletion .github/workflows/windows-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ jobs:
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$runtimeTestAgentDockBinary = Join-Path $env:RUNNER_TEMP 'agentdock-runtime-launch-test.exe'
$env:CGO_ENABLED = '0'
$env:GOOS = 'windows'
$env:GOARCH = 'amd64'
go build -trimpath -o $runtimeTestAgentDockBinary .\cmd\agentdock
& .\scripts\test\test-windows-runtime-launch-diagnostics.ps1 `
-LauncherPath .\scripts\install\launch-windows-process.ps1
-LauncherPath .\scripts\install\launch-windows-process.ps1 `
-AgentDockBinary $runtimeTestAgentDockBinary

- name: Test Task Scheduler session selection
shell: powershell
Expand Down Expand Up @@ -286,6 +292,8 @@ jobs:
$ErrorActionPreference = 'Stop'
& .\scripts\test\test-windows-setup-e2e.ps1 `
-SetupPath (Join-Path $env:RUNNER_TEMP 'agentdock-setup\AgentDockSetup-amd64.exe') `
-LegacyCorePath .\dist\agentdock.exe `
-LegacyTrayPath .\dist\agentdock-tray.exe `
-AllowLegacyTaskMutation

- name: Smoke test current binary as standard user
Expand Down
35 changes: 32 additions & 3 deletions cmd/agentdock-shim/main_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"golang.org/x/sys/windows"

"github.com/uvwt/agentdock/internal/fs/processlock"
processctl "github.com/uvwt/agentdock/internal/process"
"github.com/uvwt/agentdock/internal/updateengine"
)

Expand Down Expand Up @@ -63,12 +64,34 @@ func run() error {
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {

var runErr error
if coreLaunchRequiresParentLifetime(os.Args[1:]) {
// Scheduled Task owns the stable shim, not the generation Core. Keep the Core
// in a kill-on-close Job owned by this shim so ending the task cannot orphan it.
if err := command.Start(); err != nil {
return fmt.Errorf("start AgentDock active generation: %w", err)
}
controller, err := processctl.Attach(command)
if err != nil {
_ = command.Process.Kill()
_ = command.Wait()
return fmt.Errorf("supervise AgentDock active generation: %w", err)
}
runErr = command.Wait()
closeErr := controller.Close()
if runErr == nil && closeErr != nil {
return fmt.Errorf("release AgentDock active generation supervisor: %w", closeErr)
}
} else {
runErr = command.Run()
}
if runErr != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if errors.As(runErr, &exitErr) {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("run AgentDock active generation: %w", err)
return fmt.Errorf("run AgentDock active generation: %w", runErr)
}
return nil
}
Expand All @@ -80,6 +103,12 @@ func run() error {
return command.Process.Release()
}

func coreLaunchRequiresParentLifetime(args []string) bool {
return len(args) >= 2 &&
strings.EqualFold(strings.TrimSpace(args[0]), "service") &&
strings.EqualFold(strings.TrimSpace(args[1]), "launch-core")
}

func resolveActiveWithRecovery(root string, store *updateengine.Store, layout updateengine.WindowsLayout) (updateengine.ActiveVersion, error) {
active, err := store.ReadActive()
if err != nil {
Expand Down
22 changes: 22 additions & 0 deletions cmd/agentdock-shim/main_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,25 @@ func TestTrayRequiresWaitOnlyDetachesNormalBackgroundLaunches(t *testing.T) {
})
}
}

func TestCoreLaunchRequiresParentLifetimeOnlyForServiceHost(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{name: "service host", args: []string{"service", "launch-core", "--runtime-root", `C:\AgentDock`}, want: true},
{name: "service host case insensitive", args: []string{" SERVICE ", " LAUNCH-CORE "}, want: true},
{name: "service status", args: []string{"service", "status"}},
{name: "version", args: []string{"version", "--json"}},
{name: "empty"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := coreLaunchRequiresParentLifetime(test.args); got != test.want {
t.Fatalf("coreLaunchRequiresParentLifetime(%q) = %v, want %v", test.args, got, test.want)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/desktopruntime/task_com_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var (
procCoInitializeEx = modOle32.NewProc("CoInitializeEx")
procCoUninitialize = modOle32.NewProc("CoUninitialize")
procCoCreateInstance = modOle32.NewProc("CoCreateInstance")
procCLSIDFromProgID = modOleaut32.NewProc("CLSIDFromProgID")
procCLSIDFromProgID = modOle32.NewProc("CLSIDFromProgID")
procSysAllocString = modOleaut32.NewProc("SysAllocString")
procSysFreeString = modOleaut32.NewProc("SysFreeString")
)
Expand Down
25 changes: 25 additions & 0 deletions internal/desktopruntime/task_com_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//go:build windows

package desktopruntime

import "testing"

func TestTaskCOMProceduresResolve(t *testing.T) {
for _, test := range []struct {
name string
find func() error
}{
{name: "CoInitializeEx", find: procCoInitializeEx.Find},
{name: "CoUninitialize", find: procCoUninitialize.Find},
{name: "CoCreateInstance", find: procCoCreateInstance.Find},
{name: "CLSIDFromProgID", find: procCLSIDFromProgID.Find},
{name: "SysAllocString", find: procSysAllocString.Find},
{name: "SysFreeString", find: procSysFreeString.Find},
} {
t.Run(test.name, func(t *testing.T) {
if err := test.find(); err != nil {
t.Fatalf("resolve Windows COM procedure %s: %v", test.name, err)
}
})
}
}
14 changes: 7 additions & 7 deletions internal/desktopruntime/task_session_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ import (
)

var (
modWtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
procWTSEnumerateSessionsW = modWtsapi32.NewProc("WTSEnumerateSessionsW")
procWTSQuerySessionInformationW = modWtsapi32.NewProc("WTSQuerySessionInformationW")
procWTSFreeMemory = modWtsapi32.NewProc("WTSFreeMemory")
procWTSGetActiveConsoleSessionId = modWtsapi32.NewProc("WTSGetActiveConsoleSessionId")
modWtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
procWTSEnumerateSessionsW = modWtsapi32.NewProc("WTSEnumerateSessionsW")
procWTSQuerySessionInformationW = modWtsapi32.NewProc("WTSQuerySessionInformationW")
procWTSFreeMemory = modWtsapi32.NewProc("WTSFreeMemory")
)

const (
Expand Down Expand Up @@ -84,8 +83,9 @@ func querySessionText(sessionID uint32, infoClass int) string {
}

func activeConsoleSessionID() uint32 {
id, _, _ := procWTSGetActiveConsoleSessionId.Call()
return uint32(id)
// 与上面的 WTS 查询 API 不同,这个入口实际由 kernel32.dll 导出。
// 使用 x/sys 的生成绑定,避免在这里重复维护易错的 DLL/符号映射。
return windows.WTSGetActiveConsoleSessionId()
}

func windowsUserSID(account string) (string, error) {
Expand Down
22 changes: 22 additions & 0 deletions internal/desktopruntime/task_session_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build windows

package desktopruntime

import "testing"

func TestTaskSessionProceduresResolve(t *testing.T) {
for _, test := range []struct {
name string
find func() error
}{
{name: "WTSEnumerateSessionsW", find: procWTSEnumerateSessionsW.Find},
{name: "WTSQuerySessionInformationW", find: procWTSQuerySessionInformationW.Find},
{name: "WTSFreeMemory", find: procWTSFreeMemory.Find},
} {
t.Run(test.name, func(t *testing.T) {
if err := test.find(); err != nil {
t.Fatalf("resolve Windows session procedure %s: %v", test.name, err)
}
})
}
}
25 changes: 25 additions & 0 deletions internal/process/process_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,28 @@ func TestWindowsJobObjectTerminatesAttachedProcess(t *testing.T) {
t.Fatal(err)
}
}

func TestWindowsJobObjectCloseTerminatesAttachedProcess(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
controller, err := Attach(cmd)
if err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatal(err)
}
if err := controller.Close(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE only guarantees termination. Windows may
// report a zero process exit code, so the contract here is bounded exit, not status.
case <-time.After(5 * time.Second):
t.Fatal("closing Windows Job Object did not terminate the process")
}
}
108 changes: 108 additions & 0 deletions scripts/test/install_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,114 @@ func TestWindowsSetupLaunchesRuntimeOutsideRedirectionGuardTree(t *testing.T) {
t.Fatal("Setup finish page must not launch the long-lived tray directly from the RedirectionGuard process tree")
}
}

func TestWindowsRuntimeDiagnosticsPassesNativeTaskLauncher(t *testing.T) {
diagnosticsData, err := os.ReadFile(filepath.Join("..", "..", "scripts", "test", "test-windows-runtime-launch-diagnostics.ps1"))
if err != nil {
t.Fatalf("read runtime diagnostics test: %v", err)
}
workflowData, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "windows-installer.yml"))
if err != nil {
t.Fatalf("read Windows Installer workflow: %v", err)
}

diagnostics := strings.ReplaceAll(string(diagnosticsData), "\r\n", "\n")
workflow := strings.ReplaceAll(string(workflowData), "\r\n", "\n")
for _, want := range []string{
"[string] $AgentDockBinary",
"$resolvedAgentDockBinary = (Resolve-Path -LiteralPath $AgentDockBinary).Path",
"-AgentDockBinary $resolvedAgentDockBinary",
} {
if !strings.Contains(diagnostics, want) {
t.Fatalf("runtime diagnostics test must pass the native task launcher; missing %q", want)
}
}
for _, want := range []string{
"$runtimeTestAgentDockBinary = Join-Path $env:RUNNER_TEMP 'agentdock-runtime-launch-test.exe'",
"go build -trimpath -o $runtimeTestAgentDockBinary .\\cmd\\agentdock",
"-AgentDockBinary $runtimeTestAgentDockBinary",
} {
if !strings.Contains(workflow, want) {
t.Fatalf("Windows Installer workflow must build and pass the native task launcher; missing %q", want)
}
}
}

func TestWindowsStandardUserE2EWaitsForDirectProcessWithTimeout(t *testing.T) {
launcherData, err := os.ReadFile(filepath.Join("..", "..", "scripts", "test", "run-windows-installer-e2e-as-standard-user.ps1"))
if err != nil {
t.Fatalf("read Windows standard-user E2E launcher: %v", err)
}
childData, err := os.ReadFile(filepath.Join("..", "..", "scripts", "test", "test-install-windows-e2e.ps1"))
if err != nil {
t.Fatalf("read Windows standard-user E2E child: %v", err)
}
launcher := strings.ReplaceAll(string(launcherData), "\r\n", "\n")
child := strings.ReplaceAll(string(childData), "\r\n", "\n")

for _, want := range []string{
"$childProcessTimeoutSeconds = 600",
"function Wait-TestProcess {",
"$Process.WaitForExit($TimeoutSeconds * 1000)",
"Stop-Process -Id $Process.Id -Force",
"-Description 'Windows installer standard-user E2E'",
"-Description 'Windows Setup user-context guard'",
"-CompletionFile `\"$completionPath`\"",
"Test-Path -LiteralPath $completionPath -PathType Leaf",
"$contextSuccess -ne 'Success=false'",
} {
if !strings.Contains(launcher, want) {
t.Fatalf("Windows standard-user E2E must use bounded direct-process waits; missing %q", want)
}
}
if strings.Contains(launcher, "$process.ExitCode") || strings.Contains(launcher, "$contextProcess.ExitCode") {
t.Fatal("Windows standard-user E2E must not rely on ExitCode from Start-Process -Credential under Windows PowerShell 5.1")
}
if strings.Contains(launcher, "-Wait `\n -PassThru") {
t.Fatal("Windows standard-user E2E must not use Start-Process -Wait because installer descendants are long-lived")
}
for _, want := range []string{
"[string] $CompletionFile = ''",
"New-Item -ItemType File -Path $CompletionFile -Force",
} {
if !strings.Contains(child, want) {
t.Fatalf("Windows standard-user E2E child must report completion explicitly; missing %q", want)
}
}
}

func TestWindowsSetupE2EStagesCompleteLegacyFixture(t *testing.T) {
testScriptData, err := os.ReadFile(filepath.Join("..", "..", "scripts", "test", "test-windows-setup-e2e.ps1"))
if err != nil {
t.Fatalf("read Setup E2E script: %v", err)
}
testScript := strings.ReplaceAll(string(testScriptData), "\r\n", "\n")
for _, want := range []string{
"[string] $LegacyCorePath",
"[string] $LegacyTrayPath",
"Copy-Item -LiteralPath $resolvedLegacyCore -Destination $binaryPath -Force",
"Copy-Item -LiteralPath $resolvedLegacyTray -Destination $trayPath -Force",
} {
if !strings.Contains(testScript, want) {
t.Fatalf("Setup E2E must stage a complete migratable legacy installation; missing %q", want)
}
}

workflowData, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "windows-installer.yml"))
if err != nil {
t.Fatalf("read Windows Installer workflow: %v", err)
}
workflow := strings.ReplaceAll(string(workflowData), "\r\n", "\n")
for _, want := range []string{
"-LegacyCorePath .\\dist\\agentdock.exe",
"-LegacyTrayPath .\\dist\\agentdock-tray.exe",
} {
if !strings.Contains(workflow, want) {
t.Fatalf("Windows Installer workflow must pass a real legacy fixture binary; missing %q", want)
}
}
}

func TestWindowsSetupIncludesSimplifiedChineseBaseMessages(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "packaging", "windows", "languages", "ChineseSimplified.isl"))
if err != nil {
Expand Down
Loading
Loading