From bd8eacfe05617fa510c52a37eba926f6b8caf11b Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Sun, 30 Aug 2026 14:41:10 -0400 Subject: [PATCH] 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. --- agent/ShopDbEndpointAgent.Core/AgentConfig.cs | 33 ++ .../CommandExecutor.cs | 447 +++++++++++++++++- agent/ShopDbEndpointAgent.Core/Contracts.cs | 43 ++ .../InteractiveProcessLauncher.cs | 55 ++- .../ShopDbEndpointAgent.csproj | 12 +- 5 files changed, 583 insertions(+), 7 deletions(-) diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 78bddb68..86672779 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -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 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 ResolveScriptPaths() + { + var resolved = new Dictionary(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) diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index c739bdc7..9ffae27c 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -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 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 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 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 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 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 ScriptDetail( + string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode) + { + var detail = new Dictionary + { + ["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 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 /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 AppDetail( + AppDeliveryClaim? app, string? installerPath, int? exitCode) + { + var detail = new Dictionary { ["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 MtConnectStartAsync(CancellationToken token) diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index e35af45b..d4c92a0a 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -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 } }. diff --git a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs index 3b028bcb..326bdc07 100644 --- a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs +++ b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs @@ -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 (" ") 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); } diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 3612a79a..9c7d55bf 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,11 +7,19 @@ enable ShopDbEndpointAgent shopdbagent - - 1.3.0 + 1.4.1 true win-x64 true