Fix endpoint user-context collection running in the wrong session

Mapped drives reported Unavailable on endpoints where the operator could
see them fine. The data path was intact; the collection was running under
the wrong token. Mapped drives are per logon token, so an elevated probe
enumerates a different drive table than the interactive user.

Collection:
- Pin the user-context spool path so the probe and service agree on one
  location, and persist/overwrite it instead of deleting on read.
- Refuse to collect while elevated (exit code 2) rather than emitting a
  confident wrong answer, and stamp elevated/username on the spool.
- Reject spools stamped elevated or from an unexpected account on read,
  so an admin run cannot masquerade as valid.
- Log netuse/smbmapping/userlistener counts so each run leaves evidence
  of what it actually saw.

Install and launch context:
- Move collection to a 'ShopDB user-context probe' scheduled task under
  S-1-5-32-545 at RunLevel Limited, the only mechanism that reliably runs
  in the operator's own session. Verify registration and record it in the
  install ledger.
- Stop starting the tray as a child of the elevated installer; explorer.exe
  de-elevates without changing ACCOUNT, so an admin install produced an
  admin tray. The Run key only fires at logon, which left the tray dead on
  a machine already signed in, so add a transient 'ShopDB tray first launch'
  task that starts it once in the existing session and unregisters itself.
- Hide the console window during --collect-usercontext. The agent is a
  console exe; the tray used to mask this with CreateNoWindow, but the
  scheduled task invokes it directly and flashed a window every cycle.

Installer edits are in _install_script() in plugins/endpoints/api/siteagent.py,
which generates the install.ps1 that actually ships. The standalone
agent/installer/install-shopdbagent.ps1 was never shipped once the package
moved to the UI builder and is deleted here to stop the divergence that
caused an earlier fix to land in a file no endpoint ever ran.

Versions: agent 1.10.1, tray 1.3.0. These share the pinned spool path and
must be deployed together.

Pre-existing test failures unrelated to this work (8, identical on a clean
tree): docs publishable/versions, plugin contract symlink scan, import
guard tamper check, security headers upload sandbox, and three endpoint
timestamp tests.
This commit is contained in:
benjamin.baker
2026-09-05 17:32:03 -04:00
parent ba38e4d39d
commit d6bd7b5f54
15 changed files with 904 additions and 569 deletions

View File

@@ -84,12 +84,23 @@ event channel and system WMI.
## Tray app (system-tray icon)
The tray is a per-user process, not a service, so it starts at sign-in rather
than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies
`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart
(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and
launches it for the current user. Run the installer from the operator's own
session so the autostart lands in the right user hive. Pass `-NoTray` for a
headless, service-only install.
than under `sc.exe`. The generated `install.ps1` handles it: it copies
`shopdb-endpoint-tray.exe` next to the agent and registers a machine-wide logon
autostart (`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`,
64-bit view). The installer deliberately does NOT start the tray itself: it runs
elevated, so any process it starts inherits an admin token, and mapped drives are
per logon token - an elevated tray probes the wrong drive table. The Run key only
fires at logon, which would leave the tray dead until sign-out on a machine
someone is already using, so the installer also registers a transient
`ShopDB tray first launch` scheduled task under `S-1-5-32-545` at `RunLevel
Limited`, runs it once, and unregisters it. That task is the only mechanism that
can reach into an existing interactive session without carrying the installer's
token. Pass `-NoTray` for a headless, service-only install.
User-context collection does not depend on the tray. The installer registers a
`ShopDB user-context probe` scheduled task against the Users group at
`RunLevel Limited`, which runs `shopdbagent.exe --collect-usercontext` in the
operator's own session at logon and every 30 minutes.
Right-click the tray icon for:
@@ -143,7 +154,7 @@ logic itself changes.
## Diagnostics
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log`
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\YYYYMMDD-agent.log`
(14-day retention).
- Windows Event Log: agent runtime failures and unhandled exceptions are written
to the `Application` log under source `ShopDB Endpoint Agent`. The source is

View File

@@ -0,0 +1,51 @@
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Core;
// Hides the console window this process was given.
//
// shopdbagent.exe is a console executable, so anything that starts it in an
// interactive session gets a visible black window. That was invisible while the
// tray owned the probe - it launched with CreateNoWindow - but the user-context
// scheduled task runs the exe directly in the operator's session, where the
// window is very much visible and pops up on every collection cycle.
//
// The window is hidden rather than the exe rebuilt as a WinExe: the agent is
// also a service host and a command-line tool, and both of those want a real
// console for --once, --report and service debugging.
public static class ConsoleWindow
{
private const int SW_HIDE = 0;
[DllImport("kernel32.dll")]
private static extern nint GetConsoleWindow();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(nint window, int command);
// Best-effort: a process with no console (already detached, or redirected by
// a caller) simply has nothing to hide, and failing to hide a window must
// never stop the collection that window belongs to.
public static void Hide()
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
var window = GetConsoleWindow();
if (window != nint.Zero)
{
ShowWindow(window, SW_HIDE);
}
}
catch
{
// Nothing useful to do or say: there is no log sink yet at the point
// this runs, and a visible window is cosmetic next to losing data.
}
}
}

View File

@@ -272,12 +272,15 @@ public sealed class EndpointCollector : ICollector
return lastUserContext;
}
// Consume rather than peek: the file is removed once it has been read, so
// a tray that has died cannot keep this endpoint reporting the same
// mapped drives forever. lastUserContext still repeats the previous value
// between successful reads, because the tray collects on a much slower
// cadence than telemetry and dropping the key in between would read on
// the server as "the drives went away".
// Peek rather than consume: the probe overwrites the spool on every run,
// so the newest result is always the one on disk. The file deliberately
// survives a service restart, which the previous delete-on-read did not -
// lastUserContext is in-memory only, so a restart used to leave the agent
// with no drives to report until the tray's next probe up to 30 minutes
// later. lastUserContext still repeats the previous value between reads,
// because the tray collects on a slower cadence than telemetry and
// dropping the key in between would read on the server as "the drives
// went away".
var collected = userContextSpool.ConsumeResult(resultPath);
if (collected is not null)
lastUserContext = collected;

View File

@@ -15,7 +15,6 @@ public sealed class FileLoggerProvider : ILoggerProvider
private readonly LogLevel minLevel;
private readonly int retentionDays;
private readonly string filePrefix;
private readonly bool stampFirst;
private readonly object gate = new();
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
private string currentDateStamp = "";
@@ -27,13 +26,12 @@ public sealed class FileLoggerProvider : ILoggerProvider
public string? SinkProblem { get; private set; }
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
int retentionDays = 14, string filePrefix = "agent", bool stampFirst = false)
int retentionDays = 14, string filePrefix = "agent")
{
this.directory = directory;
this.minLevel = minLevel;
this.retentionDays = retentionDays;
this.filePrefix = filePrefix;
this.stampFirst = stampFirst;
// Best-effort: creating the log directory can fail on a locked-down host
// or when a non-privileged user targets a SYSTEM-owned path. Never let
// that abort construction; Write() degrades to a no-op if the sink is
@@ -129,10 +127,13 @@ public sealed class FileLoggerProvider : ILoggerProvider
writer?.Flush();
writer?.Dispose();
currentDateStamp = stamp;
// stampFirst gives "<datetime>-trayagent.log"; the service keeps its
// established "agent-<date>.log" so existing log collection still finds
// it. Both roll daily and prune on the same pattern.
var name = stampFirst ? $"{stamp}-{filePrefix}.log" : $"{filePrefix}-{stamp}.log";
// One convention for every log this codebase writes:
// "<yyyyMMdd>-<prefix>.log", so agent, tray and installer logs sort
// chronologically when they share a directory and can be collected with
// a single date-leading glob. The service previously used the opposite
// order, which meant a support request for "the logs from the 5th" had to
// know which component wrote which shape.
var name = $"{stamp}-{filePrefix}.log";
var path = Path.Combine(directory, name);
writer = new StreamWriter(new FileStream(
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
@@ -147,7 +148,11 @@ public sealed class FileLoggerProvider : ILoggerProvider
try
{
var cutoff = DateTime.Now.AddDays(-retentionDays);
var pattern = stampFirst ? $"*-{filePrefix}.log" : $"{filePrefix}-*.log";
// Both shapes: retention has to keep covering the "<prefix>-<date>"
// files written before the convention was unified, or an upgraded
// endpoint would keep its pre-upgrade logs forever.
string[] patterns = [$"*-{filePrefix}.log", $"{filePrefix}-*.log"];
foreach (var pattern in patterns)
foreach (var file in Directory.EnumerateFiles(directory, pattern))
{
if (File.GetLastWriteTime(file) < cutoff)

View File

@@ -28,13 +28,19 @@ namespace ShopDbEndpointAgent.Core;
// from the agent binary or be tampered with on disk between cycles.
public sealed class UserContextProbe
{
private const string ProbeScript = """
// The spool directory is injected by BuildProbeScript rather than derived in
// the script, so the writer cannot disagree with the reader. See
// UserContextSpool.PinnedSpoolDirectory for why $env:USERPROFILE was wrong.
private const string SpoolDirectoryToken = "__SHOPDB_SPOOL_DIR__";
private const string ProbeScriptTemplate = """
$ErrorActionPreference = 'Stop'
$result = [ordered]@{}
# Must match UserContextSpool.TryGetResultPath on the reader side. Uses
# USERPROFILE, which for the shop-floor operator resolves to
# C:\Users\ShopFloor - the same fixed location the service reads.
$spool = Join-Path $env:USERPROFILE 'ShopDB'
# Injected by the agent from UserContextSpool.PinnedSpoolDirectory() so the
# writer and the reader resolve the same directory. Deliberately NOT
# $env:USERPROFILE: the tray can inherit another account's environment on a
# locked-down image, which silently wrote this file into the wrong profile.
$spool = '__SHOPDB_SPOOL_DIR__'
$target = Join-Path $spool 'usercontext.json'
$temporary = "$target.tmp"
@@ -133,6 +139,20 @@ public sealed class UserContextProbe
$result['collectedat'] = (Get-Date).ToUniversalTime().ToString('o')
$result['username'] = $env:USERNAME
# Stamp the token's elevation state. The username alone does not
# settle it: the SAME account elevated and unelevated gets two
# different logon tokens and therefore two different drive tables,
# so a spool from an elevated ShopFloor session passes the account
# check while still reporting every drive as broken.
try {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$result['elevated'] = [bool] $principal.IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
} catch {
$result['elevatederror'] = $_.Exception.Message
}
# Write to a temporary name and rename into place, so the reader can
# never observe a half-written file and discard a good result.
$result | ConvertTo-Json -Depth 4 -Compress |
@@ -144,6 +164,14 @@ public sealed class UserContextProbe
exit 0
""";
// Substitute the resolved spool directory into the template. The path goes in
// as a PowerShell single-quoted literal, so an embedded quote is doubled and
// nothing else in the path is interpreted - backslashes and $ are literal
// inside single quotes, which is exactly what a Windows path needs.
private static string BuildProbeScript(string spoolDirectory)
=> ProbeScriptTemplate.Replace(
SpoolDirectoryToken, spoolDirectory.Replace("'", "''"));
// How long the probe is allowed to take. Generous: this no longer runs on
// the telemetry path, so an unresponsive WMI call delays nothing but itself.
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60);
@@ -160,15 +188,42 @@ public sealed class UserContextProbe
}
// Run the probe in THIS process's session and write the spool file.
// Returns true when the probe wrote a result.
// Returns true only when the spool file is present on disk afterwards.
//
// Called from the --collect-usercontext entry point, which the tray runs as
// the signed-in operator. Never throws: this is background collection and a
// failure must not take the tray or the agent process down with it.
public bool Collect()
{
var spoolDirectory = UserContextSpool.PinnedSpoolDirectory();
var resultPath = UserContextSpool.PinnedResultPath();
try
{
// Logged before the launch, not after. When this goes wrong the
// interesting question is always "which directory did it target",
// and a failure later in this method must not take the answer with
// it.
logger.LogInformation(
"Running the user-context probe as {User}; spool target {Path}",
Environment.UserName, resultPath);
// The probe writes a .tmp beside the target and moves it into place,
// so the directory has to exist before powershell.exe starts. The
// script creates it too, but doing it here means a permission problem
// is reported by the agent with a real exception rather than showing
// up as an opaque non-zero exit code.
try
{
Directory.CreateDirectory(spoolDirectory);
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
logger.LogWarning(error,
"Could not create the user-context spool directory at {Directory}",
spoolDirectory);
return false;
}
// Passed as -EncodedCommand rather than staged as a .ps1 and run with
// -File. The script used to be written into %TEMP% and executed from
// there, which put a dependency on that file still existing, and still
@@ -187,7 +242,7 @@ public sealed class UserContextProbe
// in the command line itself, so there is no file to lose and no
// execution-policy or quarantine step between us and the probe.
var encoded = Convert.ToBase64String(
System.Text.Encoding.Unicode.GetBytes(ProbeScript));
System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory)));
var startInfo = new System.Diagnostics.ProcessStartInfo
{
@@ -231,6 +286,28 @@ public sealed class UserContextProbe
return false;
}
// Exit code 0 is necessary but NOT sufficient. The probe used to be
// trusted on its exit code alone, which meant a run that wrote its
// file into a different profile entirely still reported success - the
// log claimed a spool had been written for weeks while nothing was
// ever there. Confirm the file exists where the reader will look.
if (!File.Exists(resultPath))
{
logger.LogWarning(
"The user-context probe reported success but no spool file is "
+ "present at {Path}.", resultPath);
return false;
}
// Byte count alone does not distinguish a healthy spool from a
// well-formed empty one, and "0 drives" is the exact symptom this
// whole path exists to diagnose. Summarising the contents means a
// single log line from any given task run answers whether the probe
// saw the operator's drives, without needing the spool itself.
var summary = SummariseSpool(resultPath);
logger.LogInformation(
"The user-context probe wrote {Bytes} bytes to {Path} as {User}: {Summary}",
new FileInfo(resultPath).Length, resultPath, Environment.UserName, summary);
return true;
}
catch (Exception exception)
@@ -240,6 +317,37 @@ public sealed class UserContextProbe
}
}
// One-line description of what the probe actually captured, for the run log.
// Best-effort by design: this is diagnostic colour on an already-successful
// run, so a parse problem here must never turn that run into a failure.
private static string SummariseSpool(string resultPath)
{
try
{
using var document = System.Text.Json.JsonDocument.Parse(
File.ReadAllText(resultPath));
var root = document.RootElement;
static int CountOf(System.Text.Json.JsonElement root, string name) =>
root.TryGetProperty(name, out var value)
&& value.ValueKind == System.Text.Json.JsonValueKind.Array
? value.GetArrayLength()
: 0;
var user = root.TryGetProperty("username", out var stamped)
? stamped.GetString()
: "(unstamped)";
return $"user={user} netuse={CountOf(root, "netuse")} "
+ $"smbmapping={CountOf(root, "smbmapping")} "
+ $"listeners={CountOf(root, "userlisteners")}";
}
catch (Exception)
{
return "(could not summarise)";
}
}
private void TryKill(System.Diagnostics.Process process)
{
try

View File

@@ -25,20 +25,38 @@ public sealed class UserContextSpool
public const string SpoolFolderName = "ShopDB";
public const string ResultFileName = "usercontext.json";
// The one definition of where the spool lives. Both the probe (writer) and
// this class (reader) call it, so the two can no longer disagree.
//
// The writer used to build its own path from $env:USERPROFILE while the
// reader used the pinned ShopFloor profile. On a locked-down image those are
// not the same directory - the tray is launched through explorer.exe by an
// elevated installer and can inherit another account's environment block, as
// the tray already documents for %TEMP%. The probe then wrote a perfectly
// good file into the wrong profile, exited 0, and the service found nothing:
// no spool ever appeared and no mapped drive was ever reported.
public static string PinnedSpoolDirectory()
=> Path.Combine(FileLoggerProvider.ShopFloorProfileDirectory(), SpoolFolderName);
public static string PinnedResultPath()
=> Path.Combine(PinnedSpoolDirectory(), ResultFileName);
// A probe result is small - a handful of mapped drives and listeners. A file
// larger than this is either corrupt or hostile, and we will not parse it.
private const long MaxResultBytes = 512 * 1024;
// A result older than this is treated as absent. Sized against the server's
// default quiet-report window (endpoints_quietreporthours, 24h): an endpoint
// that has not reported inside that window is already shown as 'quiet', so
// session data that outlived it would be claiming freshness the rest of the
// page no longer claims. Well clear of the tray's collection interval, so an
// ordinary missed run does not blank the card.
// A result older than this is treated as absent. Sized at roughly three tray
// probe intervals (30 min each), so a single missed run never blanks the card
// but genuinely stale session data retires quickly.
//
// This was 24h when the reader deleted the file after each read; the delete
// was doing the real retiring and the age check was only a backstop. Now that
// the spool persists, this value IS the staleness bound, so it is sized
// against the writer's cadence rather than the server's quiet-report window.
//
// Not read from the server: this is an offline-safe local bound, and the
// agent must retire stale session data even when it cannot reach ShopDB.
private static readonly TimeSpan MaxResultAge = TimeSpan.FromHours(24);
private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90);
private readonly ILogger<UserContextSpool> logger;
@@ -67,7 +85,7 @@ public sealed class UserContextSpool
if (Directory.Exists(pinned))
{
reason = null;
return Path.Combine(pinned, SpoolFolderName, ResultFileName);
return PinnedResultPath();
}
var profileDirectory =
@@ -161,6 +179,12 @@ public sealed class UserContextSpool
if (IsExpired(parsed, resultPath))
return null;
if (IsWrongAccount(parsed))
return null;
if (IsElevatedCollection(parsed))
return null;
return parsed;
}
catch (JsonException exception)
@@ -179,36 +203,92 @@ public sealed class UserContextSpool
}
}
// Read the spool and then REMOVE it, so the next report can only carry a
// result the tray produced after this read. Leaving a consumed file in place
// means a tray that has stopped running keeps re-reporting the same drives
// as though they were current, which is worse than reporting nothing: the
// card looks healthy while the data is frozen.
// Read the spool WITHOUT removing it. The probe overwrites the file on every
// run (write .tmp, move into place), so the newest result always replaces the
// previous one and there is nothing for the reader to clean up.
//
// The removal is deliberately AFTER a successful parse. If the file was
// mid-write (truncated JSON) it is left alone rather than destroyed, so a
// read that raced the tray costs one cycle instead of a good result. The
// collectedat age check still applies as a second bound for the window
// between the tray writing and the service next waking up.
// This used to delete the file after a successful read, to stop a dead tray
// re-reporting the same drives forever. The collectedat age check already
// does that job, and deleting created a worse problem: lastUserContext in the
// collector is in-memory only, so a service restart wiped the cache while the
// spool had already been consumed. The agent then reported NO mapped drives
// until the tray's next probe - and the installer restarts the service and
// the tray on every install, so a fresh install was guaranteed to show an
// empty card at exactly the moment someone looked at it.
//
// The contents are the operator's own session data, visible to them in their
// own context, so leaving the file in place costs nothing.
public Dictionary<string, object?>? ConsumeResult(string resultPath)
=> TryReadResult(resultPath);
// Reject a spool that some other account produced.
//
// Pinning the path guarantees the file lands where the reader looks, but it
// cannot guarantee WHO ran the probe. Mapped drives and idle time are per
// logon session, so a probe that ran as an administrator (for example a tray
// that inherited an elevated installer's environment) would enumerate that
// account's drive table and write it into the operator's spool. That is worse
// than reporting nothing, because the card would look populated and correct
// while showing drives the operator does not have.
//
// The probe stamps $env:USERNAME into the file, so the mismatch is checkable.
// Logged at warning, not debug: this is a misconfigured endpoint, not a quiet
// absence of data.
private bool IsWrongAccount(Dictionary<string, object?> parsed)
{
var parsed = TryReadResult(resultPath);
if (parsed is null)
return null;
try
if (!parsed.TryGetValue("username", out var raw) || raw is null)
{
File.Delete(resultPath);
}
catch (Exception exception)
{
// Non-fatal: the value was already read. It does mean the next cycle
// may re-read the same result, which the age check will retire.
logger.LogWarning(exception,
"Read the user-context spool but could not remove it at {Path}", resultPath);
logger.LogWarning(
"The user-context spool has no username stamp; ignoring it because "
+ "the session it describes cannot be confirmed.");
return true;
}
return parsed;
var username = raw.ToString();
if (string.Equals(username, FileLoggerProvider.ShopFloorUserName,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
logger.LogWarning(
"The user-context spool was written by {Actual}, not {Expected}; "
+ "ignoring it. Session data is per logon session, so this would "
+ "report the wrong account's mapped drives.",
username, FileLoggerProvider.ShopFloorUserName);
return true;
}
// An elevated probe runs under a different logon token to the operator's
// interactive one and enumerates a different, usually empty, drive table.
// The agent refuses to collect while elevated, so a spool stamped elevated
// should not exist; this rejects any that predates that guard or arrives
// from a hand-run --collect-usercontext in an admin shell.
//
// Note the asymmetry with IsWrongAccount: a MISSING stamp is accepted here
// rather than rejected. An unstamped spool is simply one written by an older
// agent, and treating that as elevated would blank out session data across a
// fleet mid-upgrade. An unstamped spool has already had to pass the username
// check to reach this point.
private bool IsElevatedCollection(Dictionary<string, object?> parsed)
{
if (!parsed.TryGetValue("elevated", out var raw) || raw is null)
return false;
var elevated = raw is JsonElement element
? element.ValueKind == JsonValueKind.True
: string.Equals(raw.ToString(), "true", StringComparison.OrdinalIgnoreCase);
if (!elevated)
return false;
logger.LogWarning(
"The user-context spool was collected by an ELEVATED process; ignoring "
+ "it. Mapped drives are per logon token, so an elevated probe reports "
+ "the wrong drive table even under the right account. Collection should "
+ "come from the 'ShopDB user-context probe' scheduled task, which runs "
+ "at RunLevel Limited.");
return true;
}
// Age is taken from the stamp the probe wrote, not from the file's last

View File

@@ -27,8 +27,34 @@
anywhere on the machine. The tray writes
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log (the operator's
own profile; it cannot write to ProgramData or Program Files) covering
tray startup, agent-exe resolution, probe launch and probe exit code. -->
<Version>1.1.1</Version>
tray startup, agent-exe resolution, probe launch and probe exit code. -->
<!-- 1.2.0: the tray stops claiming success it did not verify. The probe
completion message printed a hardcoded spool path regardless of what
happened, so a probe that exited 0 without writing anything read as a
clean collection; it now reports the real pinned path and distinguishes
spool-present, exit-0-but-no-file, and non-zero exit. Startup probe
delay is chosen from session uptime: the 2-minute delay still applies at
logon (drive mappings are not established yet), but a tray started into
an already-running session - which is what an installer does - probes
after 15 seconds instead of leaving a fresh install blind for the full
30-minute cycle. A log sink that cannot open its directory raises a
one-time balloon, since it cannot report its own failure through the
log. Requires agent 1.9.0: the two carry the shared pinned spool path
and must be deployed together. -->
<!-- 1.3.0: the tray is no longer the authoritative user-context collector.
The 'ShopDB user-context probe' scheduled task now owns collection,
because it is the only mechanism that runs in the operator's own logon
session at RunLevel Limited; the tray keeps its own probe cadence as
belt-and-braces and writes the same spool. The installer no longer
starts the tray directly (explorer.exe de-elevated it but did not
change ACCOUNT, so an admin install produced an admin tray whose
probe saw the wrong drive table). Autostart at logon is the Run key;
install-time startup on a machine someone is already signed in to is
a transient 'ShopDB tray first launch' scheduled task, which is the
only way to enter the operator's existing session unelevated. Probe
exit code 2 - the agent refusing to collect while elevated - is
reported by name instead of as a bare code. Requires agent 1.10.1. -->
<Version>1.3.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -42,6 +42,19 @@ internal sealed class TrayContext : ApplicationContext
// picture while competing with everything else starting up.
private static readonly TimeSpan UserContextStartupDelay = TimeSpan.FromMinutes(2);
// ...but that reasoning only applies at logon. When the tray starts into a
// session that has been up for a while - which is what an installer restart
// or a manual relaunch does - nothing is settling and the full delay just
// extends the window where the server has no session data. The installer
// kills and restarts the tray on every install, so the 2-minute delay was
// being paid at exactly the moment someone was watching the card to see
// whether the install had worked.
private static readonly TimeSpan UserContextEstablishedDelay = TimeSpan.FromSeconds(15);
// How long the session must have been up to count as established. Comfortably
// past the logon storm, well short of any realistic install-to-check gap.
private static readonly TimeSpan EstablishedSessionUptime = TimeSpan.FromMinutes(5);
private readonly System.Windows.Forms.Timer userContextTimer;
private bool userContextStarted;
private bool collectingUserContext;
@@ -59,13 +72,14 @@ internal sealed class TrayContext : ApplicationContext
{
logProvider = new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent",
stampFirst: true);
filePrefix: "trayagent");
logger = logProvider.CreateLogger("Tray");
var startupDelay = ResolveStartupDelay();
logger.LogInformation(
"Tray {Version} started; user-context probe in {Delay}, then every {Interval}.",
typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown",
UserContextStartupDelay, UserContextInterval);
startupDelay, UserContextInterval);
var menu = new ContextMenuStrip();
menu.Items.Add("Send Status now", null, OnSendStatus);
@@ -86,9 +100,25 @@ internal sealed class TrayContext : ApplicationContext
};
trayIcon.DoubleClick += OnSendStatus;
// A dead log sink cannot report itself through the log. Complaining via
// the logger would write the one message that explains the silence into
// the sink that is swallowing messages, so the balloon is the only
// channel that survives. Shown once at startup and never repeated: this
// is a "your diagnostics are blind" notice for whoever is standing at the
// machine, not a recurring nag about something the operator cannot fix.
if (logProvider.SinkProblem is not null)
{
trayIcon.ShowBalloonTip(
10000,
"ShopDB Endpoint Agent",
"Tray logging is unavailable, so tray diagnostics will not be "
+ $"recorded. {logProvider.SinkProblem}",
ToolTipIcon.Warning);
}
userContextTimer = new System.Windows.Forms.Timer
{
Interval = (int)UserContextStartupDelay.TotalMilliseconds,
Interval = (int)startupDelay.TotalMilliseconds,
};
userContextTimer.Tick += OnUserContextTimer;
userContextTimer.Start();
@@ -126,17 +156,44 @@ internal sealed class TrayContext : ApplicationContext
{
logger.LogInformation("Running the user-context probe: {Agent}", agentExe);
var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext");
if (exitCode == 0)
// Check the disk rather than announcing what the exit code implies.
// This line used to print a hardcoded path whenever the child exited
// 0, so it reported "spool written to C:\Users\ShopFloor\ShopDB" for
// weeks while the probe was actually writing into a different profile
// and the service was finding nothing.
var resultPath = UserContextSpool.PinnedResultPath();
if (exitCode == 0 && File.Exists(resultPath))
{
logger.LogInformation(
"The user-context probe finished; spool written to {Spool}.",
Path.Combine(
FileLoggerProvider.ShopFloorProfileDirectory(), "ShopDB"));
"The user-context probe finished; spool present at {Spool}.",
resultPath);
}
else if (exitCode == 0)
{
logger.LogWarning(
"The user-context probe reported success but no spool file is "
+ "present at {Spool}.", resultPath);
}
else if (exitCode == 2)
{
// The agent refused because it was elevated. Called out by name
// because as a bare exit code it reads like a probe fault and
// sends the reader looking for a drive or WMI problem that is
// not there - the fix is to start the tray unelevated, or let
// the scheduled task do the collecting.
logger.LogWarning(
"The user-context probe refused to run because this tray is "
+ "ELEVATED. Mapped drives are per logon token, so an elevated "
+ "probe would report the wrong drive table. Start the tray "
+ "unelevated, or rely on the 'ShopDB user-context probe' "
+ "scheduled task.");
}
else
{
logger.LogWarning(
"The user-context probe exited with code {ExitCode}.", exitCode);
"The user-context probe exited with code {ExitCode}; expected a "
+ "spool at {Spool}.", exitCode, resultPath);
}
}
catch (Exception exception)
@@ -353,6 +410,35 @@ internal sealed class TrayContext : ApplicationContext
startInfo.Environment.Remove(key);
}
// Full delay at logon, short delay when the session is already established.
//
// Environment.TickCount64 is machine uptime, not session uptime, which is a
// deliberate simplification: the two differ only when someone signs out and
// back in without rebooting, and treating that as "established" is harmless -
// the profile is already built and the drives are already mapped in that
// case too. A failure to read it falls back to the conservative long delay.
private TimeSpan ResolveStartupDelay()
{
try
{
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
if (uptime >= EstablishedSessionUptime)
{
logger.LogInformation(
"Session has been up {Uptime}; using the established-session probe delay.",
uptime);
return UserContextEstablishedDelay;
}
}
catch (Exception exception)
{
logger.LogDebug(exception,
"Could not read system uptime; using the logon probe delay.");
}
return UserContextStartupDelay;
}
// Same directory first (the installer ships the tray next to the agent),
// then the known Program Files install directory as a fallback.
private static string? ResolveAgentExe()

View File

@@ -29,10 +29,103 @@ var reportPayloadPath = ArgValue(args, "--report");
var agentVersion = Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "0.0.0";
// FIRST, ahead of everything else. This verb runs as the signed-in operator (the
// tray invokes it) and must not touch a single privileged resource on the way in.
//
// It used to sit below the bootstrap logger, the fault monitor, and the config
// load, which meant the operator-context process opened the SYSTEM-owned
// ProgramData log directory, attempted an EventLog source registration that only
// an administrator can perform, and read CollectorKey out of HKLM - all before it
// discovered it was only being asked to write a file into the user's own profile.
// On an EPM-managed host (CyberArk) those are exactly the calls a broker
// intercepts, and the registry read put the collector secret in a process that
// has no business holding it.
//
// It also has no config dependency by design: it only writes into the caller's
// own profile and never talks to the server, so it must still work on a machine
// whose CollectorKey has not been provisioned yet - otherwise session data would
// be missing on precisely the endpoints that are hardest to diagnose.
if (collectUserContext)
{
// First statement in this branch. The scheduled task runs this exe directly
// in the operator's interactive session, and shopdbagent.exe is a console
// app, so without this a black window flashes up on every collection cycle.
// Done before any logging or probing so the window is gone immediately
// rather than lingering for the life of the probe.
ConsoleWindow.Hide();
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
// A non-privileged user cannot write to the SYSTEM-owned log directory, and
// the file sink degrades to a no-op when it cannot create its directory, so
// pointing this at the service location meant every probe failure was
// discarded unread - which is exactly how a broken probe looked identical to
// a probe that was never invoked.
using var probeLogProvider = new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent");
using var probeLoggerFactory = LoggerFactory.Create(
logging => logging.AddProvider(probeLogProvider));
var probeLogger = probeLoggerFactory.CreateLogger<UserContextProbe>();
ReportSinkProblem(probeLogProvider, "user-context probe");
// Refuse to collect from an elevated process. Drive letters are a property
// of the logon token, not the machine: an elevated process gets a different
// token to the operator's interactive one and enumerates a DIFFERENT, almost
// always empty, drive table. Measured on a live endpoint, the same two
// shares reported Unavailable from the elevated shell and OK from the
// operator's, in both Win32_NetworkConnection and Get-SmbMapping.
//
// Refusing here rather than filtering later is deliberate. An elevated probe
// does not fail - it succeeds and reports every mapped drive as broken,
// which is indistinguishable from a genuine outage at the far end. Better to
// write nothing and say why than to publish a confident wrong answer.
if (IsProcessElevated())
{
probeLogger.LogError(
"Refusing to collect user context: this process is elevated. Mapped drives " +
"are per-logon-token, so an elevated probe reports the wrong drive table. " +
"Run the '{Task}' scheduled task (RunLevel Limited) instead of invoking " +
"--collect-usercontext from an admin shell.",
"ShopDB user-context probe");
Console.Error.WriteLine(
"shopdbagent: refusing to collect user context from an elevated process; " +
"mapped drives would be wrong. Use the scheduled task.");
return 2;
}
var probe = new UserContextProbe(probeLogger);
return probe.Collect() ? 0 : 1;
}
// True when the current process holds the elevated (high-integrity) token.
static bool IsProcessElevated()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
try
{
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}
catch
{
// Cannot tell. Treat as not elevated so an unreadable token does not
// suppress collection on a host where the probe would have worked; the
// spool's own account stamp is still checked on the way back in.
return false;
}
}
// Bootstrap logger first so config loading (including the registry read that
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
ReportSinkProblem(bootstrapFileLogger, "service");
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
faultMonitor.Install();
@@ -43,30 +136,6 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
? FileLoggerProvider.DefaultDirectory()
: config.LogPath;
// Deliberately ahead of the collector config gate. This verb only writes a file
// into the caller's own profile and never talks to the server, so it must still
// work on a machine whose CollectorKey has not been provisioned yet - otherwise
// session data would be missing on exactly the endpoints that are hardest to
// diagnose.
if (collectUserContext)
{
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
// This verb always runs as the signed-in user (the tray invokes it), and a
// non-privileged user cannot write to the SYSTEM-owned log directory. The
// file sink degrades to a no-op when it cannot create its directory, so
// pointing this at the service location meant every probe failure was
// discarded unread - which is exactly how a broken probe looked identical to
// a probe that was never invoked.
using var probeLoggerFactory = LoggerFactory.Create(logging =>
logging.AddProvider(new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent",
stampFirst: true)));
var probe = new UserContextProbe(
probeLoggerFactory.CreateLogger<UserContextProbe>());
return probe.Collect() ? 0 : 1;
}
if (!config.IsUsable())
{
bootstrapLogger.LogError(
@@ -226,12 +295,15 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
// Session-scoped collection. The agent is LocalSystem, so mapped drives and
// user-owned listeners are invisible to it. The tray, which already runs as
// the operator, writes the spool file; the service only reads it.
//
// UserContextProbe is deliberately NOT registered here. The service must
// never run the probe: descending into the session with CreateProcessAsUser
// is the reverted design, and leaving a resolvable dependency in the
// container is an invitation to reintroduce it. The probe is constructed
// only on the --collect-usercontext path, which the tray invokes.
services.AddSingleton(sp => new UserContextSpool(
sp.GetRequiredService<ILogger<UserContextSpool>>()));
services.AddSingleton(sp => new UserContextProbe(
sp.GetRequiredService<ILogger<UserContextProbe>>()));
services.AddSingleton<ICollector>(sp => new EndpointCollector(
sp.GetRequiredService<ILogger<EndpointCollector>>(),
config,
@@ -274,3 +346,22 @@ static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
logging.AddProvider(new FileLoggerProvider(logDirectory));
logging.SetMinimumLevel(LogLevel.Information);
}
// Say so, loudly, when a file sink could not open its directory.
//
// FileLoggerProvider never throws on construction and silently drops lines when
// its directory is unusable. That is the right behaviour - logging must not take
// the agent down - but it made two different failures indistinguishable: a probe
// that ran and failed, and a probe whose log had nowhere to go. The provider
// records the reason; this is the part that was missing, the caller actually
// asking for it. stderr is the only sink guaranteed to exist at this point, so
// that is where the complaint goes.
static void ReportSinkProblem(FileLoggerProvider provider, string which)
{
if (provider.SinkProblem is null)
return;
Console.Error.WriteLine(
$"shopdbagent: the {which} log sink is unusable and log lines will be "
+ $"dropped - {provider.SinkProblem}");
}

View File

@@ -7,6 +7,47 @@
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.10.1: the collect-usercontext verb hides its console window. The agent
is a console exe, which the tray used to mask by launching the probe with
CreateNoWindow; the scheduled task runs the exe directly in the operator's
session, so a black window appeared on every 30-minute collection. -->
<!-- 1.10.0: user context is collected by a scheduled task in the operator's
own logon session, not by a process the elevated installer launches.
Drive letters are per logon token: an elevated probe enumerates a
different, usually empty, drive table, so an install performed by an
admin produced a spool reporting every mapped drive as Unavailable while
the operator's own session showed OK. The installer no longer starts the
tray via explorer.exe (which de-elevates but does NOT change account) and
instead registers 'ShopDB user-context probe' against BUILTIN\Users at
RunLevel Limited, verifies it by reading the task back, and records the
outcome in the install ledger. The collect-usercontext verb now refuses
to run elevated (exit 2) rather than writing a confident wrong answer,
the probe stamps its token elevation, and the service rejects any spool
stamped elevated. Probe runs log the drive and listener counts. -->
<!-- 1.9.0: the spool is finally written where the service looks for it. The
probe script resolved its output directory from $env:USERPROFILE while
the reader used the pinned C:\Users\ShopFloor path; under a brokered or
re-parented tray launch those differ, so the probe reported success and
wrote the file somewhere the service never read. Both sides now derive
the path from one helper (UserContextSpool.PinnedResultPath). Collect()
verifies the file exists on disk before reporting success, because a
zero exit code from powershell.exe proved nothing about the write.
Minor rather than patch because the spool handoff itself changed shape
(AGENT-CONTRACT.md 5): the spool is now PERSISTED and overwritten each
run rather than consumed on read. The collector cache is in-memory only,
so consume-on-read plus a service restart blanked mapped drives until the
next tray cycle. Max age tightened 24h -> 90m now that staleness, not
deletion, is the retirement mechanism. A spool whose username stamp is
absent or not ShopFloor is rejected outright - wrong-session drive data
is worse than none.
The collect-usercontext branch moved to the top of Program.cs: it used to
open the SYSTEM-owned log directory, register an EventLog source, and
read CollectorKey out of HKLM before discovering it only needed to write
a file into the caller's own profile - all as the operator, and all
exactly what an EPM broker intercepts. Log filenames unified on
<date>-<component>.log across service, tray, and installer. A log sink
that cannot open its directory now says so on stderr instead of
discarding every line unread. Pairs with tray 1.2.0. -->
<!-- 1.8.1: user-context logging and spool location are split from the
service's. The probe runs as the signed-in operator but logged into
%ProgramData%\ShopDB\EndpointAgent\logs, which is SYSTEM-owned and not
@@ -90,7 +131,7 @@
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
1.1.0: generic, data-driven service_*/process_* actions carry their
target over the wire, so new policies/apps need no agent rebuild. -->
<Version>1.8.1</Version>
<Version>1.10.1</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -1,437 +0,0 @@
# install-shopdbagent.ps1
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
# the agent files to Program Files, runs a one-shot smoke test, then installs
# and starts the Windows service.
#
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
# values are also stamped into the installed appsettings.json as a fallback.
#
# The agent and tray are published as single-file self-contained executables,
# so copying the two .exe files plus appsettings.json is the complete payload.
[CmdletBinding()]
param(
# ShopDB server root URL for this instance. REQUIRED - there is no default,
# because a default here means one site's hostname shipped to every site
# (ADR-015) and, worse, a working install pointed at somebody else's server.
#
# https is required. The collector key travels in the X-API-Key header and
# every telemetry payload follows it, so plain http puts both on the wire in
# clear. Pass -AllowInsecureHttp only for a local test instance.
[Parameter(Mandatory = $true)]
[string] $BaseUrl,
# Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS.
# REQUIRED and never defaulted - a key in source is a key in git history, and
# this one is also the command channel's kill switch.
[Parameter(Mandatory = $true)]
[string] $CollectorKey,
# Permit an http:// BaseUrl. For a local test instance only.
[switch] $AllowInsecureHttp,
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
# Interactive tray app that shows the system-tray icon and the on-demand
# "Send Status now" action. Separate process from the SYSTEM service (a
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[switch] $NoTray,
# Add a Microsoft Defender path exclusion for the install directory. The
# agent and tray are unsigned single-file self-contained executables, which
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
# a false positive and quarantine, killing the tray before its icon appears.
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
# centrally or the binaries are code-signed).
[switch] $NoDefenderExclusion
)
# Refuse cleartext unless it was asked for explicitly. The key is a bearer
# credential for the whole fleet's telemetry and command channel.
if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) {
throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance."
}
$ErrorActionPreference = "Stop"
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
function Assert-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}
}
Assert-Admin
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}
# Write HKLM values through the explicit 64-bit registry view. If this script
# runs under 32-bit PowerShell (some management tooling launches x86), the
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
# the view removes that dependency on the launching shell's bitness.
function Set-Hklm64Value {
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Value
)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.CreateSubKey($SubKey)
try {
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
}
finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Tear down any previously installed agent BEFORE touching files. The agent and
# tray are single-file self-contained exes; while the old service or tray is
# running it holds those .exe files open, so Copy-Item over them fails with a
# file-in-use error. Stopping the service, deleting it, and killing any lingering
# agent/tray processes up front makes the install a clean overwrite. Best-effort
# throughout: a missing service or process is the normal fresh-install case.
function Uninstall-PreviousAgent {
param(
[Parameter(Mandatory)] [string] $Service,
[Parameter(Mandatory)] [string] $TrayExe,
[Parameter(Mandatory)] [string] $TargetDir
)
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " stopping and removing existing service $Service ..."
if ($existing.Status -ne "Stopped") {
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
}
# sc.exe delete only marks the service for deletion; the SCM finishes the
# removal once every handle closes. Give it a moment so the subsequent
# sc.exe create does not hit a "marked for deletion" (1072) error.
sc.exe delete $Service | Out-Null
Start-Sleep -Seconds 2
}
# Kill any lingering worker or tray processes that would keep the exes
# locked. Match by the installed exe base names; the service host process is
# shopdbagent, the tray is the TrayExe base name.
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
foreach ($name in @("shopdbagent", $trayBase)) {
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
foreach ($p in $procs) {
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Also kill anything still running out of the target install directory,
# regardless of exe name. A tray from an older build (different exe name) or
# one launched from a stale path would otherwise survive the name-based sweep
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
# the fresh tray. Match on the executable path so we only touch our own dir.
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
$path = $null
try { $path = $p.Path } catch { $path = $null }
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Brief settle so file handles are released before the copy overwrites them.
Start-Sleep -Milliseconds 500
}
# Installer transcript. Deliberately SEPARATE from both the service log
# (%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log) and the tray log
# (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log): the installer runs
# elevated and one-shot, and mixing its output into the service's rolling log
# made an install indistinguishable from runtime behaviour when reading back a
# failure. This runs as admin, so ProgramData is writable here.
$installLogDir = Join-Path $env:ProgramData 'ShopDB\Logs'
try {
New-Item -ItemType Directory -Path $installLogDir -Force | Out-Null
$installLog = Join-Path $installLogDir (
'{0}-shopdbinstalls.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
Start-Transcript -Path $installLog -Append | Out-Null
$transcriptStarted = $true
}
catch {
# Never let logging block an install.
$transcriptStarted = $false
Write-Warning "could not start the install transcript: $($_.Exception.Message)"
}
# The shop-floor operator profile. The tray and the user-context probe run as
# this local account on every shop-floor endpoint, and it is the one place both
# the operator (writer) and the SYSTEM service (reader) can reach. Defined here
# so the install can provision the directories and report their location.
$shopFloorProfile = Join-Path (Join-Path $env:SystemDrive 'Users') 'ShopFloor'
# Stop the transcript on ANY terminating error, not just the success path. The
# script throws on several validation and smoke-test failures, and those are
# precisely the installs worth reading back afterwards - an orphaned transcript
# would truncate exactly the failure it was started to capture.
trap {
if ($transcriptStarted) {
try { Stop-Transcript | Out-Null } catch { }
$script:transcriptStarted = $false
}
break
}
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
if ($transcriptStarted) { Write-Host "InstallLog : $installLog" }
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
# typically extracts installer.zip into Downloads, so every file carries a
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
# "Windows protected your PC" the first time they run - including the [3/5]
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
# installed copy exists. Unblocking the source folder up front keeps the smoke
# test (and any manual run from the extract folder) clean; the installed copies
# get unblocked again in step 2a after they land in Program Files. Best-effort:
# a file with no MotW is a no-op.
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
# and does not fail on exes locked by a running service or tray.
Write-Host "`n[0/5] Removing any previously installed agent ..."
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
# 1a. Provision the user-context spool and tray log directories in the operator
# profile, and grant the operator Modify on them.
#
# This is the handoff point between the two security contexts (AGENT-CONTRACT.md
# section 5): the tray WRITES here as the ShopFloor operator, and the SYSTEM
# service READS and then removes the spool. The operator cannot write to
# Program Files or to the SYSTEM-owned ProgramData log directory, and the file
# log sink degrades to a silent no-op when it cannot create its directory - so
# without this step a tray-side failure leaves no trace anywhere on the machine.
# Creating them here, elevated, means the directories exist and are writable
# before the tray ever starts.
Write-Host "[1a/5] Provisioning operator spool/log directories under $shopFloorProfile ..."
if (Test-Path $shopFloorProfile) {
foreach ($dir in @(
(Join-Path $shopFloorProfile 'ShopDB'),
(Join-Path $shopFloorProfile 'ShopDB\Logs'))) {
try {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$acl = Get-Acl $dir
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
'ShopFloor', 'Modify',
'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.SetAccessRule($rule)
Set-Acl -Path $dir -AclObject $acl
Write-Host " provisioned $dir (ShopFloor: Modify)"
}
catch {
# Non-fatal: telemetry, commands and inventory are unaffected. Only
# user-context (mapped drives, idle time) depends on this path.
Write-Warning " could not provision $dir : $($_.Exception.Message)"
}
}
}
else {
Write-Warning " no ShopFloor profile at $shopFloorProfile; user-context (mapped drives, idle time) will be unavailable until that user has logged in once."
}
# 2. Copy agent files to the install directory. The agent and tray are
# single-file self-contained executables, so the .exe plus appsettings.json is
# the whole payload; there are no side-by-side runtime DLLs to copy.
Write-Host "[2/5] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
if (-not (Test-Path $agentSource)) {
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
}
Copy-Item -Path $agentSource -Destination $InstallDir -Force
$appsettings = Join-Path $sourceDir "appsettings.json"
$installedSettings = Join-Path $InstallDir "appsettings.json"
if (Test-Path $appsettings) {
Copy-Item -Path $appsettings -Destination $InstallDir -Force
}
# Copy the interactive tray app next to the agent so its same-dir resolution
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
# service-only (headless) build.
$trayInstalled = $null
if (-not $NoTray) {
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {
Copy-Item -Path $traySource -Destination $InstallDir -Force
$trayInstalled = Join-Path $InstallDir $TrayExeName
Write-Host " copied tray app $TrayExeName"
}
else {
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
}
}
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
# hive so the agent cannot READ the values the registry step just wrote. Stamp
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
# appsettings first, then lets the registry override it, so this is a safe
# no-op where the registry read works and the rescue where it does not.
if (Test-Path $installedSettings) {
try {
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
$json.baseurl = $BaseUrl
$json.apikey = $CollectorKey
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
}
catch {
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
}
}
$exePath = Join-Path $InstallDir "shopdbagent.exe"
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
# your PC" the first time it runs, and the operator sees only a "Don't run"
# button. Unblock-File removes the ADS so the smoke test and the tray launch
# start clean. Best-effort: a file with no MotW is a no-op.
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 2b. Defender path exclusion for the install dir. Unsigned single-file
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
# heuristics and get quarantined, which is why the tray dies before showing an
# icon. Excluding the fixed install directory is the least-privilege fix short
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
# Defender and can be blocked by tamper protection; a failure is not fatal.
if (-not $NoDefenderExclusion) {
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
try {
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
Write-Host " excluded $InstallDir from Defender scanning."
}
catch {
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
}
}
else {
Write-Host " Defender cmdlets not present; skipping exclusion."
}
}
# 3. One-shot smoke test (exact production collection path, then exits).
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
Write-Host " smoke test exit code: $($proc.ExitCode)"
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
if (Test-Path $logFile) {
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
if ($proc.ExitCode -ne 0) {
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
}
# 4. Install the Windows service. Any previous instance was already stopped,
# deleted, and its processes killed in step 0, so this is a clean create. Guard
# against the rare case where the SCM has not finished a pending deletion yet.
Write-Host "[4/5] Installing service $ServiceName ..."
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " service still present (pending deletion); removing again ..."
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
$binPath = '"' + $exePath + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
Start-Service -Name $ServiceName
Write-Host " service started."
# 5. Register the interactive tray app to launch at user logon (per-machine).
Write-Host "[5/5] Registering tray app for logon ..."
if ($trayInstalled -and (Test-Path $trayInstalled)) {
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
# Start it now so the icon appears without waiting for the next logon. The
# Run key only fires at logon, so a fresh install would otherwise show no
# tray until the operator signs out and back in.
#
# The installer runs elevated, so a direct Start-Process would launch the
# tray ELEVATED too. That is wrong for a tray app (it should run at the
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
# unsigned exe triggers a broker prompt that comes back as Win32 1223
# "The operation was canceled by the user" - which is exactly what we hit.
# Launching through explorer.exe hands the exe to the already-running user
# shell, which spawns it de-elevated in the interactive session, sidestepping
# the elevation broker entirely.
$interactiveUser = $env:USERNAME
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
try {
# explorer.exe <path> re-parents the launch under the user shell at
# medium integrity; it returns immediately regardless of the tray's
# own lifetime, so there is nothing to wait on here.
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
Write-Host " tray app started for the current session (de-elevated via explorer)."
}
catch {
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
}
}
else {
Write-Host " no interactive user session; tray will start at next logon."
}
}
else {
Write-Host " tray skipped (headless install)."
}
Write-Host "`nInstall complete." -ForegroundColor Green
Write-Host "Service '$ServiceName' is running and set to auto-start."
# The three log destinations are separate on purpose; see AGENT-CONTRACT.md.
Write-Host "Service log : $logDir"
Write-Host "Tray log : $(Join-Path $shopFloorProfile 'ShopDB\Logs')"
if ($transcriptStarted) {
Write-Host "Install log : $installLog"
try { Stop-Transcript | Out-Null } catch { }
}