-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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:
-
HKCUresolves to SYSTEM's hive, not the signed-in user's. A registry write "succeeds" and lands somewhere nobody will ever read. -
user32desktop APIs such asSystemParametersInfohave 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.
| 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.
UserSessionLauncher performs the standard service-to-session sequence:
-
WTSGetActiveConsoleSessionIdto find the interactive console session. -
WTSQueryUserTokento borrow the signed-in user's token. -
DuplicateTokenExto a primary token. -
CreateEnvironmentBlockso the child inherits the user'sUSERPROFILE,APPDATAandLOCALAPPDATA— without this the child has none of the profile paths a login script needs. -
CreateProcessAsUserwithlpDesktop = winsta0\default,CREATE_NO_WINDOWandCREATE_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-Hostfrom a login script still reachesstartset.log.
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.
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
SuccessnorFailed. It did not fail — it never started — and calling it a success is precisely what made this class of problem invisible. -
ExitCodeisnull. A zero there would read as a clean run. - In
reports\items.jsonthe item'scurrent_statusisPending, notInstalledand notError. NotInstalled, because the settings were not applied. NotError, 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_sessionis empty andaction_performedis 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.
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.
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.
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.
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 StartSetNew-Item -ItemType File 'C:\ProgramData\ManagedState\.startset.ondemand' -ForceThen sign out and back in for the login path, and read the session log.
StartSet — MIT licensed — windowsadmins/startset — a Windows port of macadmins/outset.
StartSet
How it works
Migrating
Operating
Contributing