Skip to content

Add experimental per-session Steam isolation and game launcher - #110

Closed
bendaali wants to merge 1 commit into
vibesoftwarecoder:masterfrom
bendaali:codex/steam-isolation-v2
Closed

bendaali wants to merge 1 commit into
vibesoftwarecoder:masterfrom
bendaali:codex/steam-isolation-v2

Conversation

@bendaali

Copy link
Copy Markdown
Contributor

Launching Steam in multiple Windows seat sessions can connect a launch to another client or leave games unable to find the intended Steam instance. This adds an optional native supervisor and targeted Steam hooks, plus service integration and an EXE picker for direct game launches. The feature defaults to disabled and is proposed as a draft for compatibility review.

Changes

  • Wrap explicit absolute steam.exe launches from the service's dashboard/app and launch-on-connect paths when SteamIsolationEnabled is enabled. Validate executable/hook architecture and fail the launch if required components are missing.
  • Build x64/x86 supervisors and hook DLLs. Use a session/user-derived Steam IPC name, propagate it through child environments, separate browser cache/log paths, and handle Steam's temporary launch registry values and successful DWORD SteamPID reads.
  • Initialize hooks before Steam's primary thread runs, signal readiness through a protected service-owned event, and terminate incomplete launches on cancellation or timeout. Keep supervision across same-architecture Win32 Steam restarts, with job-object cleanup of supervised descendants.
  • Add a standalone EXE picker that passes the matching IPC environment to a directly launched game without injecting it. Such games are outside the supervisor's job and must be closed separately.
  • Document builds, configuration, explicit Apollo commands, existing IPC-based alternatives, and limitations. Vendor MinHook v1.3.4 with its license and add native CI checks.

The IPC technique predates this work; steam-multibox-easysetup is a simpler existing alternative. This PR does not claim general superiority over that approach.

Validation

  • Full managed test suite: 1,469 passed, 18 skipped, no failures, including launch-plan and readiness tests.
  • Release native builds and policy tests for x64 and x86.
  • Harmless executable fixtures for quoting, IPC and explicit child environments, browser cache/log paths, registry data types, same-architecture replacement Steam processes, readiness failures, 64-to-32-bit delegation, and descendant cleanup.
  • Direct-game smoke check verifies that the game matches the isolated Steam IPC name, uses its installation folder as the working directory, leaves the caller's environment unchanged, and receives no registry hook.
  • User reported simultaneous Steam clients in a host session and a separate Windows seat, and Valheim launching through the direct-game helper. The tested game installation contained a third-party Steamworks compatibility component; this is not validation of unmodified retail Steamworks games.

Draft limitations

Real Steam updates, unmodified retail Steamworks games, anti-cheat compatibility, broad restart scenarios, and disconnect/reconnect behavior still need runtime validation. Architecture-changing replacements require relaunch through the supervisor, and replacements bypassing Win32 process creation have not been validated. Apollo launches bypass automatic service wrapping and require the documented explicit launcher command. Native binaries must be built and deployed separately; this PR does not publish release binaries.

@bendaali
bendaali marked this pull request as ready for review October 11, 2026 13:33
@vibesoftwarecoder

Copy link
Copy Markdown
Owner

Thank you for this, and for the care in it. The problem is real. On our own host, starting Steam in one Windows session without an override can deliver its command line to another session's Steam. We reproduced it.

We read the whole change line by line, including the native code. We found no network access, no persistence and no privilege changes. We also found no memory-safety bugs. The bundled MinHook matches upstream v1.3.4. The hook DLL loads only into steam.exe, and the shipped default is off.

We can't merge it yet. These are the things we'd need changed:

  1. The Steam check is by name only. SteamIsolationLaunchPlan.cs lines 24-25 decide on Path.GetFileName(...) == "steam.exe". Names like steam.exe. or an 8.3 short name slip through unwrapped, with no error or log line.
  2. Restart coverage. Hook.cpp hooks only CreateProcessW and CreateProcessA (lines 132-148). A restart through another creation call, or with an unquoted path containing spaces, starts a Steam with no isolation.
  3. The launch wait. ProcessInjector.cs waits up to 180 seconds (line 174) while the seat is locked. The timeout errors aren't mapped to an API error, and the value isn't configurable.
  4. Upgrades remove the binaries. Our installer wipes the install folder on every deploy and restores only the two settings files. The native binaries would be deleted, and with the option on, every Steam launch would then fail. The PR would need to ship them or the installer would need to preserve them.
  5. The launcher is a console program (add_executable without WIN32, with CREATE_NEW_CONSOLE). Each launch probably shows a console window in the seat.
  6. Test gaps.
    • Nothing exercises ProcessInjector.
    • There is no negative test that another user can't open the readiness event.
    • Nothing runs the C# and native sides together.
    • The docs claim quoting is covered, but LauncherSmoke.ps1 doesn't assert the quoted argument.

Behaviours worth documenting: every child Steam starts gets the extra environment variable, SteamPID reads return 0 inside steam.exe, and all of Steam's descendants sit in one kill-on-close job.

The case we're still working on is a plain launch, such as a Start menu shortcut. Your docs say it's outside the feature, and we agree. In our tests, a Steam started without an override still reached a seat's Steam. We don't know yet how it finds it. We're looking into it, and what we learn will decide how we handle isolation.

We're keeping this open. Would you like to make the changes above yourself, or would you like us to make them? Either works. If you'd rather make them, tell us roughly when, so we can plan around it. If you'd like us to do it, we'll build on your branch and credit you in the commits.

If neither is practical for you right now, that's fine too. We'll keep it as a reference while we work on the plain-launch case.

@bendaali

bendaali commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough review and for taking the time to reproduce the issue. I really appreciate the detailed feedback and your offer to credit my contribution.

Unfortunately, I don't have enough time to continue working on the PR, and the current implementation already meets my personal needs.

I'd be more than happy for you to take over and build on my branch to address the remaining issues.

Thanks again for considering the contribution, and I'm looking forward to seeing how Multiseat evolves!

vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
The wrap decision compared the raw file name, so steam.exe. and steam.exe (trailing dot
or space) and steam.exe::$DATA were launched unwrapped with no error, although Windows
starts the real steam.exe for all of them. Normalize the last path segment first (trailing
dots and spaces, the default data stream) and hand the launcher the resolved name. Named
streams and lookalike names are still left alone, and relative or drive-relative forms are
refused like any other bad Steam path.

Tests cover each form, forward slashes, lookalikes and the normalizer. Twelve of them fail
against the old name comparison.

Part of the takeover of #110 (review item 1).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
…failures

The wait for the isolation helper was fixed at 180 s, and its failures reached the API badly:
the timeout was a bare TimeoutException, which the launch endpoint does not catch (an
unexplained 500), and an early helper exit was a plain InvalidOperationException (400, 'bad
request').

- MultiSeat:SteamIsolationReadyTimeoutSeconds sets the wait (default 180, kept between 10 and
  900 so a typo cannot fail every launch or hold the seat's lifecycle gate for hours).
- LaunchTimedOutException answers 504 and LaunchFailedException answers 500. Both derive from
  InvalidOperationException, so existing callers keep catching them. Bad paths and missing
  helper binaries stay 400.
- The timeout message names the limit and the setting.

Tests pin the status mapping, the messages, the clamp, and that the shipped appsettings.json
carries the same default as the code. Whole managed suite: 1507 passed, 18 skipped.

Part of the takeover of #110 (review item 3).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
The isolation launcher was a console program and the service started it with
CREATE_NEW_CONSOLE, so each isolated Steam launch probably showed a console window in the
seat. Two changes:

- The launcher is now a Windows-subsystem program (WIN32 with /ENTRY:wmainCRTStartup), for
  both architectures. Having no console, it reports every failure through one function: a log
  file in the seat user's profile (%LOCALAPPDATA%\MultiSeat\SteamIsolation\launcher.log,
  restarted at 256 KB), the debugger, stderr when redirected, and the parent's console when
  started from a terminal. Nothing it used to print is lost.
- The service passes CREATE_NO_WINDOW for a supervised launch instead of CREATE_NEW_CONSOLE.
  Other launches are unchanged.

The smoke test now reads both launchers' PE subsystem (checking that its probe can tell GUI
from the console fixture) and checks that a hook failure reaches the log file and stderr. Built
for x64 and Win32 with no warnings at /W4; the built launchers read as subsystem 2.

Part of the takeover of #110 (review item 5).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
The installer replaces the whole install folder on every deploy and restored only the two
settings files. SteamIsolation\ held the native helpers, so an upgrade deleted them, and with
MultiSeat:SteamIsolationEnabled on every Steam launch was then refused for want of the helper.

- Install-Payload: a payload that ships SteamIsolation\ replaces the old copy; one that does not
  leaves the existing folder as it was (restored from the full-folder backup taken just before
  the wipe). A payload that carries only some of the four files is refused before anything is
  touched, since half a set works for one Steam architecture and fails for the other.
- release.yml: a steam-isolation job on windows-2022 builds both architectures and runs the policy,
  launcher and game smoke tests; the release job downloads the four binaries into
  publish/MultiSeat/SteamIsolation and its payload check fails the release if any is missing.
- install-service.ps1 -BuildSteamIsolation builds the helpers into the stage on a source install.
  They are built fresh on that run, never taken from an earlier build's output, and a failed
  build fails the install. Without the switch, behaviour is unchanged.
- docs/steam-isolation.md describes where the helpers come from and how upgrades treat them.

Tests run the real library under Windows PowerShell and PowerShell 7: keep on upgrade, replace
when shipped, no invented folder, partial set refused, and the wiring of the installer and the
release workflow. With each fix disabled the matching tests fail.

Part of the takeover of #110 (review item 4).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
…ed paths

Three gaps in how a restarted steam.exe was recognized and isolated:

- Only CreateProcessW and CreateProcessA re-injected the hook, so a Steam that restarted itself
  through CreateProcessAsUser got the rewritten command line and environment but no hook and no
  supervision. The hook now sits on kernelbase's CreateProcessInternalW, where every Win32
  creation API ends (verified exported on this Windows build). The W/A pair and the ANSI
  conversion helper are gone.
- An unquoted program path that contains spaces was read by CommandLineToArgvW as its first
  word (C:\Program), so it was not recognized as Steam and was neither rewritten nor isolated.
  SplitImage reads it the way CreateProcess does, trying the text up to each space until a
  file exists; the first word is still taken on its name alone, and only steam.exe and
  steamwebhelper.exe are searched for.
- Inject() held about 192 KB of path buffers on the stack of whichever Steam thread restarted
  Steam. They are on the heap now, as are the buffers in MultiSeatInitialize.

The hook now logs through the same launcher.log as the launcher (new Log.h): when it becomes
active, and every restart it isolated or refused, with the reason. A refused restart used to
leave only a debugger string.

Policy tests cover the unquoted path (recognized, rewritten, not fooled by a missing folder or a
program that merely has Steam in its arguments), checked with a fake file-exists function. The
fixture can restart through CreateProcessW, A, AsUser and an unquoted path, and the smoke test
runs all four for both architectures and checks the hook's log lines. Built for x64 and Win32
at /W4 with no warnings; policy tests pass locally, the smoke tests run on CI.

Part of the takeover of #110 (review item 2).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
The wrap decision compared the raw file name, so steam.exe. and steam.exe (trailing dot
or space) and steam.exe::$DATA were launched unwrapped with no error, although Windows
starts the real steam.exe for all of them. Normalize the last path segment first (trailing
dots and spaces, the default data stream) and hand the launcher the resolved name. Named
streams and lookalike names are still left alone, and relative or drive-relative forms are
refused like any other bad Steam path.

Tests cover each form, forward slashes, lookalikes and the normalizer. Twelve of them fail
against the old name comparison.

Part of the takeover of #110 (review item 1).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
…failures

The wait for the isolation helper was fixed at 180 s, and its failures reached the API badly:
the timeout was a bare TimeoutException, which the launch endpoint does not catch (an
unexplained 500), and an early helper exit was a plain InvalidOperationException (400, 'bad
request').

- MultiSeat:SteamIsolationReadyTimeoutSeconds sets the wait (default 180, kept between 10 and
  900 so a typo cannot fail every launch or hold the seat's lifecycle gate for hours).
- LaunchTimedOutException answers 504 and LaunchFailedException answers 500. Both derive from
  InvalidOperationException, so existing callers keep catching them. Bad paths and missing
  helper binaries stay 400.
- The timeout message names the limit and the setting.

Tests pin the status mapping, the messages, the clamp, and that the shipped appsettings.json
carries the same default as the code. Whole managed suite: 1507 passed, 18 skipped.

Part of the takeover of #110 (review item 3).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
The isolation launcher was a console program and the service started it with
CREATE_NEW_CONSOLE, so each isolated Steam launch probably showed a console window in the
seat. Two changes:

- The launcher is now a Windows-subsystem program (WIN32 with /ENTRY:wmainCRTStartup), for
  both architectures. Having no console, it reports every failure through one function: a log
  file in the seat user's profile (%LOCALAPPDATA%\MultiSeat\SteamIsolation\launcher.log,
  restarted at 256 KB), the debugger, stderr when redirected, and the parent's console when
  started from a terminal. Nothing it used to print is lost.
- The service passes CREATE_NO_WINDOW for a supervised launch instead of CREATE_NEW_CONSOLE.
  Other launches are unchanged.

The smoke test now reads both launchers' PE subsystem (checking that its probe can tell GUI
from the console fixture) and checks that a hook failure reaches the log file and stderr. Built
for x64 and Win32 with no warnings at /W4; the built launchers read as subsystem 2.

Part of the takeover of #110 (review item 5).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
The installer replaces the whole install folder on every deploy and restored only the two
settings files. SteamIsolation\ held the native helpers, so an upgrade deleted them, and with
MultiSeat:SteamIsolationEnabled on every Steam launch was then refused for want of the helper.

- Install-Payload: a payload that ships SteamIsolation\ replaces the old copy; one that does not
  leaves the existing folder as it was (restored from the full-folder backup taken just before
  the wipe). A payload that carries only some of the four files is refused before anything is
  touched, since half a set works for one Steam architecture and fails for the other.
- release.yml: a steam-isolation job on windows-2022 builds both architectures and runs the policy,
  launcher and game smoke tests; the release job downloads the four binaries into
  publish/MultiSeat/SteamIsolation and its payload check fails the release if any is missing.
- install-service.ps1 -BuildSteamIsolation builds the helpers into the stage on a source install.
  They are built fresh on that run, never taken from an earlier build's output, and a failed
  build fails the install. Without the switch, behaviour is unchanged.
- docs/steam-isolation.md describes where the helpers come from and how upgrades treat them.

Tests run the real library under Windows PowerShell and PowerShell 7: keep on upgrade, replace
when shipped, no invented folder, partial set refused, and the wiring of the installer and the
release workflow. With each fix disabled the matching tests fail.

Part of the takeover of #110 (review item 4).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vibesoftwarecoder added a commit that referenced this pull request Oct 14, 2026
…ed paths

Three gaps in how a restarted steam.exe was recognized and isolated:

- Only CreateProcessW and CreateProcessA re-injected the hook, so a Steam that restarted itself
  through CreateProcessAsUser got the rewritten command line and environment but no hook and no
  supervision. The hook now sits on kernelbase's CreateProcessInternalW, where every Win32
  creation API ends (verified exported on this Windows build). The W/A pair and the ANSI
  conversion helper are gone.
- An unquoted program path that contains spaces was read by CommandLineToArgvW as its first
  word (C:\Program), so it was not recognized as Steam and was neither rewritten nor isolated.
  SplitImage reads it the way CreateProcess does, trying the text up to each space until a
  file exists; the first word is still taken on its name alone, and only steam.exe and
  steamwebhelper.exe are searched for.
- Inject() held about 192 KB of path buffers on the stack of whichever Steam thread restarted
  Steam. They are on the heap now, as are the buffers in MultiSeatInitialize.

The hook now logs through the same launcher.log as the launcher (new Log.h): when it becomes
active, and every restart it isolated or refused, with the reason. A refused restart used to
leave only a debugger string.

Policy tests cover the unquoted path (recognized, rewritten, not fooled by a missing folder or a
program that merely has Steam in its arguments), checked with a fake file-exists function. The
fixture can restart through CreateProcessW, A, AsUser and an unquoted path, and the smoke test
runs all four for both architectures and checks the hook's log lines. Built for x64 and Win32
at /W4 with no warnings; policy tests pass locally, the smoke tests run on CI.

Part of the takeover of #110 (review item 2).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@vibesoftwarecoder

Copy link
Copy Markdown
Owner

Thank you for handing this over, and for the work behind it.

We've taken it on, as you suggested. It continued in #113, which is now merged. Your commit is the first commit of that work on master (bfc644e), with your authorship, so you stay credited in the history.

Three corrections to our review above, stated as fact:

  • We said 8.3 short names slip past the Steam check. For steam.exe itself that is wrong: the name already fits 8.3 and has no alias. The real gaps were trailing dots and spaces and the default stream (steam.exe., steam.exe , steam.exe::$DATA).
  • We said Hook.cpp hooks only CreateProcessW and A. It also hooks NtCreateUserProcess, which rewrites every child's command line and environment. The real gap was that only the W/A pair re-injected the hook into a restarted Steam, and that an unquoted path with spaces was not recognized.
  • We said LauncherSmoke.ps1 doesn't assert the quoted argument. It does (arg5=two words). The argument with an embedded quote is not asserted.

#113 lists what we changed, what we tested and what we did not. We're closing this PR because #113 replaces it. Your branch is not touched.

Thanks again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants