Skip to content

Architecture

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

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.

Assemblies

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.

Components and flow

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
Loading

The service

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.

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 StartSet

LogonEventWorker

Subscribes 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:

  1. login-once and login-every, with the event's TargetUserName
  2. login-privileged-every — always, no trigger file needed
  3. login-privileged-once — only if .startset.login-privileged exists, 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.

TriggerWatcherWorker

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.

The execution engine

ExecutionEngine.ExecuteAsync(payloadTypes, username, waitForNetwork) is the single entry point. Its order is:

  1. Ignored-user check. If a username was supplied and matches ignored_users case-insensitively, return immediately with no results. Nothing runs, for any payload type in this call.
  2. Ensure directories. Create any missing payload, share, log and report directories.
  3. Network wait. If requested (or wait_for_network is true), poll every 2 s up to network_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 over Win32_NetworkAdapterConfiguration WHERE IPEnabled = TRUE looking for a DefaultIPGateway is tried as a fallback. On timeout, abort the whole run unless ignored_network_failure is set.
  4. 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 against share\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_timeout deadline.
    • Record successful run-once executions; delete boot-once payloads after success.
    • Record each outcome into the session's items.json set.
  5. 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.

Processors

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).

User-context dispatch

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.

Execution statuses

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

State on disk

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.

Clone this wiki locally