Skip to content

Windows Session Model

Rod Christiansen edited this page Sep 3, 2026 · 1 revision

Windows session model

This page is the one with no macOS equivalent. On macOS, outset's LaunchAgent already runs inside the user's GUI session. On Windows there is no equivalent of a LaunchAgent in StartSet's design: everything is driven by one LocalSystem service in session 0, and reaching the signed-in user is work the service has to do explicitly.

Session 0 isolation

The StartSet service runs as LocalSystem in session 0. Interactive users get session 1 and above. Session 0 has no desktop a user can see and no access to a user's profile-scoped state. A process started plainly by the service — Process.Start — inherits session 0 and the SYSTEM token. For that process:

  • HKCU resolves to SYSTEM's hive, not the signed-in user's. A registry write "succeeds" and lands somewhere nobody will ever read.
  • user32 desktop APIs such as SystemParametersInfo have no session to act on. Wallpaper, desktop settings and window operations do nothing.
  • %USERPROFILE%, %APPDATA% and %LOCALAPPDATA% point at the SYSTEM profile.
  • A script that guards with "skip administrators" matches SYSTEM, skips its own work, and exits 0.

That last one is the dangerous case, because it reports success. This is why StartSet has a Deferred status at all.

Which payload types get a user session

Payload type Context
login-once Console user
login-every Console user
on-demand Console user
boot-once, boot-every, login-window Service context (LocalSystem, session 0)
login-privileged-once, login-privileged-every, on-demand-privileged Service context (LocalSystem, session 0)

The decision is a property of the payload type, not the file extension: .ps1, .cmd, .bat and .exe payloads in a user-context directory are all launched into the user's session.

.msi and .msix payloads are not — package installation always runs in the invoking context, so an .msi dropped into login-every installs as SYSTEM.

How the user session is reached

UserSessionLauncher performs the standard service-to-session sequence:

  1. WTSGetActiveConsoleSessionId to find the interactive console session.
  2. WTSQueryUserToken to borrow the signed-in user's token.
  3. DuplicateTokenEx to a primary token.
  4. CreateEnvironmentBlock so the child inherits the user's USERPROFILE, APPDATA and LOCALAPPDATA — without this the child has none of the profile paths a login script needs.
  5. CreateProcessAsUser with lpDesktop = winsta0\default, CREATE_NO_WINDOW and CREATE_UNICODE_ENVIRONMENT, with stdout and stderr on inherited pipes so output is still captured into the session log.

Note the consequences of this design:

  • Only the active console session is targeted. On a machine with several signed-in sessions, the payload goes to the console one.
  • The launched process is not elevated. It carries the user's own token, so a user-context payload gets exactly the privileges that user has.
  • Output is captured, so Write-Host from a login script still reaches startset.log.

The token race, and the 60-second wait

At sign-in the session exists before a user token is available for it, and StartSet's login trigger fires inside that window. A single attempt at the moment of logon samples a race at its worst point.

So TryGetConsoleUserToken polls once a second for up to 60 seconds waiting for a token. Once the token appears, every later payload in the same run gets it on the first attempt.

If the full 60 seconds elapses with no token, the process sets a memo: subsequent payloads in the same run each make one attempt but do not wait again. Without that, a dozen payloads on a machine where impersonation is genuinely unavailable would each block for a minute and turn a one-minute wait into a quarter-hour of login. The memo is static and lives exactly as long as the process.

Deferral

If the console user's session cannot be reached, the payload is recorded as Deferred:

  • It did not run. Nothing was executed in any context.
  • It is neither Success nor Failed. It did not fail — it never started — and calling it a success is precisely what made this class of problem invisible.
  • ExitCode is null. A zero there would read as a clean run.
  • In reports\items.json the item's current_status is Pending, not Installed and not Error. Not Installed, because the settings were not applied. Not Error, because the next sign-in retries and a fleet-wide alarm for a self-healing condition is its own kind of noise.
  • last_seen_in_session is empty and action_performed is absent, so a consumer filtering to "what this run actually did" correctly excludes it.

The log carries both a per-payload warning and a run-level one:

Deferred Set-Wallpaper.ps1: could not reach the console user's session (WTSQueryUserToken failed for session 1: ...). The script did NOT run.
1 payload(s) did not run because the console user's session could not be reached. Their settings have NOT been applied.

Grep for Deferred when a user reports that their settings did not apply.

Which logons actually fire login payloads

LogonEventWorker watches Security event 4624 restricted to LogonType 2, 10 and 11.

Logon type Fires payloads
2 — Interactive (console, uncached credentials) yes
10 — RemoteInteractive (RDP) yes
11 — CachedInteractive (console, cached credentials) yes
3 — Network no
4 — Batch (scheduled tasks) no
5 — Service no
7 — Unlock no
8, 9 — NetworkCleartext, NewCredentials no

Type 11 is the one that matters in practice on domain-joined and Entra-joined machines: a user's first sign-in on a machine is type 2, and every sign-in after that is type 11 once their credentials are cached. Watching only 2 and 10 means login payloads run once per user per machine and then never again.

Workstation unlock (type 7) is not a logon event here. Payloads do not re-run when a user unlocks; they run on sign-in.

Accounts that never fire payloads

Filtered before anything is dispatched:

Pattern Why
SYSTEM, LOCAL SERVICE, NETWORK SERVICE, ANONYMOUS LOGON Not users
DWM-<n> Desktop Window Manager pseudo-session
UMFD-<n> User Mode Font Driver pseudo-session
anything ending $ Computer accounts

DWM-n and UMFD-n are matched by regex, not a fixed list, because <n> is the session id and grows without bound on a machine that accumulates sessions. This also means logs will look busy with type-2 logon events on a shared machine even when no human has signed in — those pseudo- sessions produce them constantly, which is why "the log shows type 2 logons" is not evidence that a user signed in.

Add real accounts you want excluded to ignored_users in Configuration.

Privileged login payloads and the user's hive

login-privileged-* payloads run as LocalSystem in session 0, with all of the constraints at the top of this page. They are for machine-scoped work you want to happen around a logon — not a way to write to the logged-in user's hive with elevation. There is no mechanism in StartSet that gives you both elevation and the user's hive in one payload.

If you need to write a per-user setting with data only an administrator can obtain, split it: have a privileged payload compute and stage the data somewhere world-readable (for example C:\ProgramData\ManagedState\share), and have a login-every payload consume it as the user.

Testing a user-context payload

managedstatekeeper process login-every run from an interactive elevated shell launches into the console session correctly, because the console user's token is obtainable. Run over a remote shell (SSH, PsExec, a scheduled task with no interactive session) the same command may find no console session and defer every payload — which looks like a bug and is the mechanism doing its job.

To test what the service will actually do, drive the service:

Restart-Service StartSet
New-Item -ItemType File 'C:\ProgramData\ManagedState\.startset.ondemand' -Force

Then sign out and back in for the login path, and read the session log.

Clone this wiki locally