Skip to content
Closed
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
37 changes: 37 additions & 0 deletions .github/workflows/steam-isolation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Steam isolation native checks

on:
pull_request:
paths:
- 'src/MultiSeat.SteamIsolation/**'
- '.github/workflows/steam-isolation.yml'
push:
paths:
- 'src/MultiSeat.SteamIsolation/**'
- '.github/workflows/steam-isolation.yml'

permissions:
contents: read

jobs:
native-checks:
runs-on: windows-2022
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Build x64 and x86 launchers, hooks, and safe fixtures
shell: pwsh
run: ./src/MultiSeat.SteamIsolation/build.ps1 -BuildFixture
- name: Check native isolation policies
shell: pwsh
run: |
foreach ($architecture in @('x64', 'Win32')) {
ctest --test-dir "src/MultiSeat.SteamIsolation/build-$architecture" -C Release --output-on-failure
if ($LASTEXITCODE -ne 0) { throw "Policy checks failed for $architecture" }
}
- name: Check launch, restart, readiness, and cleanup
shell: pwsh
run: ./src/MultiSeat.SteamIsolation/tests/LauncherSmoke.ps1
- name: Check direct game environment and working directory
shell: pwsh
run: ./src/MultiSeat.SteamIsolation/tests/GameLauncherSmoke.ps1
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ PktMon.*
# C++ InputHook DLL build output
src/MultiSeat.InputHook/build/

# Steam isolation native build products
src/MultiSeat.SteamIsolation/build-*/
src/MultiSeat.SteamIsolation/artifacts/

# TypeScript incremental build info
*.tsbuildinfo

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ Edit `appsettings.json` in `C:\Program Files\MultiSeat\` (restart the service af
| `EnableKeyboardMouseIsolation` | `false` | Keyboard/mouse session isolation (no-op as architected — see Known Constraints in CLAUDE.md) |
| `EnableSharedGameLibrary` | `true` | Create a shared games/ROMs folder all seats can use |
| `SharedGameLibraryDir` | `C:\MultiSeatGames` | Root of the shared library (`\SteamLibrary` + `\ROMs`) |
| `SteamIsolationEnabled` | `false` | Wrap explicit `steam.exe` seat launches with the experimental native isolation helper. See [Steam isolation setup](docs/steam-isolation.md) for binaries, Apollo commands and limitations. |
| `SteamIsolationLauncherPath` | `C:\Program Files\MultiSeat\SteamIsolation\MultiSeat.SteamLauncher.exe` | Supervising x64 launcher; the matching x86 launcher and hooks live beside it. |
| `EnableEmulatorNetplay` | `true` | Assign + open a per-seat RetroArch netplay port (seats connect over `127.0.0.1`) |
| `SeedRetroArchNetplayConfig` | `false` | Auto-write each seat's `retroarch.cfg` (netplay port + shared ROM dir) |
| `UpdateCheckEnabled` | `false` | Ask GitHub's public releases API whether newer MultiSeat, ApolloVibe and MoonlightVibe releases exist, and show the answer on the dashboard. **Off by default**: it is the only internet connection the service would make. It sends an unauthenticated GET with a generic User-Agent and nothing about your host, and never downloads or installs anything. Set it in `appsettings.local.json` or from the dashboard's System page; no restart needed. Details: `docs/security-posture.md`, "Outbound connections". |
Expand Down
67 changes: 67 additions & 0 deletions docs/steam-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Steam isolation (experimental, opt in)

MultiSeat can launch Steam through its own native isolation launcher inside a seat's existing Windows user and session. This feature does not change Steam installations, replace system DLLs, or affect unrelated applications.

Build and place the native launcher and hooks in `C:\Program Files\MultiSeat\SteamIsolation`. The folder must contain the x64 `MultiSeat.SteamLauncher.exe` and `MultiSeat.SteamHook.dll`, and for 32-bit Steam also `MultiSeat.SteamLauncher32.exe` and `MultiSeat.SteamHook32.dll`. Keep these binaries in a directory standard seat accounts cannot modify, while granting them read/execute access.

Enable it in the deployed `appsettings.local.json`, then restart MultiSeat:

```json
{
"MultiSeat": {
"SteamIsolationEnabled": true,
"SteamIsolationLauncherPath": "C:\\Program Files\\MultiSeat\\SteamIsolation\\MultiSeat.SteamLauncher.exe"
}
}
```

The dashboard's app launch and `MultiSeat:LaunchOnConnect` automatically wrap an explicit, absolute path whose filename is exactly `steam.exe` (case insensitive). Other executables keep their existing behavior. Relative Steam paths are refused when isolation is enabled. Steam's original arguments and the launch working directory are preserved. Missing binaries, invalid PE files, and mismatched hook architecture prevent the isolated launch; there is no fallback that silently starts unisolated Steam.

For example, configure a launch-on-connect entry with an absolute installed Steam path and `-bigpicture`. The launcher supervises the whole process tree, including replacement Steam processes, and returns the initial Steam process's exit code after the tree exits. MultiSeat tracks the supervisor PID, so reconnect duplicate detection and disconnect process-tree cleanup continue to cover the isolated Steam process.

The service waits for an explicit native initialization signal, rather than assuming a running launcher means isolation succeeded. It creates a unique protected `Global\MultiSeatSteamReady-<guid>` event and grants only SYSTEM, administrators and the exact seat account access (the seat can signal/wait only). The launcher receives `--ready-event <name>` and signals it after successful hook initialization. Startup exit before the signal, cancellation, or a 180-second initialization timeout fails the launch and terminates the supervisor, whose job cleans up Steam and its children. There is no unisolated retry. Failures after readiness are visible in the native launcher's output; the service does not continuously monitor its exit code.

Apollo launches its Moonlight app entries directly, so those commands bypass the managed service's wrapping. To opt in for an Apollo Steam app, use an explicit command in the source Apollo app list, substituting the installed Steam path:

```text
"C:\Program Files\MultiSeat\SteamIsolation\MultiSeat.SteamLauncher.exe" --steam "C:\Program Files (x86)\Steam\steam.exe" -- -bigpicture
```

MultiSeat copies the source app list into the seat when it is provisioned again. Do not put this command in a detached/preparation command: the supervisor needs to remain the app process. A `steam://open/bigpicture` command invokes Windows' protocol handler and is not automatically rewritten. Replace that entry with the explicit launcher command when isolation is desired. Invoking the native launcher directly is itself an opt in and works independently of the service setting.

Steam launched manually from a shortcut, Explorer, a Startup entry, or another program does not pass through this feature. Close Steam in the target seat before starting the isolated instance, and use the wrapper for subsequent Steam launches. Separate Windows users already isolate user files; native hooks address the Steam-specific machine/global state and require multi-session runtime validation. The feature does not grant game licenses or change Steam account access rules.

## Building and checking

Build the managed service using the normal MultiSeat build process. Building the native component requires Visual Studio 2022 C++ tools and CMake:

```powershell
.\src\MultiSeat.SteamIsolation\build.ps1
```

The four release binaries are written to `src\MultiSeat.SteamIsolation\artifacts`. MinHook v1.3.4 is vendored with its license in `third_party\minhook`. To build safe test fixtures and run the launcher smoke checks without launching installed Steam:

```powershell
.\src\MultiSeat.SteamIsolation\build.ps1 -BuildFixture
.\src\MultiSeat.SteamIsolation\tests\LauncherSmoke.ps1 -Artifacts .\src\MultiSeat.SteamIsolation\artifacts
.\src\MultiSeat.SteamIsolation\tests\GameLauncherSmoke.ps1 -Artifacts .\src\MultiSeat.SteamIsolation\artifacts
dotnet test src\MultiSeat.Tests\MultiSeat.Tests.csproj --filter FullyQualifiedName~SteamIsolation
```

## Isolation behavior and limits

The hook creates an IPC name from the Windows session and user SID, replaces Steam's `-master_ipc_name_override` argument and `steam_master_ipc_name_override` environment entry, and separates browser `htmlcache` and `cef_log` paths. It redirects `TempAppPath` and `TempAppCmdLine` registry operations to the seat user's `HKCU\Software\Valve\Steam`, and returns zero for successful DWORD `SteamPID` reads.

Child command lines and explicit environments are adjusted before process creation. Same-architecture replacement `steam.exe` launches through `CreateProcessW/A` receive the hook before their main thread runs. Browser and game processes receive no injected hook DLL. An architecture-changing Steam update requires relaunching through the supervisor; the current hook cannot inject a DLL of the other architecture. Native-only replacement launches that bypass the Win32 creation functions have not been validated.

The feature defaults to disabled. Fixture checks cover both architectures, restart propagation, quoting, registry types, explicit child environments, readiness, and job cleanup. Automated tests do not validate actual Steam login, updates, games, anti-cheat behavior, or simultaneous real Windows seat sessions. Limited manual reports are described below. This source does not establish compatibility with every Steam release.

## Launching a game directly

A game launched from Explorer does not inherit the isolated Steam client's IPC environment. Launch it from that Steam client's library, or use `Launch-Game.cmd` and `Launch-IsolatedGame.ps1` together from this component's source folder. The command opens an EXE picker and launches the selected game with the same session/user-derived Steam connection name and its own installation folder as the working directory. Run it as the normal seat account, after starting Steam with the native supervisor.

The picker changes only the new game's environment. It does not inject a DLL into the game, modify saves, or create a second copy. This directly launched game is outside the Steam supervisor's job, so stopping the supervisor does not guarantee that the selected game exits. Close the game separately. The helper checks for a Steam process in the session; it does not verify that process was launched with isolation.

Manual user testing reported simultaneous Steam clients on the host and one Windows seat and successful Valheim launches via this helper. The game installation included a third-party Steamworks compatibility component; unmodified retail Steamworks games have not been validated. These reports supplement the fixture checks rather than establishing general game compatibility.

The IPC override technique predates this implementation; see [steam-multibox-easysetup](https://github.com/dreamware-media/steam-multibox-easysetup). This implementation adds targeted hooks, startup readiness, restart propagation, and process-tree supervision. No claim is made that those additions outperform simpler launch scripts across all games.
7 changes: 7 additions & 0 deletions src/MultiSeat.Service/Configuration/MultiSeatOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,13 @@ public sealed class MultiSeatOptions
// at login run before any stream and never see the per-stream virtual pad.
public LaunchOnConnectApp[] LaunchOnConnect { get; set; } = [];

/// <summary>Opt in to wrapping explicit steam.exe seat launches with the native isolation helper.</summary>
public bool SteamIsolationEnabled { get; set; } = false;

/// <summary>Absolute path to the supervising x64 launcher; matching hooks live beside it.</summary>
public string SteamIsolationLauncherPath { get; set; } =
@"C:\Program Files\MultiSeat\SteamIsolation\MultiSeat.SteamLauncher.exe";

// Delay after the client-connect event before launching the apps, giving Apollo
// a moment to create the virtual controller so the apps enumerate it at startup.
public int LaunchOnConnectDelayMs { get; set; } = 4_000;
Expand Down
33 changes: 29 additions & 4 deletions src/MultiSeat.Service/Sessions/ProcessInjector.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Principal;
using Microsoft.Extensions.Options;
using MultiSeat.Service.Configuration;
using MultiSeat.Service.Interop;
Expand Down Expand Up @@ -66,6 +67,11 @@ public async Task<int> LaunchInSessionAsync(
var consoleSessionId = (int)Kernel32.WTSGetActiveConsoleSessionId();
EnsureNotConsoleSession(sessionId, consoleSessionId, exePath, allowConsoleSession);

var launchPlan = SteamIsolationLaunchPlan.Create(exePath, arguments, _options);
if (launchPlan.Supervised)
_logger.LogInformation("Launching isolated Steam through '{Launcher}' in session {Sid}",
launchPlan.ExecutablePath, sessionId);

// Step 1: Get a primary token for the target session
using var token = _sessionLauncher.GetSessionToken(sessionId, accountName);

Expand Down Expand Up @@ -101,6 +107,12 @@ public async Task<int> LaunchInSessionAsync(
Marshal.GetLastWin32Error(), sessionId, exePath);
}

using var tokenIdentity = launchPlan.Supervised ? new WindowsIdentity(token.DangerousGetHandle()) : null;
using var readiness = launchPlan.Supervised
? new SteamIsolationReadiness(tokenIdentity!.User ?? throw new InvalidOperationException("Seat token has no user SID."))
: null;
if (readiness is not null) launchPlan = launchPlan.WithReadyEvent(readiness.Name);

// Step 2: Create the environment block for the user
if (!UserEnv.CreateEnvironmentBlock(out var envBlock, token, false))
{
Expand All @@ -112,7 +124,7 @@ public async Task<int> LaunchInSessionAsync(
try
{
// Step 3: Build the command line
var cmdLine = FormatCommandLine(exePath, arguments);
var cmdLine = FormatCommandLine(launchPlan.ExecutablePath, launchPlan.Arguments);

var si = new Kernel32.StartupInfo
{
Expand Down Expand Up @@ -154,12 +166,25 @@ public async Task<int> LaunchInSessionAsync(
// session we ASKED for, which is a claim about our intent rather than about the world —
// so a process running in the wrong session produced a perfectly reassuring log. Seven
// rounds of issue #18 went past exactly that line.
VerifyLandedInSession(pi, exePath, sessionId, consoleSessionId);

try
{
VerifyLandedInSession(pi, exePath, sessionId, consoleSessionId);
// Step 5: Wait briefly for the process to initialize
await WaitForProcessStartAsync(pi, ct);
if (readiness is not null)
await readiness.WaitAsync(pi.hProcess, TimeSpan.FromSeconds(180), ct);
else
await WaitForProcessStartAsync(pi, ct);
}
catch
{
// The supervisor owns a kill-on-close job containing Steam and its children.
// Terminating it also cleans up an incomplete or cancelled isolated launch.
if (readiness is not null &&
Kernel32.WaitForSingleObject(pi.hProcess, 0) == Kernel32.WAIT_TIMEOUT &&
!Kernel32.TerminateProcess(pi.hProcess, 1))
_logger.LogError("Failed to terminate Steam isolation supervisor PID {Pid}: {Error}",
pi.dwProcessId, Marshal.GetLastWin32Error());
throw;
}
finally
{
Expand Down
94 changes: 94 additions & 0 deletions src/MultiSeat.Service/Sessions/SteamIsolationLaunchPlan.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System.Reflection.PortableExecutable;
using MultiSeat.Service.Configuration;

namespace MultiSeat.Service.Sessions;

/// <summary>Wraps explicit Steam launches without changing unrelated seat processes.</summary>
internal sealed record SteamIsolationLaunchPlan(string ExecutablePath, string? Arguments, bool Supervised)
{
private string? SteamPath { get; init; }
private string? SteamArguments { get; init; }

internal SteamIsolationLaunchPlan WithReadyEvent(string eventName)
{
if (!Supervised) throw new InvalidOperationException("Only isolated Steam launches use a readiness event.");
if (!eventName.StartsWith(@"Global\MultiSeatSteamReady-", StringComparison.Ordinal) ||
!Guid.TryParseExact(eventName[@"Global\MultiSeatSteamReady-".Length..], "N", out _))
throw new ArgumentException("Invalid Steam readiness event name.", nameof(eventName));
return this with { Arguments = BuildArguments(SteamPath!, SteamArguments, eventName) };
}

internal static SteamIsolationLaunchPlan Create(string executablePath, string? arguments,
MultiSeatOptions options)
{
if (!options.SteamIsolationEnabled ||
!string.Equals(Path.GetFileName(executablePath), "steam.exe", StringComparison.OrdinalIgnoreCase))
return new(executablePath, arguments, false);

RequireFile(executablePath, "Steam executable");
var launcher = options.SteamIsolationLauncherPath;
RequireFile(launcher, "Steam isolation launcher");
var directory = Path.GetDirectoryName(launcher)!;
var machine = ReadMachine(executablePath);
if (machine is not (Machine.I386 or Machine.Amd64))
throw new InvalidOperationException("Steam isolation supports only x86 and x64 Steam executables.");

RequireMachine(launcher, Machine.Amd64, "Steam isolation launcher");
var hook = Path.Combine(directory, machine == Machine.I386
? "MultiSeat.SteamHook32.dll" : "MultiSeat.SteamHook.dll");
RequireFile(hook, "Steam isolation hook");
RequireMachine(hook, machine, "Steam isolation hook");
if (machine == Machine.I386)
{
var launcher32 = Path.Combine(directory, "MultiSeat.SteamLauncher32.exe");
RequireFile(launcher32, "32-bit Steam isolation launcher");
RequireMachine(launcher32, Machine.I386, "32-bit Steam isolation launcher");
}

// Steam arguments are already a Windows command-line tail. Do not split/requote them.
return new(launcher, BuildArguments(executablePath, arguments, null), true)
{ SteamPath = executablePath, SteamArguments = arguments };
}

private static string BuildArguments(string steam, string? arguments, string? readyEvent)
{
var wrapped = $"--steam {QuotePath(steam)}";
if (readyEvent is not null) wrapped += $" --ready-event {readyEvent}";
wrapped += " --";
if (!string.IsNullOrWhiteSpace(arguments)) wrapped += " " + arguments;
return wrapped;
}

private static void RequireFile(string path, string description)
{
if (string.IsNullOrWhiteSpace(path) || path.IndexOfAny(['"', '\0']) >= 0 ||
!Path.IsPathFullyQualified(path) || !File.Exists(path))
throw new InvalidOperationException(
$"{description} must be an existing absolute file path: '{path}'. " +
"Steam was not launched without isolation.");
}

private static Machine ReadMachine(string path)
{
try
{
using var stream = File.OpenRead(path);
using var pe = new PEReader(stream);
if (pe.PEHeaders.PEHeader is null) throw new BadImageFormatException();
return pe.PEHeaders.CoffHeader.Machine;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or BadImageFormatException)
{
throw new InvalidOperationException($"Cannot read Steam isolation executable architecture: '{path}'.", ex);
}
}

private static void RequireMachine(string path, Machine expected, string description)
{
if (ReadMachine(path) != expected)
throw new InvalidOperationException($"{description} has the wrong architecture: '{path}' (expected {expected}).");
}

// Validated file paths cannot contain a quote and cannot end in a directory separator.
private static string QuotePath(string path) => $"\"{path}\"";
}
Loading