Repository navigation
Architecture
StartSet is two executables over five assemblies. The service and the CLI are thin hosts around one execution engine; everything either of them can do, the other can do.
| Project | Namespace | Responsibility |
|---|---|---|
src/Core |
StartSet.Core |
Path constants, PayloadType / TriggerType / ExecutionStatus enums, models |
src/Infrastructure |
StartSet.Infrastructure |
Preferences, logging and session logging, run-once tracking, checksums, permission validation, network monitor |
src/Engine |
StartSet.Engine |
ExecutionEngine, the four payload processors, the user-session launcher |
src/CLI |
StartSet.CLI |
System.CommandLine surface → managedstatekeeper.exe
|
src/Service |
StartSet.Service |
Generic Host with three background workers → StartSetService.exe
|
Everything is net10.0-windows, published self-contained single-file for win-x64 and
win-arm64.
flowchart TD
subgraph svc["StartSetService.exe — LocalSystem, session 0"]
BW["BootWorker<br/>runs once at service start"]
LW["LogonEventWorker<br/>Security 4624, types 2/10/11"]
TW["TriggerWatcherWorker<br/>FileSystemWatcher .startset.*"]
end
CLI["managedstatekeeper.exe"]
ENG["ExecutionEngine"]
subgraph proc["Processors"]
PS[".ps1 — pwsh / powershell"]
BAT[".cmd .bat — cmd.exe"]
EXE[".exe — direct"]
PKG[".msi msiexec / .msix Add-AppxPackage"]
end
USL["UserSessionLauncher<br/>CreateProcessAsUser"]
subgraph state["C:\\ProgramData\\ManagedState"]
DIRS["payload directories"]
SHARE["share\\runonce-*.json<br/>share\\checksums.yaml"]
LOGS["logs\\ and reports\\"]
end
BW --> ENG
LW --> ENG
TW --> ENG
CLI --> ENG
ENG --> proc
proc -->|"login-once, login-every, on-demand"| USL
ENG --> DIRS
ENG --> SHARE
ENG --> LOGS
StartSetService.exe is a .NET Generic Host registered as the Windows service StartSet,
running as LocalSystem with automatic start. It hosts three BackgroundService workers,
registered in this order: TriggerWatcherWorker, LogonEventWorker, BootWorker.
Runs once when the service starts, then completes — it does not loop. It waits one second, runs
boot-once and boot-every (honouring wait_for_network), then runs login-window, then
closes its session log.
Because it fires at service start rather than at OS boot specifically, restarting the service
re-runs boot-every. This is the supported way to test a boot payload:
Restart-Service StartSetSubscribes to the Security event log with an EventLogWatcher and this query: event ID 4624
with LogonType 2, 10 or 11.
| Logon type | Meaning | Watched |
|---|---|---|
| 2 | Interactive — console sign-in with uncached credentials | yes |
| 10 | RemoteInteractive — RDP | yes |
| 11 | CachedInteractive — console sign-in against cached credentials | yes |
| others | Network, service, batch, unlock, etc. | no |
Type 11 matters: on a domain-joined or Entra-joined machine, a user's first sign-in is type 2 and every sign-in after that is type 11 once credentials are cached. Watching only 2 and 10 would make login payloads fire once per user per machine and then silently never again.
Accounts filtered out before anything runs: SYSTEM, LOCAL SERVICE, NETWORK SERVICE,
ANONYMOUS LOGON, anything matching ^(DWM|UMFD)-\d+$, and any name ending in $ (computer
accounts). The DWM-n / UMFD-n pattern is a regex rather than a fixed list because the number is
the session id and grows without bound on a machine that accumulates sessions.
On a qualifying logon the worker waits login_delay seconds, then runs, in order:
-
login-onceandlogin-every, with the event'sTargetUserName -
login-privileged-every— always, no trigger file needed -
login-privileged-once— only if.startset.login-privilegedexists, which is then deleted
If the Security log cannot be read the worker logs
Insufficient permissions to monitor Security event log and login payloads are simply never
triggered automatically.
Ensures the directory tree exists, then watches C:\ProgramData\ManagedState with a
FileSystemWatcher filtered to .startset.* on FileName and CreationTime notifications.
On creation it waits 100 ms for the write to settle, maps the filename to payload types, runs
them, then deletes the trigger file. Existing trigger files are processed once at start-up, so a
trigger dropped while the service was stopped is not lost.
ExecutionEngine.ExecuteAsync(payloadTypes, username, waitForNetwork) is the single entry point.
Its order is:
-
Ignored-user check. If a username was supplied and matches
ignored_userscase-insensitively, return immediately with no results. Nothing runs, for any payload type in this call. - Ensure directories. Create any missing payload, share, log and report directories.
-
Network wait. If requested (or
wait_for_networkis true), poll every 2 s up tonetwork_timeout. A network is "available" when a non-loopback, non-tunnel interface is Up and has an IPv4 default gateway; if that check says no, a WMI query overWin32_NetworkAdapterConfiguration WHERE IPEnabled = TRUElooking for aDefaultIPGatewayis tried as a fallback. On timeout, abort the whole run unlessignored_network_failureis set. -
Per payload type, in the order the caller listed them:
- Discover top-level files whose extension is in
allowed_extensions, sorted case-insensitively by filename. - Compute each file's SHA256.
- If
checksum_validation, validate againstshare\checksums.yaml; mismatch marks the payload skipped. - If the type requires elevation, run the ownership check; failure marks the payload skipped.
- For run-once types, consult the ledger unless the filename is in
overrides. - Execute the not-skipped payloads sequentially, each with a
script_timeoutdeadline. - Record successful run-once executions; delete
boot-oncepayloads after success. - Record each outcome into the session's
items.jsonset.
- Discover top-level files whose extension is in
- Summarise. Log succeeded / failed / skipped / deferred counts, and log a second explicit warning if anything was deferred.
parallel_execution is read and warned about but not honoured.
A processor is selected by file extension, first match wins, in registration order: PowerShell,
Batch, Executable, Package. An unmatched extension yields UnsupportedType.
| Processor | Extensions | Invocation |
|---|---|---|
PowerShellProcessor |
.ps1 |
<pwsh-or-powershell> -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "<path>" |
BatchProcessor |
.cmd, .bat
|
cmd.exe /c "<path>" |
ExecutableProcessor |
.exe |
the file directly, no arguments |
PackageProcessor |
.msi |
msiexec.exe /i "<path>" /qn /norestart /l*v "<installlog>" |
PackageProcessor |
.msix |
powershell.exe … -Command "Add-AppxPackage -Path '<path>' -ForceApplicationShutdown" |
PowerShell selection prefers PowerShell 7: C:\Program Files\PowerShell\7\pwsh.exe, then the
x86 path, then pwsh.exe on PATH, falling back to
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe. Scripts should therefore not assume
Windows PowerShell 5.1 semantics.
The working directory for a payload is the payload directory itself.
Every processor redirects stdout and stderr and enforces the timeout by killing the entire process
tree on expiry, producing ExecutionStatus.Timeout. MSI exit codes 0 and 3010 are success;
other codes are mapped to a human-readable message (1603 Fatal error during installation,
1618 Another installation is already in progress, and so on).
Before running its own child process, each of the PowerShell, Batch and Executable processors
calls UserContextExecution.TryRunAsConsoleUser. That helper returns null for payload types
that belong in SYSTEM context, letting the processor proceed; for login-once, login-every and
on-demand it takes over completely and its result is final. There is deliberately no path that
runs a user-context payload as SYSTEM. See Windows Session Model.
.msi and .msix payloads are not routed through the user session — package installation always
runs in the invoking context.
| Status | Meaning |
|---|---|
Success |
Ran, exit code 0 (or MSI 0/3010) |
Failed |
Ran, non-zero exit, or an exception was thrown |
Skipped |
Run-once already satisfied, checksum mismatch, or permission validation failed |
Deferred |
User-context payload that could not be started in the console session — it did not run |
Timeout |
Killed after script_timeout
|
NotFound, ChecksumMismatch, PermissionDenied, NetworkTimeout
|
Defined in the enum; the engine currently reports these conditions as Skipped with a reason |
UnsupportedType |
No processor for the extension |
| Path | Written by | Format |
|---|---|---|
share\runonce-system.json |
run-once tracker (system context) | JSON, snake_case |
share\runonce-<user>.json |
run-once tracker (user context) | JSON, snake_case |
share\checksums.yaml |
checksum service | YAML |
Config.yaml |
preferences service | YAML |
logs\<date>\<HHmm>\ |
session logger | text + JSON + JSONL |
reports\ |
session logger | JSON + text |
A run-once ledger entry looks like this:
{
"version": 1,
"last_modified": "2026-03-01T18:22:04.1234567+00:00",
"entries": {
"map-departmentdrives.ps1": {
"script_path": "C:\\ProgramData\\ManagedState\\login-once\\Map-DepartmentDrives.ps1",
"checksum": "3b1f9a0c8e6d4f2a1b7c5d9e0f3a6b8c2d4e6f8a0b1c3d5e7f9a1b3c5d7e9f01",
"executed_at": "2026-03-01T18:22:04.0000000+00:00",
"payload_type": "LoginOnce",
"exit_code": 0,
"success": true,
"username": "SYSTEM",
"notes": null
}
}
}The map key is the lowercased filename. username records the account of the process that did
the recording — SYSTEM when the service ran the payload — not the user the payload was run
for. The user identity is carried by which ledger file the entry is in.
StartSet — MIT licensed — windowsadmins/startset — a Windows port of macadmins/outset.
StartSet
How it works
Migrating
Operating
Contributing