Agent: execute run_script and update_app delivery pushes

Bumps the agent to 1.4.1. run_script fetches a ShopDB-hosted script by digest and runs it in the script's configured context (LocalSystem or the interactive logged-on user), replacing the local scriptpaths allowlist; user-context runs wait for exit and report the real exit code instead of fire-and-forget. update_app resolves an app-level or version-level install source, fetches the pinned bytes, verifies sha256, and runs silently. Both stage into a fixed Defender-excludable folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem temp path. Adds the DeliveryClaim contract for the claim payload.
This commit is contained in:
benjamin.baker
2026-08-30 14:41:10 -04:00
committed by cproudlock
parent 4590156f30
commit 84f9cfe13d
5 changed files with 583 additions and 7 deletions

View File

@@ -80,6 +80,39 @@ public sealed class AgentConfig
return bare;
}
// Per-name allowlist of pre-staged scripts for the generic run_script
// action. Exactly like LaunchPaths, the wire never carries a script body or
// path - only a script NAME the agent already holds here. A name absent
// from this map fails closed. There are no shipped defaults: a script is a
// site-specific artifact, so an empty map means run_script does nothing
// until a site stages scripts and lists them in appsettings.json. Keyed by
// script name (case-insensitive, .ps1 optional).
[JsonPropertyName("scriptpaths")]
public Dictionary<string, string> ScriptPaths { get; set; } = new();
// The effective script map: appsettings.json entries only (no defaults).
// Keys are normalized to bare names so a lookup tolerates 'reset' or
// 'reset.ps1'.
public IReadOnlyDictionary<string, string> ResolveScriptPaths()
{
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in ScriptPaths)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
continue;
resolved[NormalizeScript(pair.Key)] = pair.Value;
}
return resolved;
}
private static string NormalizeScript(string name)
{
var bare = (name ?? "").Trim();
if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
return bare;
}
public bool IsUsable()
=> !string.IsNullOrWhiteSpace(BaseUrl)
&& !string.IsNullOrWhiteSpace(ApiKey)

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.ServiceProcess;
using Microsoft.Extensions.Logging;
@@ -58,12 +59,18 @@ public sealed class CommandExecutor
// map cannot be started - process_start fails closed.
private readonly IReadOnlyDictionary<string, string> launchPaths;
// 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.
private readonly AgentConfig config;
public CommandExecutor(ILogger<CommandExecutor> logger,
AgentConfig config, TimeSpan? startTimeout = null)
{
this.logger = logger;
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths();
this.config = config ?? new AgentConfig();
this.launchPaths = this.config.ResolveLaunchPaths();
}
public async Task<CommandResult> ExecuteAsync(
@@ -103,6 +110,17 @@ public sealed class CommandExecutor
return RequireTarget(action, target)
?? await ProcessStart(target, token);
// Software delivery. run_script fetches a ShopDB-hosted script
// pinned by SHA-256 and runs it in the script's configured context
// (system or the interactive user); update_app fetches a ShopDB
// installer pinned by SHA-256 and runs it silently. Both download
// the body themselves with the collector credential and fail closed.
case "run_script":
return RequireTarget(action, target)
?? await RunScript(target, claim.Script, token);
case "update_app":
return await UpdateApp(claim.App, token);
// Fixed destructive action: reboot this endpoint immediately. Runs
// as the SYSTEM agent, so shutdown does not need a user session.
case "system_restart":
@@ -470,6 +488,433 @@ public sealed class CommandExecutor
return found;
}
// ---- Software delivery: run_script and update_app ----
// Run a ShopDB-hosted script pinned by the server's delivery descriptor. The
// wire carries no body: the agent downloads the script from BaseUrl +
// InstallPath with its own collector credential, verifies the SHA-256 (when
// the server pinned one), then runs it in the descriptor's run context -
// 'system' through the agent's own service account, or 'user' in the active
// console session so the script sees the interactive user's resources (for
// example mapped network drives). Anything missing or mismatched fails closed.
private async Task<CommandResult> RunScript(
string name, ScriptDeliveryClaim? script, CancellationToken token)
{
if (script is null || string.IsNullOrWhiteSpace(script.InstallPath))
{
logger.LogWarning(
"Rejected run_script for {Name}: no delivery descriptor on the claim", name);
return new CommandResult
{
Succeeded = false,
Message = $"run_script for {name} arrived without a resolved script descriptor",
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
};
}
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey))
{
return new CommandResult
{
Succeeded = false,
Message = "run_script cannot download: agent has no base URL or collector key",
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
};
}
var scriptUrl = CombineUrl(config.BaseUrl, script.InstallPath!);
var tempPath = Path.Combine(GetStagingDirectory(),
$"shopdb-script-{script.ScriptId}.ps1");
try
{
using var handler = new HttpClientHandler();
using var client = new HttpClient(handler);
client.Timeout = TimeSpan.FromMinutes(5);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl);
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
if (!response.IsSuccessStatusCode)
{
return new CommandResult
{
Succeeded = false,
Message = $"run_script download failed: HTTP {(int)response.StatusCode}",
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
};
}
await using (var source = await response.Content.ReadAsStreamAsync(token))
await using (var file = File.Create(tempPath))
{
await source.CopyToAsync(file, token);
}
if (!string.IsNullOrWhiteSpace(script.Sha256))
{
var actual = HashFile(tempPath);
if (!actual.Equals(script.Sha256, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(
"run_script SHA-256 mismatch for {Name}: expected {Expected} got {Actual}",
name, script.Sha256, actual);
return new CommandResult
{
Succeeded = false,
Message = "run_script integrity check failed: script SHA-256 does not match",
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
};
}
}
var runInUser = string.Equals(
script.RunContext, "user", StringComparison.OrdinalIgnoreCase);
return runInUser
? RunScriptInUserSession(name, script, tempPath)
: await RunScriptAsSystem(name, script, tempPath, token);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception error)
{
logger.LogWarning(error, "run_script for {Name} failed", name);
return new CommandResult
{
Succeeded = false,
Message = $"Script {name} failed: {error.Message}",
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
};
}
finally
{
try
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
catch (Exception cleanupError)
{
logger.LogDebug(cleanupError, "run_script temp cleanup failed: {Path}", tempPath);
}
}
}
// System context: run through the agent's own service account and wait for
// the exit code, the same as before. Fine for machine-scoped scripts that do
// not need the interactive user's session resources.
private async Task<CommandResult> RunScriptAsSystem(
string name, ScriptDeliveryClaim script, string scriptPath, CancellationToken token)
{
var startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? "",
};
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-NonInteractive");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
startInfo.ArgumentList.Add("-File");
startInfo.ArgumentList.Add(scriptPath);
using var process = Process.Start(startInfo);
if (process is null)
{
return new CommandResult
{
Succeeded = false,
Message = $"Failed to start script {name}",
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
};
}
await process.WaitForExitAsync(token);
var exit = process.ExitCode;
return new CommandResult
{
Succeeded = exit == 0,
Message = exit == 0
? $"Script {name} completed (system context)"
: $"Script {name} exited with code {exit}",
Detail = ScriptDetail(name, script, scriptPath, exit),
};
}
// User context: launch PowerShell in the ACTIVE CONSOLE session so the script
// runs at the interactive user's integrity and sees that user's resources
// (mapped network drives, HKCU, profile). The launcher waits for the process
// to exit and returns its real exit code, so success here means the script
// actually ran to a zero exit - not merely that it was launched. Fails closed
// when no interactive user is signed in.
private CommandResult RunScriptInUserSession(
string name, ScriptDeliveryClaim script, string scriptPath)
{
var quotedPath = "\"" + scriptPath + "\"";
var arguments =
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + quotedPath;
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
"powershell.exe", arguments, workingDirectory, waitForExit: true);
if (!outcome.Launched)
{
logger.LogWarning(
"run_script for {Name} could not launch in the user session: {Error}",
name, outcome.Error);
return new CommandResult
{
Succeeded = false,
Message = $"Script {name} could not run in the user session: {outcome.Error}",
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
};
}
var exit = outcome.ExitCode;
var ok = exit is null || exit == 0;
return new CommandResult
{
Succeeded = ok,
Message = exit is null
? $"Script {name} launched in the interactive user session"
: ok
? $"Script {name} completed (user context)"
: $"Script {name} exited with code {exit}",
Detail = ScriptDetail(name, script, scriptPath, exit),
};
}
private static Dictionary<string, object> ScriptDetail(
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode)
{
var detail = new Dictionary<string, object>
{
["kind"] = "script",
["name"] = name,
};
if (script is not null)
{
detail["scriptid"] = script.ScriptId;
if (!string.IsNullOrWhiteSpace(script.RunContext))
detail["runcontext"] = script.RunContext!;
}
if (scriptPath is not null)
detail["path"] = scriptPath;
if (exitCode is not null)
detail["exitcode"] = exitCode;
return detail;
}
// Fetch a ShopDB-hosted installer pinned by the server's delivery descriptor
// and run it silently. The wire carries no bytes: the agent downloads the
// package itself from BaseUrl + InstallPath using its own collector
// credential, verifies the SHA-256 (when the server pinned one), then runs
// an MSI via msiexec /qn or an EXE with the version's SilentArgs. Anything
// missing or mismatched fails closed.
private async Task<CommandResult> UpdateApp(AppDeliveryClaim? app, CancellationToken token)
{
if (app is null || string.IsNullOrWhiteSpace(app.InstallPath))
{
logger.LogWarning("Rejected update_app: no delivery descriptor on the claim");
return new CommandResult
{
Succeeded = false,
Message = "update_app arrived without a resolved package descriptor",
Detail = AppDetail(app, installerPath: null, exitCode: null),
};
}
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey))
{
return new CommandResult
{
Succeeded = false,
Message = "update_app cannot download: agent has no base URL or collector key",
Detail = AppDetail(app, installerPath: null, exitCode: null),
};
}
var packageUrl = CombineUrl(config.BaseUrl, app.InstallPath!);
var ext = Path.GetExtension(app.InstallPath) ?? "";
var packageKey = app.AppVersionId?.ToString() ?? $"app{app.AppId}";
var tempPath = Path.Combine(GetStagingDirectory(),
$"shopdb-app-{packageKey}{ext}");
try
{
using var handler = new HttpClientHandler();
using var client = new HttpClient(handler);
client.Timeout = TimeSpan.FromMinutes(10);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl);
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
if (!response.IsSuccessStatusCode)
{
return new CommandResult
{
Succeeded = false,
Message = $"update_app download failed: HTTP {(int)response.StatusCode}",
Detail = AppDetail(app, installerPath: null, exitCode: null),
};
}
await using (var source = await response.Content.ReadAsStreamAsync(token))
await using (var file = File.Create(tempPath))
{
await source.CopyToAsync(file, token);
}
if (!string.IsNullOrWhiteSpace(app.Sha256))
{
var actual = HashFile(tempPath);
if (!actual.Equals(app.Sha256, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(
"update_app SHA-256 mismatch for {App} v{Version}: expected {Expected} got {Actual}",
app.AppName, app.Version, app.Sha256, actual);
return new CommandResult
{
Succeeded = false,
Message = "update_app integrity check failed: package SHA-256 does not match",
Detail = AppDetail(app, tempPath, exitCode: null),
};
}
}
var (fileName, arguments) = BuildInstallInvocation(tempPath, ext, app.SilentArgs);
var startInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(tempPath) ?? "",
};
using var installer = Process.Start(startInfo);
if (installer is null)
{
return new CommandResult
{
Succeeded = false,
Message = $"Failed to start installer for {app.AppName}",
Detail = AppDetail(app, tempPath, exitCode: null),
};
}
await installer.WaitForExitAsync(token);
var exit = installer.ExitCode;
// MSI treats 3010 as success-pending-reboot; keep that as success.
var ok = exit == 0 || exit == 3010;
return new CommandResult
{
Succeeded = ok,
Message = ok
? $"Installed {app.AppName} v{app.Version}"
+ (exit == 3010 ? " (reboot required)" : "")
: $"Installer for {app.AppName} exited with code {exit}",
Detail = AppDetail(app, tempPath, exit),
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception error)
{
logger.LogWarning(error, "update_app for {App} failed", app.AppName);
return new CommandResult
{
Succeeded = false,
Message = $"update_app for {app.AppName} failed: {error.Message}",
Detail = AppDetail(app, tempPath, exitCode: null),
};
}
finally
{
try
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
catch (Exception cleanupError)
{
logger.LogDebug(cleanupError, "update_app temp cleanup failed: {Path}", tempPath);
}
}
}
// MSI installs run through msiexec /i <pkg> /qn (any SilentArgs appended);
// an EXE runs directly with its version's SilentArgs (homegrown EXEs vary by
// developer, so the switches are per-version server data, not guessed here).
private static (string fileName, string arguments) BuildInstallInvocation(
string packagePath, string ext, string? silentArgs)
{
var extra = (silentArgs ?? "").Trim();
if (ext.Equals(".msi", StringComparison.OrdinalIgnoreCase))
{
var args = $"/i \"{packagePath}\" /qn";
if (extra.Length > 0)
args += " " + extra;
return ("msiexec.exe", args);
}
return (packagePath, extra);
}
// Fixed, known staging folder we control and that is Defender-excluded in
// the environment. LocalSystem's Path.GetTempPath() resolves to
// C:\Windows\SystemTemp on Win11/Server2022+, which we do not use; stage
// downloads here instead so run_script and update_app land in a predictable
// spot. Created on demand.
private static string GetStagingDirectory()
{
var programData = Environment.GetFolderPath(
Environment.SpecialFolder.CommonApplicationData);
var staging = Path.Combine(programData, "ShopDB", "EndpointAgent");
Directory.CreateDirectory(staging);
return staging;
}
private static string HashFile(string path)
{
using var stream = File.OpenRead(path);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
// Join the agent base URL to a ShopDB-relative package path, tolerating a
// trailing slash on the base and a leading slash on the path.
private static string CombineUrl(string baseUrl, string path)
{
var left = baseUrl.TrimEnd('/');
var right = path.StartsWith('/') ? path : "/" + path;
return left + right;
}
private static Dictionary<string, object> AppDetail(
AppDeliveryClaim? app, string? installerPath, int? exitCode)
{
var detail = new Dictionary<string, object> { ["kind"] = "app" };
if (app is not null)
{
if (app.AppVersionId is not null) detail["appversionid"] = app.AppVersionId.Value;
detail["appid"] = app.AppId;
if (app.AppName is not null) detail["appname"] = app.AppName;
if (app.Version is not null) detail["version"] = app.Version;
}
if (installerPath is not null) detail["installer"] = installerPath;
if (exitCode is not null) detail["exitcode"] = exitCode;
return detail;
}
// ---- Fixed MTConnect actions ----
private async Task<CommandResult> MtConnectStartAsync(CancellationToken token)

View File

@@ -85,6 +85,49 @@ public sealed class CommandClaim
[JsonPropertyName("action")] public string Action { get; set; } = "";
[JsonPropertyName("target")] public string? Target { get; set; }
[JsonPropertyName("targetkind")] public string? TargetKind { get; set; }
// For update_app the server resolves the pinned application version into a
// delivery descriptor (package URL, SHA-256, silent-install switches). The
// agent fetches the bytes itself with its collector credential; the wire
// never carries the installer body. Null for every other action.
[JsonPropertyName("app")] public AppDeliveryClaim? App { get; set; }
// For run_script the server resolves the ShopDB-hosted script into a
// delivery descriptor (body URL, SHA-256, run context). The agent fetches
// the body itself with its collector credential; the wire never carries the
// script body. Null for every other action.
[JsonPropertyName("script")] public ScriptDeliveryClaim? Script { get; set; }
}
// The resolved installer-push descriptor accompanying an update_app claim.
// InstallPath is a ShopDB-relative package URL the agent joins to its BaseUrl
// and downloads with its collector credential; Sha256 pins the exact bytes;
// SilentArgs are this version's unattended-install switches (may be empty for
// an MSI, which the agent installs via msiexec /qn). AppVersionId is null when
// the push resolves an app-level install source rather than a pinned version.
public sealed class AppDeliveryClaim
{
[JsonPropertyName("appversionid")] public int? AppVersionId { get; set; }
[JsonPropertyName("appid")] public int AppId { get; set; }
[JsonPropertyName("appname")] public string? AppName { get; set; }
[JsonPropertyName("version")] public string? Version { get; set; }
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
[JsonPropertyName("silentargs")] public string? SilentArgs { get; set; }
}
// The resolved script-push descriptor accompanying a run_script claim.
// InstallPath is a ShopDB-relative body URL the agent joins to its BaseUrl and
// downloads with its collector credential; Sha256 pins the exact bytes;
// RunContext is 'system' (the agent's own service account) or 'user' (the
// interactive logged-on session), decided per script in the ShopDB library.
public sealed class ScriptDeliveryClaim
{
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
[JsonPropertyName("scriptname")] public string? ScriptName { get; set; }
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
}
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.

View File

@@ -18,7 +18,7 @@ namespace ShopDbEndpointAgent.Core;
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
public static class InteractiveProcessLauncher
{
public readonly record struct LaunchOutcome(bool Launched, string? Error);
public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null);
private const int TokenDuplicate = 0x0002;
private const int TokenQuery = 0x0008;
@@ -32,11 +32,28 @@ public static class InteractiveProcessLauncher
private const uint InvalidSessionId = 0xFFFFFFFF;
private const uint Infinite = 0xFFFFFFFF;
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
// failures come back as Launched=false with a descriptive Error so the
// caller can surface a clean CommandResult.
public static LaunchOutcome LaunchInActiveSession(
string applicationPath, string? workingDirectory)
=> LaunchInActiveSession(applicationPath, null, workingDirectory);
// Overload that also passes a command line (arguments). When commandLine is
// supplied it is handed to CreateProcessAsUser as lpCommandLine with a null
// lpApplicationName so the arguments are honored; the executable is resolved
// from the command line's first token as usual.
//
// When waitForExit is true the launcher waits for the process to exit and
// returns its real exit code in LaunchOutcome.ExitCode, so a caller that
// needs the actual outcome (run_script in the user session) can report
// completed/failed instead of fire-and-forget "launched". GUI callers
// (process_start) leave it false and return as soon as the window is up.
public static LaunchOutcome LaunchInActiveSession(
string applicationPath, string? arguments, string? workingDirectory,
bool waitForExit = false)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == InvalidSessionId)
@@ -75,10 +92,26 @@ public static class InteractiveProcessLauncher
var creationFlags = CreateUnicodeEnvironment | CreateNoWindow;
// With arguments, pass a single command line ("<exe> <args>") as
// lpCommandLine and a null lpApplicationName so the arguments apply;
// without arguments, keep the original applicationName form.
string? applicationName;
string? commandLine;
if (string.IsNullOrWhiteSpace(arguments))
{
applicationName = applicationPath;
commandLine = null;
}
else
{
applicationName = null;
commandLine = "\"" + applicationPath + "\" " + arguments;
}
var launched = CreateProcessAsUser(
duplicatedToken,
applicationPath,
null,
applicationName,
commandLine,
IntPtr.Zero,
IntPtr.Zero,
false,
@@ -95,12 +128,20 @@ public static class InteractiveProcessLauncher
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
}
int? exitCode = null;
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
{
WaitForSingleObject(processInformation.hProcess, Infinite);
if (GetExitCodeProcess(processInformation.hProcess, out var code))
exitCode = unchecked((int)code);
}
if (processInformation.hProcess != IntPtr.Zero)
CloseHandle(processInformation.hProcess);
if (processInformation.hThread != IntPtr.Zero)
CloseHandle(processInformation.hThread);
return new LaunchOutcome(true, null);
return new LaunchOutcome(true, null, exitCode);
}
finally
{
@@ -197,4 +238,10 @@ public static class InteractiveProcessLauncher
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
}

View File

@@ -7,11 +7,19 @@
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.2.0: process_start launches via a per-image path allowlist
<!-- 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".
1.4.0: run_script fetches a ShopDB-hosted script by digest and runs it
in the script's configured context (system or the interactive user),
replacing the local scriptpaths allowlist; update_app resolves an app-
level or version-level install source.
1.2.0: process_start launches via a per-image path allowlist
(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.3.0</Version>
<Version>1.4.1</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>