Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows

This commit is contained in:
2026-09-02 16:15:53 -04:00
parent 2144305433
commit bb733f1332
84 changed files with 7107 additions and 186 deletions

View File

@@ -34,14 +34,23 @@ public sealed class AgentConfig
[JsonPropertyName("monitors")] public MonitorConfig Monitors { get; set; } = new();
// Per-image launchable paths for the generic process_start action. The wire
// never carries a path (that would be a code-execution hole); Start sends
// only the image name and the agent maps it here to a known-good executable
// historically never carried a path; now a server-provisioned monitor MAY
// carry one, but the agent only accepts it after LaunchPathPolicy validates
// it against ApprovedLaunchRoots (see below). Start otherwise sends only the
// image name and the agent maps it here to a known-good executable
// (or a .bat launcher). Anything not in the resolved map fails closed. A
// site overrides or extends the shipped defaults in appsettings.json without
// a rebuild. Keyed by process image name (case-insensitive, .exe optional).
[JsonPropertyName("launchpaths")]
public Dictionary<string, string> LaunchPaths { get; set; } = new();
// Approved root directories a server-provisioned launch path must sit under
// before the agent will honor it. Empty = fall back to LaunchPathPolicy's
// shipped org defaults (Program Files roots). A site (or the future Site
// Agent Configuration tool) can widen or narrow this without a rebuild.
[JsonPropertyName("approvedlaunchroots")]
public List<string> ApprovedLaunchRoots { get; set; } = new();
// Shipped defaults for the org's common shop-floor apps. A site's
// appsettings.json LaunchPaths entries win over these on a per-image basis.
// MTConnect is intentionally absent here: it is a Windows service controlled
@@ -162,6 +171,42 @@ public sealed class MonitorConfig
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
= Array.Empty<MonitorTarget>();
// Union the local (appsettings.json) watch list with the server-resolved one,
// keyed case-insensitively by wire name so a target watched both locally and
// by policy is not enumerated twice. Server entries win on the label (App) so
// a policy display name overrides a stale local one, but a local-only target
// is preserved - the server list augments, it does not replace. Nulls on
// either side degrade to the other, which is what keeps monitoring alive when
// a fetch fails.
public static MonitorConfig Merge(MonitorConfig? local, ResolvedMonitors? server)
{
return new MonitorConfig
{
Processes = MergeTargets(local?.Processes, server?.Processes),
Services = MergeTargets(local?.Services, server?.Services),
};
}
private static MonitorTarget[] MergeTargets(
MonitorTarget[]? local, MonitorTarget[]? server)
{
var byName = new Dictionary<string, MonitorTarget>(
StringComparer.OrdinalIgnoreCase);
foreach (var target in local ?? Array.Empty<MonitorTarget>())
{
if (string.IsNullOrWhiteSpace(target?.Name))
continue;
byName[target.Name.Trim()] = target;
}
foreach (var target in server ?? Array.Empty<MonitorTarget>())
{
if (string.IsNullOrWhiteSpace(target?.Name))
continue;
byName[target.Name.Trim()] = target;
}
return byName.Values.ToArray();
}
}
public sealed class MonitorTarget
@@ -172,4 +217,13 @@ public sealed class MonitorTarget
// Human-facing application label, optional. Falls back to Name when empty.
[JsonPropertyName("app")] public string App { get; set; } = "";
// Optional server-provisioned launch path for a process monitor. Only set on
// process targets that a site leader configured a path for. The agent NEVER
// launches this blindly: LaunchPathPolicy validates it against an approved-
// root allowlist first, and a local appsettings.json launchpaths entry for
// the same image still wins. Null/empty means "no server path" - the agent
// falls back to its local launch map, and process_start fails closed when
// neither yields a validated path.
[JsonPropertyName("launchpath")] public string? LaunchPath { get; set; }
}

View File

@@ -34,7 +34,15 @@ public sealed class AgentFaultMonitor
}
public void ReportFatal(string context, Exception error)
=> Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
{
// File log first, always: the Event Log sink below is admin-gated (the
// source cannot be created without elevation), so an unelevated run such
// as the tray-launched --once/--report path would otherwise fail with no
// trace anywhere. The rolling file log is the one sink guaranteed to work
// in every context.
logger.LogError(error, "Fatal in {Context}", context);
Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
}
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
{

View File

@@ -89,6 +89,42 @@ public sealed class CollectorClient : IDisposable
return envelope.Data;
}
// Fetch the server-resolved watch list (policy-attached processes/services)
// for this hostname, GET /api/endpoints/monitors/resolved. Shares the same
// X-API-Key credential as telemetry. Best-effort: returns null on any
// failure so the caller falls back to the last-known/local list rather than
// blanking monitoring on a transient blip. A single attempt, no retry - the
// next telemetry cycle retries naturally, and a stale-but-usable watch list
// beats an empty one.
public async Task<ResolvedMonitors?> FetchResolvedMonitorsAsync(
string hostname, CancellationToken token)
{
var url = "api/endpoints/monitors/resolved?hostname="
+ Uri.EscapeDataString(hostname);
try
{
using var response = await http.GetAsync(url, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning(
"Resolved-monitors fetch returned HTTP {Status}: {Body}",
(int)response.StatusCode, body);
return null;
}
var envelope = JsonSerializer.Deserialize<ResolvedMonitorsEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
catch (Exception error) when (
error is HttpRequestException or TaskCanceledException or JsonException
&& !token.IsCancellationRequested)
{
logger.LogWarning(error, "Resolved-monitors fetch failed; keeping current watch list");
return null;
}
}
private TimeSpan BackoffDelay(int attempt)
{
// 2^(attempt-1) seconds, capped, plus up to 1s jitter.
@@ -96,6 +132,53 @@ public sealed class CollectorClient : IDisposable
return TimeSpan.FromSeconds(seconds + jitter.NextDouble());
}
// Uploads one operator issue report (free-text reason/details plus an
// optional PNG screenshot) to POST /api/endpoints/operatorreports as
// multipart/form-data, authorized by the same X-API-Key collector key as
// telemetry. The server resolves this hostname to its asset. A single
// attempt: the tray shows the operator the outcome and can retry by hand,
// and a stuck retry loop holding a screenshot in memory is worse than a
// clear failure. Returns the parsed upload response (new row id) or throws.
public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync(
string reason, string details, string? screenshotPath, string hostname,
CancellationToken token)
{
using var form = new MultipartFormDataContent();
form.Add(new StringContent(reason ?? string.Empty), "reason");
form.Add(new StringContent(details ?? string.Empty), "details");
form.Add(new StringContent(hostname ?? string.Empty), "hostname");
StreamContent? imageContent = null;
FileStream? imageStream = null;
try
{
if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath))
{
imageStream = File.OpenRead(screenshotPath);
imageContent = new StreamContent(imageStream);
imageContent.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
form.Add(imageContent, "screenshot",
Path.GetFileName(screenshotPath));
}
using var response = await http.PostAsync(
"api/endpoints/operatorreports", form, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<OperatorReportUploadEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
finally
{
imageContent?.Dispose();
imageStream?.Dispose();
}
}
public void Dispose() => http.Dispose();
}

View File

@@ -22,12 +22,26 @@ public sealed class CommandClient : IDisposable
};
private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger<CommandClient> logger;
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
{
this.logger = logger;
http = new HttpClient
// The command channel long-polls: the server holds each claim open for a
// hold window and then, between windows, the pooled TLS connection sits
// idle. Gunicorn's keep-alive closes an idle socket after a couple of
// seconds, so a connection reused after that yields "unexpected EOF or 0
// bytes from the transport stream" on the next TLS handshake. Retire
// pooled connections faster than the server's keep-alive so a socket the
// server has already dropped is never handed back out; the lifetime cap
// is a hard backstop so no connection lingers indefinitely.
handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
// Longer than the collector client: the claim request is held open

View File

@@ -56,9 +56,14 @@ public sealed class CommandExecutor
// Resolved per-image launchable paths for process_start (shipped defaults
// overlaid with any appsettings.json overrides). A target absent from this
// map cannot be started - process_start fails closed.
// map - and without a validated server-provisioned path - cannot be started,
// so process_start fails closed.
private readonly IReadOnlyDictionary<string, string> launchPaths;
// Validates server-provisioned launch paths (from policy-attached monitors)
// before they are honored. Local launchPaths always win over a server path.
private readonly LaunchPathPolicy launchPathPolicy;
// Held for update_app: the agent joins InstallPath to BaseUrl and downloads
// the pinned installer with its own collector credential (ApiKey), so the
// installer body never rides the command wire.
@@ -71,6 +76,7 @@ public sealed class CommandExecutor
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
this.config = config ?? new AgentConfig();
this.launchPaths = this.config.ResolveLaunchPaths();
this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots);
}
public async Task<CommandResult> ExecuteAsync(
@@ -366,8 +372,8 @@ public sealed class CommandExecutor
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
if (!launchPaths.TryGetValue(bare, out var launchPath)
|| string.IsNullOrWhiteSpace(launchPath))
var launchPath = ResolveLaunchPath(bare, image);
if (string.IsNullOrWhiteSpace(launchPath))
{
logger.LogWarning(
"Rejected process_start for {Image}: no launchable path configured",
@@ -452,6 +458,62 @@ public sealed class CommandExecutor
}
}
// Resolves the launchable path for a process image. Precedence:
// 1. Local launch map (shipped defaults + appsettings.json). A site pin
// always wins so a server value never silently overrides it.
// 2. A server-provisioned path on the matching process monitor, but only
// after LaunchPathPolicy validates it (approved root, exists, launchable
// extension). A rejected server path is logged and dropped - the caller
// then fails closed exactly as before.
// Returns null when neither source yields a usable path.
private string? ResolveLaunchPath(string bareImage, string image)
{
if (launchPaths.TryGetValue(bareImage, out var localPath)
&& !string.IsNullOrWhiteSpace(localPath))
{
return localPath;
}
var serverPath = ServerLaunchPathFor(image, bareImage);
if (string.IsNullOrWhiteSpace(serverPath))
return null;
if (launchPathPolicy.TryValidate(serverPath, out var validated, out var reason))
return validated;
logger.LogWarning(
"Rejected server-provisioned launch path for {Image} ({Path}): {Reason}",
image, serverPath, reason);
return null;
}
// Finds the server-provisioned launch path carried on the process monitor
// whose wire name matches the image (case-insensitive, .exe optional).
private string? ServerLaunchPathFor(string image, string bareImage)
{
var processes = config.Monitors?.Processes;
if (processes is null)
return null;
foreach (var target in processes)
{
if (string.IsNullOrWhiteSpace(target?.Name)
|| string.IsNullOrWhiteSpace(target.LaunchPath))
continue;
var name = target.Name.Trim();
var bareName = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
? name[..^4]
: name;
if (string.Equals(name, image, StringComparison.OrdinalIgnoreCase)
|| string.Equals(bareName, bareImage, StringComparison.OrdinalIgnoreCase))
{
return target.LaunchPath;
}
}
return null;
}
private bool IsProcessRunning(string image)
{
var processes = FindProcessesByImage(image);

View File

@@ -72,6 +72,20 @@ public sealed class CollectorEnvelope
[JsonPropertyName("data")] public CollectorResponse? Data { get; set; }
}
// Server response to an operator issue report upload. Carries the new row id so
// the agent can log it for correlation.
public sealed class OperatorReportUploadResponse
{
[JsonPropertyName("operatorreportid")] public int? OperatorReportId { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
}
public sealed class OperatorReportUploadEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public OperatorReportUploadResponse? Data { get; set; }
}
// A command the server claimed for this endpoint on the long-poll. The
// allowlisted action name crosses the wire (see AGENT-CONTRACT.md section 7);
// generic actions (service_* / process_*) also carry the subject to act on -
@@ -147,3 +161,24 @@ public sealed class CommandResult
[JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; }
[JsonPropertyName("detail")] public Dictionary<string, object>? Detail { get; set; }
}
// The server-resolved watch list for this endpoint (processes and services the
// site's policy attaches to the box's PC type), fetched each telemetry cycle so
// a policy-attached monitor is enumerated without editing appsettings.json on
// the box. Same {name, app} shape as the local MonitorConfig, so the merge is a
// direct list union. Empty lists mean "watch nothing extra beyond local".
public sealed class ResolvedMonitors
{
[JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; }
= Array.Empty<MonitorTarget>();
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
= Array.Empty<MonitorTarget>();
}
// Envelope for the resolved-monitors route: { status, data: { processes, services } }.
public sealed class ResolvedMonitorsEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public ResolvedMonitors? Data { get; set; }
}

View File

@@ -0,0 +1,238 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Guards server-provisioned launch paths. Historically the command wire never
// carried a launch path at all - the agent mapped an image name to a known-good
// local path and anything else failed closed. Server-provisioned monitors now
// MAY carry a path a site leader set in the WebUI, which is a deliberate
// relaxation of that invariant. This policy is the replacement safeguard: a
// server path is only honored when it sits under an approved root, actually
// exists, and is an approved executable type. Everything else is rejected and
// the agent falls back to its local launch map (still fail closed).
//
// The approved-root list is org-opinionated with shipped defaults (the Windows
// Program Files roots), overridable per site via AgentConfig.ApprovedLaunchRoots
// so the future Site Agent Configuration tool can widen or narrow it without a
// rebuild. A local appsettings.json launchpaths entry still wins over any
// server path, so a site pin is never silently overridden.
public sealed class LaunchPathPolicy
{
// Only these extensions are launchable. .bat is included because some shop-
// floor apps ship a launcher script (see the eDNC/UDC run.bat pattern).
private static readonly string[] ApprovedExtensions =
{ ".exe", ".bat", ".cmd" };
// Shipped org defaults: the standard 64- and 32-bit install roots. Resolved
// from the environment so a non-C: system drive still matches.
public static IReadOnlyList<string> DefaultApprovedRoots { get; } =
BuildDefaultRoots();
private readonly IReadOnlyList<string> approvedRoots;
public LaunchPathPolicy(IEnumerable<string>? approvedRootsOverride = null)
{
var roots = (approvedRootsOverride ?? Enumerable.Empty<string>())
.Where(root => !string.IsNullOrWhiteSpace(root))
.Select(NormalizeRoot)
.ToList();
approvedRoots = roots.Count > 0 ? roots : DefaultApprovedRoots;
}
// Result of validating a candidate path: the normalized full path when it is
// acceptable, or a human-readable reason when it is rejected.
public bool TryValidate(string? candidate, out string fullPath, out string reason)
{
fullPath = "";
reason = "";
if (string.IsNullOrWhiteSpace(candidate))
{
reason = "empty path";
return false;
}
string resolved;
try
{
resolved = Path.GetFullPath(candidate.Trim());
}
catch (Exception ex)
{
reason = $"not a valid path: {ex.Message}";
return false;
}
// Reject an alternate data stream before anything else looks at the
// path. On .NET Framework an interior colon made GetFullPath throw; on
// net8.0 it does not, so "readme.txt:payload.exe" survives, reports an
// .exe extension, passes the root check, and File.Exists returns true
// for the stream. CreateProcess will run it. Write access to any
// existing file under an approved root would otherwise be enough.
var lastSegment = Path.GetFileName(resolved);
if (lastSegment.Contains(':'))
{
reason = "path names an alternate data stream";
return false;
}
var extension = Path.GetExtension(resolved);
if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
reason = $"extension '{extension}' is not launchable";
return false;
}
if (!IsUnderApprovedRoot(resolved))
{
reason = "path is outside every approved launch root";
return false;
}
if (!File.Exists(resolved))
{
reason = "file does not exist";
return false;
}
// Everything above is LEXICAL: GetFullPath is pure string normalization
// and never touches the disk, so the root check so far constrains the
// name, not the file. File.Exists follows reparse points, which means a
// junction anywhere under an approved root pointed the check at one
// place and the execution at another. Creating a junction needs no
// privilege, only write access to the parent - and vendor install trees
// under Program Files are not reliably locked down.
//
// So resolve what the path actually refers to and re-check THAT. A
// resolve failure rejects: this is the last gate before the agent runs
// the file.
string finalPath;
try
{
finalPath = ResolveFinalPath(resolved);
}
catch (Exception ex)
{
reason = $"cannot resolve the real path: {ex.Message}";
return false;
}
if (!IsUnderApprovedRoot(finalPath))
{
reason = "path resolves outside every approved launch root";
return false;
}
fullPath = finalPath;
return true;
}
// The path with every symlink and junction on it followed, so the caller
// checks the file that will actually execute. The leaf is resolved first,
// then the directory chain above it - a junction is usually placed on a
// DIRECTORY, so resolving only the leaf would miss the case this exists to
// catch.
private static string ResolveFinalPath(string resolved)
{
string? name;
try
{
name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
}
catch
{
name = null;
}
if (!string.IsNullOrWhiteSpace(name))
{
// ResolveLinkTarget resolves the file itself; run it back through
// GetFullPath so the comparison sees the same shape as the roots.
return Path.GetFullPath(name);
}
// Not a link. Its directory chain still might be, so resolve that.
var directory = Path.GetDirectoryName(resolved);
if (string.IsNullOrEmpty(directory))
return resolved;
var realDirectory = ResolveDirectoryChain(directory);
return Path.GetFullPath(Path.Combine(realDirectory, Path.GetFileName(resolved)));
}
// Walk up the directory chain resolving any junction or symlink on it. A
// junction is usually placed on a DIRECTORY, not on the executable, so
// resolving only the leaf would miss the case this exists to catch.
private static string ResolveDirectoryChain(string directory)
{
// Stop at the volume root. Walking into it and asking whether "C:\" is
// a link throws DirectoryNotFoundException, which the caller turned
// into a rejection - so the first version of this rejected every
// legitimate path as well as the malicious ones. A fail-closed gate
// that also fails the normal case is just an outage. Caught in the VM,
// not here.
var parent = Path.GetDirectoryName(directory);
if (string.IsNullOrEmpty(parent) || parent == directory)
return directory;
var resolvedDirectory = TryResolveLink(directory);
if (resolvedDirectory is not null)
return resolvedDirectory;
var realParent = ResolveDirectoryChain(parent);
return realParent == parent
? directory
: Path.Combine(realParent, Path.GetFileName(directory));
}
// A component's link target, or null when it is not a link OR cannot be
// asked. Never throws: "cannot tell" must not be reported as "is a link",
// and the caller re-checks the assembled path against the roots regardless,
// so an unresolvable component cannot widen what is accepted.
private static string? TryResolveLink(string path)
{
try
{
var target = System.IO.Directory
.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName;
return string.IsNullOrWhiteSpace(target) ? null : target;
}
catch
{
return null;
}
}
private bool IsUnderApprovedRoot(string resolved)
{
foreach (var root in approvedRoots)
{
// Compare against root + separator so "C:\Program Files" does not
// also match "C:\Program FilesEvil\...". Case-insensitive on Windows.
var prefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string NormalizeRoot(string root)
{
var full = Path.GetFullPath(root.Trim());
return full.TrimEnd(Path.DirectorySeparatorChar);
}
private static IReadOnlyList<string> BuildDefaultRoots()
{
var candidates = new[]
{
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
};
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(NormalizeRoot)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// The on-disk hand-off between the interactive tray (which captures the
// operator's screen and notes) and the headless service binary (which holds the
// collector key and performs the upload). The tray writes this JSON sidecar to a
// per-run temp folder and invokes `shopdbagent.exe --report <path>`; the agent
// reads it, uploads, and deletes the folder. Keeping the secret in the service
// preserves the "tray never holds a credential" rule already used by --once.
public sealed class OperatorReportPayload
{
[JsonPropertyName("reason")]
public string Reason { get; set; } = "Other (free text)";
[JsonPropertyName("details")]
public string Details { get; set; } = string.Empty;
// Absolute path to the captured PNG, or null when the operator declined a
// screenshot. Written by the tray into the interactive operator's
// %USERPROFILE%\ShopDBScreenshots folder and passed through as an absolute
// path; the agent reads it verbatim and never re-derives the location (as
// SYSTEM its own profile would resolve elsewhere).
[JsonPropertyName("screenshotpath")]
public string? ScreenshotPath { get; set; }
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
public static void Write(string filePath, string reason, string details,
string? screenshotPath)
{
var payload = new OperatorReportPayload
{
Reason = reason,
Details = details,
ScreenshotPath = screenshotPath,
};
File.WriteAllText(filePath, JsonSerializer.Serialize(payload, Options));
}
public static OperatorReportPayload Read(string filePath)
{
var json = File.ReadAllText(filePath);
return JsonSerializer.Deserialize<OperatorReportPayload>(json, Options)
?? throw new InvalidDataException("empty or invalid report payload");
}
}

View File

@@ -96,6 +96,21 @@ public sealed class TelemetryWorker : BackgroundService
// exact production collection path.
public async Task<CollectorResponse> RunOnceAsync(bool adopt, CancellationToken token)
{
// Refresh the watch list from server policy BEFORE collecting so a
// policy-attached monitor (added in the WebUI, never in appsettings.json)
// is enumerated and reported this cycle. Server entries merge over the
// local list; a failed fetch returns null and Merge keeps the local list,
// so monitoring never blanks on a transient blip.
var resolved = await client.FetchResolvedMonitorsAsync(
Environment.MachineName, token);
if (resolved is not null)
{
config.Monitors = MonitorConfig.Merge(config.Monitors, resolved);
logger.LogDebug(
"Watch list refreshed from policy: {Processes} process(es), {Services} service(s)",
config.Monitors.Processes.Length, config.Monitors.Services.Length);
}
var report = collector.Collect(agentVersion, adopt);
if (utilities is not null)

View File

@@ -0,0 +1,97 @@
using ShopDbEndpointAgent.Core;
// Planted-violation suite for LaunchPathPolicy, run on real Windows because the
// cases that matter (junctions, alternate data streams, 8.3 names) are
// filesystem behaviour, not string behaviour. Linux would prove nothing.
//
// Builds its own fixtures under an approved root, so it exercises the same
// shape as a real shop-floor install rather than a synthetic path.
int failures = 0;
void Check(string label, bool expectAccept, string candidate, LaunchPathPolicy policy)
{
var ok = policy.TryValidate(candidate, out var full, out var reason);
var pass = ok == expectAccept;
if (!pass) failures++;
var verdict = pass ? "PASS" : "FAIL";
var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
Console.WriteLine($"{verdict} {label}");
Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
}
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LppFixture");
var outside = Path.Combine(Path.GetTempPath(), "LppOutside");
Directory.CreateDirectory(root);
Directory.CreateDirectory(outside);
var policy = new LaunchPathPolicy(new[] { root });
// A real executable inside the approved root.
var good = Path.Combine(root, "good.exe");
File.WriteAllBytes(good, new byte[] { 0x4D, 0x5A });
// The payload the attacker wants run, planted OUTSIDE the approved root.
var payload = Path.Combine(outside, "payload.exe");
File.WriteAllBytes(payload, new byte[] { 0x4D, 0x5A });
// A text file inside the root, to hang an alternate data stream off.
var carrier = Path.Combine(root, "readme.txt");
File.WriteAllText(carrier, "notes");
Console.WriteLine($"approved root : {root}");
Console.WriteLine($"outside dir : {outside}");
Console.WriteLine();
Check("a real exe under the approved root", true, good, policy);
Check("wrong extension", false, carrier, policy);
Check("file that does not exist", false, Path.Combine(root, "nope.exe"), policy);
Check("traversal back out of the root", false,
Path.Combine(root, "..", "..", "Windows", "System32", "cmd.exe"), policy);
Check("UNC path", false, @"\\attacker\share\payload.exe", policy);
Check("sibling directory sharing the root's prefix", false, root + "Evil\\payload.exe", policy);
Check("empty path", false, "", policy);
// --- alternate data stream --------------------------------------------------
try
{
File.WriteAllBytes(carrier + ":payload.exe", new byte[] { 0x4D, 0x5A });
Check("alternate data stream on a file in the root", false,
carrier + ":payload.exe", policy);
}
catch (Exception ex)
{
Console.WriteLine($"SKIP alternate data stream fixture: {ex.Message}");
}
// --- directory junction out of the root ------------------------------------
var junction = Path.Combine(root, "logs");
var mk = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c mklink /J \"{junction}\" \"{outside}\"",
UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,
});
mk!.WaitForExit();
if (mk.ExitCode == 0)
{
Check("exe reached through a junction pointing out of the root", false,
Path.Combine(junction, "payload.exe"), policy);
}
else
{
Console.WriteLine($"SKIP junction fixture: mklink exit {mk.ExitCode} {mk.StandardError.ReadToEnd().Trim()}");
}
// --- 8.3 short name ---------------------------------------------------------
// 8.3 short names used to be rejected, because the lexical prefix check could
// not match "C:\PROGRA~1" against "C:\Program Files" - fail closed, but only by
// accident of not understanding the path. Resolution now expands it to the real
// directory, which IS under the approved root, so accepting is correct: the
// check that matters runs on the resolved path either way.
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
Console.WriteLine();
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
return failures == 0 ? 0 : 1;

View File

@@ -0,0 +1,33 @@
# LaunchPathPolicy probe
A planted-violation probe for `LaunchPathPolicy`. It builds real fixtures under
a real approved root - a junction pointing out of it, an alternate data stream,
an 8.3 short name - and asserts what the policy does with each.
## Why it is not a unit test in CI
The cases worth testing are filesystem behaviour, not string behaviour:
junctions, alternate data streams and 8.3 short names do not exist on Linux, and
CI runs on Linux runners. A suite that skipped every meaningful case there would
report green and mean nothing, which is the failure shape this repo has already
been bitten by more than once. So it is a probe you run on Windows, by hand,
when the policy changes.
## Running it
Publish from the dev box and run it in the win11 VM:
dotnet publish agent/ShopDbEndpointAgent.PolicyProbe -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true -o /tmp/lpp
Copy `lpp/lpptest.exe` into the VM and run it as an account that may create a
junction under Program Files. Exit code 0 means every expectation held; it
prints one line per case either way.
## What it caught
The first version of the link-resolving hardening recursed to the volume root
and asked whether `C:\` was a link. That throws, and the caller treated a
resolve failure as a rejection - so the policy rejected every legitimate launch
path as well as the malicious ones. It compiled cleanly and read correctly. Only
running it on Windows showed the agent would have failed closed on everything.

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Planted-violation probe for LaunchPathPolicy. See README.md next to this
file: it is run BY HAND on Windows, not by CI, because the cases it
exists to catch are filesystem behaviour and CI runs on Linux.
net8.0, not net8.0-windows: the policy itself only touches System.IO, and
a plain target cross-publishes from the Linux dev box. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>lpptest</AssemblyName>
<RootNamespace>LppTest</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,8 @@
using System.Threading;
namespace ShopDbEndpointAgent.Tray;
// Interactive user-session tray app (SCAFFOLD). Separate process from the
// Interactive user-session tray app (SCAFFOLD).
// headless Windows Service: the service owns telemetry, this owns the operator
// surface - a system-tray icon and the 5-click-max ServiceNow ticket flow
// (DESIGN-DECISIONS-agent.md section 6). This iteration wires the tray and a
@@ -8,9 +10,35 @@ namespace ShopDbEndpointAgent.Tray;
// is deferred until that decision is made server-side.
internal static class Program
{
// Machine-wide single-instance guard. The installer starts the tray from an
// HKLM Run value for every user at logon, and an operator can also launch it
// from the Start Menu shortcut, so a second copy is easy to spawn. A named
// mutex in the Global namespace keeps exactly one tray per session: a later
// launch that cannot own the mutex exits immediately instead of stacking a
// duplicate icon. Global\ scopes across sessions on the same machine.
private const string SingleInstanceMutexName = @"Global\ShopDbEndpointAgentTray";
[STAThread]
private static void Main()
private static void Main(string[] args)
{
// Headless icon export for the installer: `--export-icon <path>` writes
// the branded .ico and exits without starting the tray, so install.ps1
// can point the Start Menu shortcut at a real brand icon. Kept in this
// exe so the icon has a single source (TrayIcon) and no binary is
// checked into the repo.
if (args.Length >= 2 &&
string.Equals(args[0], "--export-icon", StringComparison.OrdinalIgnoreCase))
{
TrayIcon.Export(args[1]);
return;
}
using var singleInstance = new Mutex(true, SingleInstanceMutexName, out bool owned);
if (!owned)
{
return;
}
ApplicationConfiguration.Initialize();
Application.Run(new TrayContext());
}

View File

@@ -0,0 +1,34 @@
using System.Drawing.Imaging;
namespace ShopDbEndpointAgent.Tray;
// Captures the full virtual desktop (all monitors) to a PNG on disk, porting
// the operator PowerShell proof of concept:
// CopyFromScreen over SystemInformation.VirtualScreen, then Bitmap.Save as
// PNG. Kept in the tray project because only the interactive tray session can
// see the operator's desktop; the headless service runs in session 0 and
// would capture a black frame.
internal static class ScreenCapture
{
// Grab every monitor in one shot. VirtualScreen spans the whole desktop so
// a multi-head shop-floor PC is captured intact rather than just the
// primary display. Returns the saved file path.
public static string CaptureToPng(string filePath)
{
var bounds = SystemInformation.VirtualScreen;
using var bitmap = new Bitmap(bounds.Width, bounds.Height,
PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0,
bounds.Size, CopyPixelOperation.SourceCopy);
}
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
bitmap.Save(filePath, ImageFormat.Png);
return filePath;
}
}

View File

@@ -1,22 +1,20 @@
namespace ShopDbEndpointAgent.Tray;
// The 5-click-max issue reporter (SCAFFOLD). Target flow from
// DESIGN-DECISIONS-agent.md section 6:
// network precheck -> issue dropdown -> optional screenshot -> submit.
// This iteration renders the dropdown of fixed reasons and a submit button but
// does NOT talk to ServiceNow yet - the submission path (direct API vs proxied
// through a ShopDB server endpoint so ServiceNow creds stay server-side) is an
// open decision. Submit currently shows what WOULD be sent.
// The operator issue reporter. Collects a free-text description and an optional
// screenshot request, then hands the result back to TrayContext which performs
// the capture (after this dialog closes, so it never appears in the shot) and
// the upload via the service binary.
//
// Only "Other (free text)" is offered today: the fixed-reason routing from the
// original design is not wired to a ticketing backend, so a single free-text
// path is the one flow that works end to end. The dropdown is kept (disabled,
// preselected) so the richer reason list can return without reworking the form.
internal sealed class TicketForm : Form
{
// Fixed dropdown reasons from the source conversation (section 6).
// Only the free-text reason is functional. Kept as a one-item list so the
// control and its layout stay in place for the future reason routing.
private static readonly string[] Reasons =
{
"Can't logon to CSF",
"Data collections down",
"Can't download program",
"Machine communication failing",
"Network drives down",
"Other (free text)",
};
@@ -24,6 +22,11 @@ internal sealed class TicketForm : Form
private readonly TextBox detailBox;
private readonly CheckBox screenshotBox;
// Collected result, read by TrayContext after DialogResult.OK.
public string Reason => reasonBox.SelectedItem?.ToString() ?? "Other (free text)";
public string Details => detailBox.Text?.Trim() ?? string.Empty;
public bool IncludeScreenshot => screenshotBox.Checked;
public TicketForm()
{
Text = "Report an issue";
@@ -46,13 +49,16 @@ internal sealed class TicketForm : Form
Top = 40,
Width = 388,
DropDownStyle = ComboBoxStyle.DropDownList,
// Disabled until the fixed-reason routing exists; free text is the
// only functional path, so the selection is fixed on it.
Enabled = false,
};
reasonBox.Items.AddRange(Reasons);
reasonBox.SelectedIndex = 0;
var detailLabel = new Label
{
Text = "Details (optional)",
Text = "Details",
Left = 16,
Top = 76,
Width = 388,
@@ -72,6 +78,7 @@ internal sealed class TicketForm : Form
Left = 16,
Top = 188,
Width = 388,
Checked = true,
};
var submit = new Button
@@ -82,7 +89,6 @@ internal sealed class TicketForm : Form
Width = 76,
DialogResult = DialogResult.OK,
};
submit.Click += OnSubmit;
var cancel = new Button
{
@@ -101,18 +107,4 @@ internal sealed class TicketForm : Form
AcceptButton = submit;
CancelButton = cancel;
}
private void OnSubmit(object? sender, EventArgs e)
{
// Placeholder: show what would be submitted. The real path (screenshot
// capture to Pictures\Screenshots, then ServiceNow submit) lands once
// the direct-vs-proxied decision is made.
var summary =
$"Reason: {reasonBox.SelectedItem}\n" +
$"Details: {(string.IsNullOrWhiteSpace(detailBox.Text) ? "(none)" : detailBox.Text)}\n" +
$"Screenshot: {(screenshotBox.Checked ? "yes" : "no")}";
MessageBox.Show(
"Ticketing is not wired up yet. This would be submitted:\n\n" + summary,
"ShopDB agent (scaffold)", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}

View File

@@ -1,4 +1,7 @@
using System.Diagnostics;
using System.Reflection;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
@@ -44,10 +47,102 @@ internal sealed class TrayContext : ApplicationContext
trayIcon.DoubleClick += OnSendStatus;
}
private void OnReportIssue(object? sender, EventArgs e)
private async void OnReportIssue(object? sender, EventArgs e)
{
using var dialog = new TicketForm();
dialog.ShowDialog();
string reason;
string details;
bool includeScreenshot;
using (var dialog = new TicketForm())
{
if (dialog.ShowDialog() != DialogResult.OK)
return;
reason = dialog.Reason;
details = dialog.Details;
includeScreenshot = dialog.IncludeScreenshot;
}
var agentExe = ResolveAgentExe();
if (agentExe is null)
{
ShowBalloon("Report an issue", "Could not find " + AgentExeName +
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
return;
}
// Capture AFTER the dialog closes so the report form is never in the
// shot. Stage into the interactive operator's own profile
// (%USERPROFILE%\ShopDBScreenshots), NOT %TEMP%: on this locked-down
// image the tray process's TEMP resolves to a different account's
// AppData that ShopFloor cannot write, and AppData/Temp are aggressively
// ACL'd/redirected. The profile root is owned by the operator and, by
// inherited ACL, readable by SYSTEM (the agent service) so the handoff
// works. Files are kept for the operator to show a DT/specialist later;
// pruning is the agent's future server-driven Capture Cleanup job.
var storeDir = OperatorReportStoreDir();
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
string? payloadPath = null;
try
{
Directory.CreateDirectory(storeDir);
string? screenshotPath = null;
if (includeScreenshot)
{
screenshotPath = Path.Combine(storeDir, "screenshot-" + stamp + ".png");
ScreenCapture.CaptureToPng(screenshotPath);
}
payloadPath = Path.Combine(storeDir, "report-" + stamp + ".json");
OperatorReportPayload.Write(payloadPath, reason, details, screenshotPath);
ShowBalloon("Report an issue", "Sending your report to ShopDB...",
ToolTipIcon.Info);
int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\"");
if (exitCode == 0)
ShowBalloon("Report an issue", "Your report was sent to ShopDB.",
ToolTipIcon.Info);
else
ShowBalloon("Report an issue",
"Report send failed (exit " + exitCode +
"). See the agent log under ProgramData\\ShopDB.",
ToolTipIcon.Warning);
}
catch (Exception error)
{
ShowBalloon("Report an issue", "Could not send the report: " +
error.Message, ToolTipIcon.Error);
}
finally
{
// Only the transient JSON sidecar is removed; the screenshot stays
// for the operator. The agent also deletes the sidecar on success,
// so this just covers the failure path.
if (payloadPath is not null)
TryDeleteFile(payloadPath);
}
}
// The operator-visible folder screenshots and the report sidecar are staged
// into, under the interactive user's profile. Resolved in the tray (running
// as that user) and passed to the agent as an absolute path; the agent must
// never re-derive it, since as SYSTEM its profile would resolve elsewhere.
private static string OperatorReportStoreDir()
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"ShopDBScreenshots");
private static void TryDeleteFile(string filePath)
{
try
{
if (File.Exists(filePath))
File.Delete(filePath);
}
catch
{
// Best-effort; a leftover sidecar is harmless and the agent also
// removes it on a successful upload.
}
}
// Runs shopdbagent.exe --once out of process so the operator can push a
@@ -74,7 +169,7 @@ internal sealed class TrayContext : ApplicationContext
try
{
int exitCode = await RunAgentOnceAsync(agentExe);
int exitCode = await RunAgentAsync(agentExe, "--once");
if (exitCode == 0)
ShowBalloon("Send Status", "Status sent to ShopDB.",
ToolTipIcon.Info);
@@ -96,12 +191,12 @@ internal sealed class TrayContext : ApplicationContext
}
}
private static Task<int> RunAgentOnceAsync(string agentExe)
private static Task<int> RunAgentAsync(string agentExe, string arguments)
{
var startInfo = new ProcessStartInfo
{
FileName = agentExe,
Arguments = "--once",
Arguments = arguments,
WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
@@ -181,9 +276,36 @@ internal sealed class TrayContext : ApplicationContext
=> MessageBox.Show(
"ShopDB Endpoint Agent tray.\n" +
"Telemetry runs as the ShopDB Endpoint Agent Windows service.\n" +
"Send Status now reports this PC to ShopDB on demand.",
"Send Status now reports this PC to ShopDB on demand.\n\n" +
"Tray version: " + TrayVersion() + "\n" +
"Agent version: " + AgentVersion(),
"About", MessageBoxButtons.OK, MessageBoxIcon.Information);
// The tray's own assembly version, the same value stamped from the csproj
// <Version> at build time.
private static string TrayVersion()
=> Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
// The installed agent service exe's file version, read from wherever the
// tray resolves it (beside the tray, then the default install dir). The
// service and tray can legitimately be different versions, so this is shown
// as its own line rather than assumed equal to the tray. Degrades to a
// clear note when the agent exe is not found or its version is unreadable.
private static string AgentVersion()
{
var agentExe = ResolveAgentExe();
if (agentExe is null)
return "not found";
try
{
return FileVersionInfo.GetVersionInfo(agentExe).FileVersion ?? "unknown";
}
catch (FileNotFoundException)
{
return "not found";
}
}
private void OnExit(object? sender, EventArgs e)
{
trayIcon.Visible = false;

View File

@@ -62,6 +62,22 @@ internal static class TrayIcon
}
}
// Writes the branded icon to an .ico file so a Start Menu / desktop shortcut
// can show the brand instead of the generic exe icon. Reuses the same GDI+
// drawing as the tray so there is still one source of brand truth and no
// checked-in binary. 64px suits shortcut/large-icon views; the OS downscales.
public static void Export(string path, int size = 64)
{
using var icon = Create(size);
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write);
icon.Save(stream);
}
private static GraphicsPath RoundedRect(RectangleF rect, float radius)
{
float diameter = radius * 2;

View File

@@ -3,4 +3,6 @@
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
<!-- Run by hand on Windows, never by CI. See its README. -->
<Project Path="ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj" />
</Solution>

View File

@@ -10,6 +10,9 @@ using ShopDbEndpointAgent.Core;
// (default) run as a Windows Service (AddWindowsService)
// --console run interactively in the foreground for debugging
// --once collect and report exactly once, then exit (smoke test / cron)
// --report <path> upload one operator issue report (JSON sidecar written by
// the tray), then exit. Keeps the collector key in the
// service so the tray never holds a secret.
//
// Secrets are never read here: AgentConfigProvider layers the registry
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
@@ -19,6 +22,7 @@ using ShopDbEndpointAgent.Core;
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase));
var reportPayloadPath = ArgValue(args, "--report");
var agentVersion = Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "0.0.0";
@@ -58,6 +62,9 @@ try
if (runOnce)
return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor);
if (!string.IsNullOrWhiteSpace(reportPayloadPath))
return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor);
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
return 0;
}
@@ -103,6 +110,61 @@ static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
}
}
// Uploads one operator issue report described by a tray-written JSON sidecar,
// then deletes the payload folder. Runs out of process from the tray so the
// collector key stays in the service binary. The exit code is the tray's
// contract: 0 = uploaded, non-zero = failed (detail in the agent log).
static async Task<int> RunReportAsync(AgentConfig config, string payloadPath,
string logDirectory, AgentFaultMonitor faultMonitor)
{
using var services = BuildOnceServices(config, "0.0.0", logDirectory);
var client = services.GetRequiredService<CollectorClient>();
var logger = services.GetRequiredService<ILogger<CollectorClient>>();
try
{
var payload = OperatorReportPayload.Read(payloadPath);
var response = await client.UploadOperatorReportAsync(
payload.Reason, payload.Details, payload.ScreenshotPath,
Environment.MachineName, CancellationToken.None);
logger.LogInformation("Operator report uploaded: id={ReportId}",
response?.OperatorReportId);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("--report", error);
return 1;
}
finally
{
// Remove only the transient JSON sidecar. The screenshot lives in the
// operator's %USERPROFILE%\ShopDBScreenshots folder and is deliberately
// KEPT so the operator can show it to a DT/specialist later; pruning it
// is the future server-driven Capture Cleanup job, not this path. (The
// sidecar previously sat in a throwaway temp folder we deleted whole;
// now it shares the operator's folder, so a recursive delete here would
// wipe saved screenshots.)
try
{
if (File.Exists(payloadPath))
File.Delete(payloadPath);
}
catch { /* best-effort; the tray also removes the sidecar */ }
}
}
// Read the value following a flag (e.g. --report C:\path\report.json). Returns
// null when the flag is absent or has no following token.
static string? ArgValue(string[] args, string flag)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
return null;
}
static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion,
string logDirectory)
{

View File

@@ -7,7 +7,38 @@
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.4.1: run_script/update_app stage into a fixed, Defender-excludable
<!-- 1.6.3: operator issue reports stage under the interactive operator's
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
instead of %TEMP%, which on locked-down endpoints redirected to another
user's AppData\Local\Temp and left the SYSTEM-context report upload
reading an inaccessible path. Screenshots use flat timestamped names and
are KEPT after a successful upload for operator review; only the transient
JSON sidecar is pruned, so the report path no longer recursively deletes
the shared folder. AgentFaultMonitor.ReportFatal now mirrors fatal report
failures to the rolling file log so unelevated tray-launched runs surface
a cause instead of exiting 1 silently.
1.6.2: command channel retires pooled TLS connections faster than the
server keep-alive (SocketsHttpHandler PooledConnectionIdleTimeout 1s,
PooledConnectionLifetime 2m). Fixes intermittent "unexpected EOF or 0
bytes from the transport stream" on the long-poll claim when the pool
reused a socket the server had already closed between hold windows.
Telemetry was unaffected; this only touched CommandClient.
1.6.1: republished against the current source so both site packages
(Wilmington and West Jefferson) carry the same vetted build. No agent
behavior change; the server now computes warn-only drift from the
service/process state the agent already reports each cycle.
1.6.0: a policy-attached process monitor may carry a launch path set by
a site leader in the WebUI; it rides the /monitors/resolved payload and
the agent honors it only after LaunchPathPolicy validates it (approved
root, exists, launchable extension). A local appsettings.json launchpaths
entry still wins; an unvalidated path is dropped and process_start fails
closed.
1.5.0: the agent fetches its watch list from server policy each
telemetry cycle (GET /api/endpoints/monitors/resolved) and merges it
over local appsettings.json, so a policy-attached process/service
monitor is enumerated and reported without editing the box. Fail-closed:
a failed fetch keeps the local list.
1.4.1: run_script/update_app stage into a fixed, Defender-excludable
folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem
temp path, and run_script user-context waits for exit and reports the
real exit code instead of fire-and-forget "launched".
@@ -19,7 +50,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.4.1</Version>
<Version>1.6.3</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -7,20 +7,10 @@
"utilitiespath": "",
"logpath": "",
"monitors": {
"processes": [
{ "name": "dncMain.exe", "app": "eDNC" },
{ "name": "udc.exe", "app": "Universal Data Collector" },
{ "name": "agent.exe", "app": "MTConnect" }
],
"services": [
{ "name": "MTConnect UDC Agent", "app": "MTConnect" }
]
"processes": [],
"services": []
},
"launchpaths": {
"DncMain.exe": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe",
"UDC.exe": "C:\\Program Files\\UDC\\UDC.exe",
"agent.exe": "C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat"
}
"launchpaths": {}
}