//go:build windows package pkgmanager import ( "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "unsafe" "golang.org/x/sys/windows" ) // wingetPath returns a usable winget.exe for the given user profile, falling // back to SYSTEM/global locations. winget is a UWP app execution alias that is // commonly missing from the PATH of service accounts (e.g. LocalSystem). func wingetPath(profile string) string { if profile != "" { if p := fileExists(filepath.Join(profile, "AppData", "Local", "Microsoft", "WindowsApps", "winget.exe")); p != "" { return p } } if p, err := exec.LookPath("winget"); err == nil { return p } for _, base := range []string{ os.ExpandEnv(`%LOCALAPPDATA%\Microsoft\WindowsApps\winget.exe`), os.ExpandEnv(`%SystemRoot%\System32\config\systemprofile\AppData\Local\Microsoft\WindowsApps\winget.exe`), } { if p := fileExists(base); p != "" { return p } } matches, _ := filepath.Glob(filepath.Join(os.ExpandEnv(`%ProgramFiles%`), "WindowsApps", "Microsoft.DesktopAppInstaller_*", "winget.exe")) if len(matches) > 0 { sort.Strings(matches) return matches[len(matches)-1] } return "" } func chocoPath() string { if p, err := exec.LookPath("choco"); err == nil { return p } if p := fileExists(filepath.Join(os.ExpandEnv(`%ProgramData%`), "chocolatey", "bin", "choco.exe")); p != "" { return p } return "" } func resolveManagerPath(executable, profile string) string { switch executable { case "winget": if p := wingetPath(profile); p != "" { return p } case "choco": if profile != "" { if p := fileExists(filepath.Join(profile, "AppData", "Local", "chocolatey", "bin", "choco.exe")); p != "" { return p } } if p := chocoPath(); p != "" { return p } } return "" } // interactiveProfile returns the profile directory of the logged-on user, or "" // when no interactive session exists. func interactiveProfile() string { token, profile, err := interactiveUser() if err != nil { return "" } token.Close() return profile } // runManagerCommand runs a package manager command. winget and choco runs are // executed in the context of the interactive (logged-on) user so that the winget // app execution alias resolves and per-user installers land in the user's own // profile (and therefore are visible to them). If the user context lacks // elevation, the command is retried in the SYSTEM context. func runManagerCommand(executable string, args []string) (string, error) { switch executable { case "choco", "winget": token, profile, err := interactiveUser() if err == nil { defer token.Close() exe := resolveManagerPath(executable, profile) if exe == "" { return "", fmt.Errorf("%s not found in user context or system locations", executable) } out, code, spawnErr := runAsUser(token, profile, exe, args) if spawnErr == nil { if code == 0 { return out, nil } if !needsElevation(out) { return out, fmt.Errorf("%s failed with exit code %d\nOutput: %s", executable, code, out) } } // user context failed to launch or needs elevation: retry as SYSTEM } } exe := resolveManagerPath(executable, "") if exe == "" { return "", fmt.Errorf("%s not found in any known location", executable) } cmd := exec.Command(exe, args...) out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out)) } return string(out), nil } // interactiveUser returns a duplicated primary token for the logged-on user and // their profile directory. It fails when no interactive session exists. func interactiveUser() (windows.Token, string, error) { var sessionID uint32 var count uint32 var sessions *windows.WTS_SESSION_INFO if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil { sessionID = windows.WTSGetActiveConsoleSessionId() } else { defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions))) info := unsafe.Slice(sessions, count) for _, s := range info { if s.SessionID != 0 && (s.State == windows.WTSActive || s.State == windows.WTSConnected) { sessionID = s.SessionID break } } } if sessionID == 0 { return 0, "", fmt.Errorf("no interactive session") } var token windows.Token if err := windows.WTSQueryUserToken(sessionID, &token); err != nil { return 0, "", fmt.Errorf("WTSQueryUserToken: %w", err) } var dup windows.Token if err := windows.DuplicateTokenEx(token, windows.TOKEN_ALL_ACCESS, nil, windows.SecurityImpersonation, windows.TokenPrimary, &dup); err != nil { token.Close() return 0, "", fmt.Errorf("DuplicateTokenEx: %w", err) } token.Close() profile, err := userProfileDir(dup) if err != nil { dup.Close() return 0, "", fmt.Errorf("GetUserProfileDirectory: %w", err) } return dup, profile, nil } func userProfileDir(token windows.Token) (string, error) { buf := make([]uint16, 1024) for { size := uint32(len(buf)) err := windows.GetUserProfileDirectory(token, &buf[0], &size) if err == nil { return windows.UTF16ToString(buf), nil } if err != windows.ERROR_INSUFFICIENT_BUFFER { return "", err } buf = make([]uint16, size) } } // runAsUser runs executable in the given user's context, redirecting stdout and // stderr to a temp file via inherited handles. Returns combined output and the // process exit code. func runAsUser(token windows.Token, profile, exe string, args []string) (string, int, error) { outFile, err := os.CreateTemp("", "ff-agent-out-*.txt") if err != nil { return "", -1, fmt.Errorf("CreateTemp: %w", err) } outPath := outFile.Name() outFile.Close() defer os.Remove(outPath) var sa windows.SecurityAttributes sa.Length = uint32(unsafe.Sizeof(sa)) sa.InheritHandle = 1 shareMode := uint32(windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE) outHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(outPath), windows.GENERIC_WRITE, shareMode, &sa, windows.CREATE_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0) if err != nil { return "", -1, fmt.Errorf("CreateFile output: %w", err) } defer windows.CloseHandle(outHandle) nulHandle, err := windows.CreateFile(windows.StringToUTF16Ptr("NUL"), windows.GENERIC_READ, shareMode, &sa, windows.OPEN_EXISTING, 0, 0) if err != nil { return "", -1, fmt.Errorf("CreateFile NUL: %w", err) } defer windows.CloseHandle(nulHandle) commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args)) if err != nil { return "", -1, fmt.Errorf("UTF16PtrFromString: %w", err) } var envBlock *uint16 if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil { envBlock = nil } else { defer windows.DestroyEnvironmentBlock(envBlock) } cwd, _ := windows.UTF16PtrFromString(profile) if cwd == nil || *cwd == 0 { cwd = nil } var si windows.StartupInfo si.Cb = uint32(unsafe.Sizeof(si)) si.Flags = windows.STARTF_USESTDHANDLES si.StdInput = nulHandle si.StdOutput = outHandle si.StdErr = outHandle var pi windows.ProcessInformation flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW) err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, true, flags, envBlock, cwd, &si, &pi) if err != nil { return "", -1, fmt.Errorf("CreateProcessAsUser: %w", err) } windows.CloseHandle(pi.Thread) defer windows.CloseHandle(pi.Process) _, _ = windows.WaitForSingleObject(pi.Process, windows.INFINITE) var exitCode uint32 _ = windows.GetExitCodeProcess(pi.Process, &exitCode) out, _ := os.ReadFile(outPath) return string(out), int(exitCode), nil } func windowsQuote(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } // RunInUserSessionCapture runs an executable in the context of the logged-on // interactive user and returns its combined output and exit code. It is used // e.g. to write per-user registry settings for the VNC server. func RunInUserSessionCapture(exe string, args []string) (string, int, error) { token, profile, err := interactiveUser() if err != nil { return "", -1, err } defer token.Close() return runAsUser(token, profile, exe, args) } // LaunchInUserSession launches an executable in the context of the logged-on // interactive user without waiting for it to finish. It returns the process ID // of the launched process, which can be used to terminate it later. This is // used e.g. to start a VNC server inside the user's desktop session. func LaunchInUserSession(exe string, args []string) (uint32, error) { token, profile, err := interactiveUser() if err != nil { return 0, err } defer token.Close() commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args)) if err != nil { return 0, fmt.Errorf("UTF16PtrFromString: %w", err) } var envBlock *uint16 if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil { envBlock = nil } else { defer windows.DestroyEnvironmentBlock(envBlock) } cwd, _ := windows.UTF16PtrFromString(profile) if cwd == nil || *cwd == 0 { cwd = nil } var si windows.StartupInfo si.Cb = uint32(unsafe.Sizeof(si)) si.Flags = windows.STARTF_USESHOWWINDOW si.ShowWindow = windows.SW_SHOWNORMAL var pi windows.ProcessInformation flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW) err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, false, flags, envBlock, cwd, &si, &pi) if err != nil { return 0, fmt.Errorf("CreateProcessAsUser: %w", err) } windows.CloseHandle(pi.Thread) windows.CloseHandle(pi.Process) return pi.ProcessId, nil } func windowsQuoteArgs(args []string) string { out := make([]string, 0, len(args)) for _, a := range args { out = append(out, windowsQuote(a)) } return strings.Join(out, " ") } func fileExists(p string) string { if p == "" { return "" } if _, err := os.Stat(p); err == nil { return p } return "" } func needsElevation(output string) bool { low := strings.ToLower(output) for _, s := range []string{ "elevat", "administrator", "requires authentication", "access is denied", "access denied", "run as admin", } { if strings.Contains(low, s) { return true } } return false }