Skip to content

Commit

Permalink
feat(agent): Win32 pseudo-console-based agent launcher executable (#378)
Browse files Browse the repository at this point in the history
> One can never find the end of the joy of playing with Win32.

Not sure whether this should be marked as feature or bugfix. The aim is
to prevent WSL API to cause terminal windows popping up when the agent
uses it to take an action inside a WSL instance. That happened because
WSL API launches console processes, so if the parent is not a console
process, a new one is created. There is no public API to control that
behavior.

So the solution is to compile the agent as a console application, so it
hosts the WSL API processes. To make it still invisible, another layer
of indirection is added: a native Win32 window process is created, but
instead of showing a window, it creates a pseudo-console device and
hosts the agent under it, as if the launcher was a skinny version of
`conhost.exe`. Being a pseudo-console, we can fully control how it's
presented to the user, or not presented at all, as done in this PR.

The logic behind creating a pseudo-console device and using it to host
child processes is quite convoluted, in my opinion, lots of possible
error paths requiring freeing resources, thus I made it more C++-ish,
with RAII like semantics, either by leveraing classes or being creative
with `std::unique_ptr`.

For more information about this thingy, please check out:


https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session
and

https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/

The GUI, its tests and the appxmanifest were also updated to reflect
that the "agent's background process" must be the launcher.

A nice side effect is that, if the launcher is killed, let's say by the
task manager, the agent quits cleanly, deleting the `addr` file as if it
received `Ctrl-C`. That was not (and still is not) true for killing the
agent itself, it has no chance to react.
  • Loading branch information
CarlosNihelton authored Nov 7, 2023
2 parents de36e2e + f5e7183 commit 870c5eb
Show file tree
Hide file tree
Showing 12 changed files with 524 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'package:path/path.dart' as p;
/// A try-catch block (on AssertionError) can still prevent process exit.
Future<void> buildAgentExe(
String destination, {
String exeName = 'ubuntu-pro-agent.exe',
String exeName = 'ubuntu-pro-agent-launcher.exe',
}) async {
const config = 'Debug';
const platform = 'x64';
Expand Down
2 changes: 1 addition & 1 deletion gui/packages/ubuntupro/lib/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ const kAddrFileName = 'addr';
const kDefaultMargin = 32.0;

/// The path of the agent executable relative to the msix root directory.
const kAgentRelativePath = 'agent/ubuntu-pro-agent.exe';
const kAgentRelativePath = 'agent/ubuntu-pro-agent-launcher.exe';
4 changes: 2 additions & 2 deletions msix/UbuntuProForWindows/Package.appxmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@
<Extensions>
<uap5:Extension
Category="windows.startupTask"
Executable="agent\ubuntu-pro-agent.exe"
Executable="agent\ubuntu-pro-agent-launcher.exe"
EntryPoint="Windows.FullTrustApplication">
<uap5:StartupTask
TaskId="UbuntuPro"
Enabled="true"
DisplayName="Ubuntu Pro For Windows background agent" />
</uap5:Extension>
<desktop:Extension Category="windows.fullTrustProcess" Executable="agent\ubuntu-pro-agent.exe">
<desktop:Extension Category="windows.fullTrustProcess" Executable="agent\ubuntu-pro-agent-launcher.exe">
<desktop:FullTrustProcess>
<desktop:ParameterGroup GroupId="agent" Parameters=""/>
</desktop:FullTrustProcess>
Expand Down
5 changes: 1 addition & 4 deletions msix/agent/agent.targets
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
<PropertyGroup>
<GoAppRoot>$(MSBuildThisFileDirectory)..\..\windows-agent\</GoAppRoot>
<GoAppDir>$(GoAppRoot)cmd\ubuntu-pro-agent\</GoAppDir>
<GoFlags></GoFlags>
<!-- Comment below to see the console window poping up -->
<GoFlags>-ldflags -H=windowsgui</GoFlags>
<GoBuildTags Condition="'$(UP4W_TEST_WITH_MS_STORE_MOCK)' != ''">-tags=server_mocks</GoBuildTags>
</PropertyGroup>
<ItemGroup>
Expand Down Expand Up @@ -33,7 +30,7 @@
</ItemGroup>
<Message Text="Building Go artifacts to $(OutDir) and bundling @(DepAssemblies)" Importance="high"/>
<MakeDir Directories="$(OutDir)" />
<Exec Command="go build $(GoBuildTags) $(GoFlags) $(GoAppDir)" WorkingDirectory="$(OutDir)" />
<Exec Command="go build $(GoBuildTags) $(GoAppDir)" WorkingDirectory="$(OutDir)" />
</Target>
<Target Name="Clean" Condition="Exists($(TargetPath))">
<Message Text="Cleaning $(TargetPath)" Importance="high" />
Expand Down
3 changes: 3 additions & 0 deletions msix/agent/agent.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@
<ProjectReference Include="..\storeapi\storeapi.vcxproj">
<Project>{4ee3b168-c58d-4ae5-a259-1b5a04c018de}</Project>
</ProjectReference>
<ProjectReference Include="..\ubuntu-pro-agent-launcher\ubuntu-pro-agent-launcher.vcxproj">
<Project>{eb330327-e1d2-4ab7-9980-fa2e56bf308b}</Project>
</ProjectReference>
</ItemGroup>
<!-- TODO: Add a reference to the store API DLL project -->
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
Expand Down
8 changes: 7 additions & 1 deletion msix/msix.sln
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "UbuntuProForWindows", "Ubun
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ubuntupro", "gui\gui.vcxproj", "{89935278-02C9-414F-8DB5-47A465BC1F2B}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "agent", "agent\agent.vcxproj", "{6427BB31-C4BE-4585-A090-10EFDFB06B04}"
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ubuntu-pro-agent", "agent\agent.vcxproj", "{6427BB31-C4BE-4585-A090-10EFDFB06B04}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "storeapi", "storeapi\storeapi.vcxproj", "{4EE3B168-C58D-4AE5-A259-1B5A04C018DE}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ubuntu-pro-agent-launcher", "ubuntu-pro-agent-launcher\ubuntu-pro-agent-launcher.vcxproj", "{EB330327-E1D2-4AB7-9980-FA2E56BF308B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Expand All @@ -35,6 +37,10 @@ Global
{4EE3B168-C58D-4AE5-A259-1B5A04C018DE}.Debug|x64.Build.0 = Debug|x64
{4EE3B168-C58D-4AE5-A259-1B5A04C018DE}.Release|x64.ActiveCfg = Release|x64
{4EE3B168-C58D-4AE5-A259-1B5A04C018DE}.Release|x64.Build.0 = Release|x64
{EB330327-E1D2-4AB7-9980-FA2E56BF308B}.Debug|x64.ActiveCfg = Debug|x64
{EB330327-E1D2-4AB7-9980-FA2E56BF308B}.Debug|x64.Build.0 = Debug|x64
{EB330327-E1D2-4AB7-9980-FA2E56BF308B}.Release|x64.ActiveCfg = Release|x64
{EB330327-E1D2-4AB7-9980-FA2E56BF308B}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
144 changes: 144 additions & 0 deletions msix/ubuntu-pro-agent-launcher/console.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#include "console.hpp"

#include <memory>
#include <type_traits>

#include "error.hpp"

namespace up4w {
PseudoConsole ::~PseudoConsole() {
if (hInRead != nullptr && hInRead != INVALID_HANDLE_VALUE) {
CloseHandle(hInRead);
}
if (hInWrite != nullptr && hInWrite != INVALID_HANDLE_VALUE) {
CloseHandle(hInWrite);
}
if (hOutRead != nullptr && hOutRead != INVALID_HANDLE_VALUE) {
CloseHandle(hOutRead);
}
if (hOutWrite != nullptr && hOutWrite != INVALID_HANDLE_VALUE) {
CloseHandle(hOutWrite);
}
if (hDevice != nullptr && hDevice != INVALID_HANDLE_VALUE) {
ClosePseudoConsole(hDevice);
}
}
PseudoConsole::PseudoConsole(COORD coordinates) {
SECURITY_ATTRIBUTES sa{sizeof(SECURITY_ATTRIBUTES), nullptr, true};
if (!CreatePipe(&hInRead, &hInWrite, &sa, 0)) {
throw hresult_exception{HRESULT_FROM_WIN32(GetLastError())};
}

if (!CreatePipe(&hOutRead, &hOutWrite, &sa, 0)) {
throw hresult_exception{HRESULT_FROM_WIN32(GetLastError())};
}

if (auto hr =
CreatePseudoConsole(coordinates, hInRead, hOutWrite, 0, &hDevice);
FAILED(hr)) {
throw hresult_exception{hr};
}
}

void attr_list_deleter(PPROC_THREAD_ATTRIBUTE_LIST p) {
if (p) {
DeleteProcThreadAttributeList(p);
HeapFree(GetProcessHeap(), 0, p);
}
};
using unique_attr_list =
std::unique_ptr<std::remove_pointer_t<PPROC_THREAD_ATTRIBUTE_LIST>,
decltype(&attr_list_deleter)>;

/// Returns a list of attributes for process/thread creation with the
/// pseudo-console key enabled and set to [con].
unique_attr_list PseudoConsoleProcessAttrList(HPCON con) {
PPROC_THREAD_ATTRIBUTE_LIST attrs = nullptr;

size_t bytesRequired = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &bytesRequired);
// Allocate memory to represent the list
attrs = static_cast<PPROC_THREAD_ATTRIBUTE_LIST>(
HeapAlloc(GetProcessHeap(), 0, bytesRequired));
if (!attrs) {
throw hresult_exception{E_OUTOFMEMORY};
}

// Initialize the list memory location
if (!InitializeProcThreadAttributeList(attrs, 1, 0, &bytesRequired)) {
throw hresult_exception{HRESULT_FROM_WIN32(GetLastError())};
}

unique_attr_list result{attrs, &attr_list_deleter};

if (!UpdateProcThreadAttribute(attrs, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
con, sizeof(con), NULL, NULL)) {
throw hresult_exception{HRESULT_FROM_WIN32(GetLastError())};
}

return result;
}

Process PseudoConsole::StartProcess(std::wstring commandLine) {
unique_attr_list attributes = PseudoConsoleProcessAttrList(hDevice);
// Prepare Startup Information structure
STARTUPINFOEX si{};
si.StartupInfo.cb = sizeof(STARTUPINFOEX);
si.StartupInfo.hStdInput = hInRead;
si.StartupInfo.hStdOutput = hOutWrite;
si.StartupInfo.hStdError = hOutWrite;
si.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
si.lpAttributeList = attributes.get();

Process p{};
if (!CreateProcessW(NULL, commandLine.data(), NULL, NULL, FALSE,
EXTENDED_STARTUPINFO_PRESENT, NULL, NULL, &si.StartupInfo,
&p)) {
throw hresult_exception{HRESULT_FROM_WIN32(GetLastError())};
}

return p;
}

void EventLoop::reserve(std::size_t size) {
handles_.reserve(size);
listeners_.reserve(size);
}

EventLoop::EventLoop(
std::initializer_list<
std::pair<HANDLE, std::function<std::optional<int>(HANDLE)>>>
listeners) {
reserve(listeners.size());
for (auto& [k, f] : listeners) {
handles_.push_back(k);
listeners_.push_back(f);
}
}

int EventLoop::Run() {
do {
DWORD signaledIndex = MsgWaitForMultipleObjectsEx(
static_cast<DWORD>(handles_.size()), handles_.data(), INFINITE,
QS_ALLEVENTS, MWMO_INPUTAVAILABLE);
// none of the handles, thus the window message queue was signaled.
if (signaledIndex >= handles_.size()) {
MSG msg;
if (!GetMessage(&msg, NULL, 0, 0)) {
// WM_QUIT
return 0;
}

TranslateMessage(&msg);
DispatchMessage(&msg);
} else {
// invoke the listener subscribed to the handle that was signaled.
if (auto done = listeners_.at(signaledIndex)(handles_.at(signaledIndex));
done.has_value()) {
return done.value();
}
}
} while (true);
}

} // namespace up4w
85 changes: 85 additions & 0 deletions msix/ubuntu-pro-agent-launcher/console.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#pragma once
#include <Windows.h>

#include <functional>
#include <initializer_list>
#include <optional>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

namespace up4w {
// An RAII wrapper around the PROCESS_INFORMATION structure to ease preventing
// HANDLE leaks.
struct Process : PROCESS_INFORMATION {
Process(Process const& other) = delete;
Process& operator=(Process const& other) = delete;
Process(Process&& other) noexcept { *this = std::move(other); }
Process& operator=(Process&& other) noexcept {
hProcess = std::exchange(other.hProcess, nullptr);
hThread = std::exchange(other.hThread, nullptr);
dwProcessId = std::exchange(other.dwProcessId, 0);
dwThreadId = std::exchange(other.dwThreadId, 0);
return *this;
}
Process() noexcept {
hProcess = nullptr;
hThread = nullptr;
dwProcessId = 0;
dwThreadId = 0;
}

~Process() noexcept {
if (hThread != nullptr && hThread != INVALID_HANDLE_VALUE) {
CloseHandle(hThread);
}
if (hProcess != nullptr && hProcess != INVALID_HANDLE_VALUE) {
CloseHandle(hProcess);
}
}
};

// An abstraction on top of the pseudo-console device that prevents leaking
// HANDLEs and makes it easier to start processes under itself.
class PseudoConsole {
HANDLE hInRead = nullptr;
HANDLE hInWrite = nullptr;
HANDLE hOutRead = nullptr;
HANDLE hOutWrite = nullptr;

HPCON hDevice;

public:
/// Constructs a new pseudo-console with the specified [dimensions].
explicit PseudoConsole(COORD dimensions);

HANDLE GetReadHandle() const { return hOutRead; }

/// Starts a child process under this pseudo-console by running the fully
/// specified [commandLine].
Process StartProcess(std::wstring commandLine);

~PseudoConsole();
};

/// A combination of traditional window message loop with event listening.
/// Listener functions return any integer value other than nullopt to report
/// that the event loop should exit.
class EventLoop {
std::vector<HANDLE> handles_;
std::vector<std::function<std::optional<int>(HANDLE)>> listeners_;
void reserve(std::size_t size);

public:
explicit EventLoop(
std::initializer_list<
std::pair<HANDLE, std::function<std::optional<int>(HANDLE)>>>
listeners);

// Runs the event loop until one of the listeners return a value or a closing
// message is received in the message queue.
int Run();
};

} // namespace up4w
37 changes: 37 additions & 0 deletions msix/ubuntu-pro-agent-launcher/error.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#include "error.hpp"
#include <chrono>
#include <fstream>

namespace up4w {
std::wstring UnderLocalAppDataPath(std::wstring_view destination) {
std::wstring_view localAppData = L"LOCALAPPDATA";
std::wstring resultPath{};
resultPath.resize(MAX_PATH);

auto truncatedLength =
static_cast<DWORD>(resultPath.capacity() - destination.size() - 1);

auto length =
GetEnvironmentVariable(localAppData.data(), resultPath.data(), truncatedLength);
if (length == 0) {
return {};
}

if (length > truncatedLength) {
throw hresult_exception{CO_E_PATHTOOLONG};
}

resultPath.insert(length, destination.data());
return resultPath;
}

void LogSingleShot(std::filesystem::path const& logFilePath,
std::string_view message) {
auto const time =
std::chrono::current_zone()->to_local(std::chrono::system_clock::now());

std::ofstream logfile{logFilePath, std::ios::app};
logfile << std::format("{:%Y-%m-%d %T}: {}\n", time, message);
}

} // namespace up4w
Loading

0 comments on commit 870c5eb

Please sign in to comment.