diff --git a/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs
new file mode 100644
index 0000000..41275bc
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs
@@ -0,0 +1,287 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+using static WebhookServer.Core.Execution.Native.NativeMethods;
+
+namespace WebhookServer.Core.Execution.Native;
+
+///
+/// Launches a child process inside the active console session under whoever is
+/// logged in at the keyboard. Required when running as SYSTEM (the service account)
+/// because Process.Start would land the child in Session 0 where it can't
+/// show UI on the user's desktop.
+///
+/// Caller must already be SYSTEM (the service runs as SYSTEM by default) — only
+/// SYSTEM can call WTSQueryUserToken.
+///
+[SupportedOSPlatform("windows")]
+internal static class InteractiveProcessLauncher
+{
+ public sealed class LaunchOptions
+ {
+ public required string FileName { get; init; }
+ public required IReadOnlyList Arguments { get; init; }
+ public string? WorkingDirectory { get; init; }
+ public IReadOnlyDictionary? ExtraEnvVars { get; init; }
+ public byte[]? StdinBytes { get; init; }
+ }
+
+ public sealed class LaunchResult : IDisposable
+ {
+ public required IntPtr ProcessHandle { get; init; }
+ public required uint ProcessId { get; init; }
+ public required StreamReader Stdout { get; init; }
+ public required StreamReader Stderr { get; init; }
+
+ public void Dispose()
+ {
+ try { Stdout.Dispose(); } catch { }
+ try { Stderr.Dispose(); } catch { }
+ if (ProcessHandle != IntPtr.Zero)
+ try { CloseHandle(ProcessHandle); } catch { }
+ }
+ }
+
+ public static LaunchResult Launch(LaunchOptions opts)
+ {
+ var sessionId = WTSGetActiveConsoleSessionId();
+ if (sessionId == INVALID_SESSION_ID)
+ throw new InvalidOperationException("No active console session - is anyone logged in at the keyboard?");
+
+ if (!WTSQueryUserToken(sessionId, out var userToken))
+ throw LastError("WTSQueryUserToken (must run as SYSTEM)");
+
+ IntPtr primaryToken = IntPtr.Zero;
+ IntPtr envBlock = IntPtr.Zero;
+ IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
+ IntPtr stderrRead = IntPtr.Zero, stderrWrite = IntPtr.Zero;
+ IntPtr stdinRead = IntPtr.Zero, stdinWrite = IntPtr.Zero;
+ var pi = new PROCESS_INFORMATION();
+ bool succeeded = false;
+
+ try
+ {
+ if (!DuplicateTokenEx(userToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
+ SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
+ TOKEN_TYPE.TokenPrimary, out primaryToken))
+ throw LastError("DuplicateTokenEx");
+
+ if (!CreateEnvironmentBlock(out envBlock, primaryToken, false))
+ throw LastError("CreateEnvironmentBlock");
+
+ if (opts.ExtraEnvVars is { Count: > 0 })
+ envBlock = AppendEnvVars(envBlock, opts.ExtraEnvVars);
+
+ CreateInheritablePipe(out stdoutRead, out stdoutWrite, parentReads: true);
+ CreateInheritablePipe(out stderrRead, out stderrWrite, parentReads: true);
+ CreateInheritablePipe(out stdinRead, out stdinWrite, parentReads: false);
+
+ var si = new STARTUPINFO
+ {
+ cb = Marshal.SizeOf(),
+ dwFlags = STARTF_USESTDHANDLES,
+ hStdInput = stdinRead,
+ hStdOutput = stdoutWrite,
+ hStdError = stderrWrite,
+ lpDesktop = @"winsta0\default",
+ };
+
+ var commandLine = BuildCommandLine(opts.FileName, opts.Arguments);
+
+ if (!CreateProcessAsUser(
+ primaryToken,
+ null,
+ commandLine,
+ IntPtr.Zero, IntPtr.Zero,
+ bInheritHandles: true,
+ CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS,
+ envBlock,
+ string.IsNullOrEmpty(opts.WorkingDirectory) ? null : opts.WorkingDirectory,
+ ref si,
+ out pi))
+ {
+ throw LastError("CreateProcessAsUser");
+ }
+
+ // Close child-side handles in our process so EOF propagates when the child exits.
+ CloseHandle(stdoutWrite); stdoutWrite = IntPtr.Zero;
+ CloseHandle(stderrWrite); stderrWrite = IntPtr.Zero;
+ CloseHandle(stdinRead); stdinRead = IntPtr.Zero;
+
+ // Pipe stdin if provided, then close the write end.
+ if (opts.StdinBytes is { Length: > 0 })
+ {
+ using var stdinStream = new FileStream(new SafeFileHandle(stdinWrite, ownsHandle: true), FileAccess.Write);
+ stdinStream.Write(opts.StdinBytes, 0, opts.StdinBytes.Length);
+ stdinStream.Flush();
+ stdinWrite = IntPtr.Zero; // ownership transferred to the FileStream
+ }
+ else
+ {
+ CloseHandle(stdinWrite); stdinWrite = IntPtr.Zero;
+ }
+
+ CloseHandle(pi.hThread);
+
+ var stdout = new StreamReader(new FileStream(new SafeFileHandle(stdoutRead, true), FileAccess.Read), Encoding.UTF8);
+ var stderr = new StreamReader(new FileStream(new SafeFileHandle(stderrRead, true), FileAccess.Read), Encoding.UTF8);
+ stdoutRead = IntPtr.Zero;
+ stderrRead = IntPtr.Zero;
+
+ succeeded = true;
+ return new LaunchResult
+ {
+ ProcessHandle = pi.hProcess,
+ ProcessId = pi.dwProcessId,
+ Stdout = stdout,
+ Stderr = stderr,
+ };
+ }
+ finally
+ {
+ if (userToken != IntPtr.Zero) CloseHandle(userToken);
+ if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
+ if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
+ if (!succeeded)
+ {
+ if (stdoutRead != IntPtr.Zero) CloseHandle(stdoutRead);
+ if (stdoutWrite != IntPtr.Zero) CloseHandle(stdoutWrite);
+ if (stderrRead != IntPtr.Zero) CloseHandle(stderrRead);
+ if (stderrWrite != IntPtr.Zero) CloseHandle(stderrWrite);
+ if (stdinRead != IntPtr.Zero) CloseHandle(stdinRead);
+ if (stdinWrite != IntPtr.Zero) CloseHandle(stdinWrite);
+ if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
+ if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
+ }
+ }
+ }
+
+ public static async Task WaitAsync(IntPtr processHandle, TimeSpan timeout, CancellationToken ct)
+ {
+ // Poll WaitForSingleObject in 100ms slices to honor cancellation/timeout cheaply.
+ var deadline = DateTime.UtcNow + timeout;
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+ var remaining = deadline - DateTime.UtcNow;
+ if (remaining <= TimeSpan.Zero) throw new TimeoutException();
+
+ var slice = (uint)Math.Min(100, (int)remaining.TotalMilliseconds);
+ var result = WaitForSingleObject(processHandle, slice);
+ if (result == 0) // WAIT_OBJECT_0
+ {
+ if (!GetExitCodeProcess(processHandle, out var code))
+ throw LastError("GetExitCodeProcess");
+ return (int)code;
+ }
+ if (result == 0xFFFFFFFF)
+ throw LastError("WaitForSingleObject");
+ await Task.Yield();
+ }
+ }
+
+ public static void Kill(IntPtr processHandle)
+ {
+ try { TerminateProcess(processHandle, 1); } catch { }
+ }
+
+ private static void CreateInheritablePipe(out IntPtr read, out IntPtr write, bool parentReads)
+ {
+ var sa = new SECURITY_ATTRIBUTES
+ {
+ nLength = Marshal.SizeOf(),
+ bInheritHandle = 1,
+ lpSecurityDescriptor = IntPtr.Zero,
+ };
+ if (!CreatePipe(out read, out write, ref sa, 0))
+ throw LastError("CreatePipe");
+
+ // Mark the parent's end non-inheritable so it's not duplicated into the child.
+ var parentEnd = parentReads ? read : write;
+ if (!SetHandleInformation(parentEnd, HANDLE_FLAG_INHERIT, 0))
+ throw LastError("SetHandleInformation");
+ }
+
+ private static IntPtr AppendEnvVars(IntPtr existingBlock, IReadOnlyDictionary extras)
+ {
+ // The existing block is a sequence of null-terminated WCHAR strings ending with
+ // an extra null. Walk it byte-pair by byte-pair to find the end.
+ var parsed = new List();
+ var offset = 0;
+ while (true)
+ {
+ var ch0 = Marshal.ReadInt16(existingBlock, offset);
+ if (ch0 == 0) break;
+ var entry = Marshal.PtrToStringUni(existingBlock + offset)!;
+ parsed.Add(entry);
+ offset += (entry.Length + 1) * sizeof(char);
+ }
+ DestroyEnvironmentBlock(existingBlock);
+
+ var combined = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in parsed)
+ {
+ var eq = entry.IndexOf('=');
+ if (eq <= 0) continue;
+ combined[entry.Substring(0, eq)] = entry.Substring(eq + 1);
+ }
+ foreach (var (k, v) in extras) combined[k] = v;
+
+ var sb = new StringBuilder();
+ foreach (var (k, v) in combined)
+ {
+ sb.Append(k).Append('=').Append(v).Append('\0');
+ }
+ sb.Append('\0');
+
+ var bytes = Encoding.Unicode.GetBytes(sb.ToString());
+ var ptr = Marshal.AllocHGlobal(bytes.Length);
+ Marshal.Copy(bytes, 0, ptr, bytes.Length);
+ return ptr;
+ }
+
+ ///
+ /// Build a Windows command line string from a filename + arg list using the
+ /// quoting rules consumed by CommandLineToArgvW.
+ ///
+ private static string BuildCommandLine(string fileName, IReadOnlyList args)
+ {
+ var sb = new StringBuilder();
+ AppendArg(sb, fileName);
+ foreach (var arg in args)
+ {
+ sb.Append(' ');
+ AppendArg(sb, arg);
+ }
+ return sb.ToString();
+ }
+
+ private static void AppendArg(StringBuilder sb, string arg)
+ {
+ // Empty arg → "".
+ if (arg.Length == 0) { sb.Append("\"\""); return; }
+
+ var needsQuoting = arg.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) >= 0;
+ if (!needsQuoting) { sb.Append(arg); return; }
+
+ sb.Append('"');
+ var backslashes = 0;
+ foreach (var ch in arg)
+ {
+ if (ch == '\\') { backslashes++; continue; }
+ if (ch == '"')
+ {
+ sb.Append('\\', backslashes * 2 + 1);
+ sb.Append('"');
+ backslashes = 0;
+ continue;
+ }
+ if (backslashes > 0) { sb.Append('\\', backslashes); backslashes = 0; }
+ sb.Append(ch);
+ }
+ // Trailing backslashes before closing quote must be doubled.
+ if (backslashes > 0) sb.Append('\\', backslashes * 2);
+ sb.Append('"');
+ }
+}
diff --git a/src/WebhookServer.Core/Execution/Native/NativeMethods.cs b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs
new file mode 100644
index 0000000..b809674
--- /dev/null
+++ b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs
@@ -0,0 +1,144 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace WebhookServer.Core.Execution.Native;
+
+///
+/// Win32 P/Invoke layer for launching processes in another user's session.
+/// Used by ; not intended for general use.
+///
+[SupportedOSPlatform("windows")]
+internal static class NativeMethods
+{
+ public const uint INVALID_SESSION_ID = 0xFFFFFFFF;
+ public const int MAXIMUM_ALLOWED = 0x02000000;
+
+ public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
+ public const uint CREATE_NO_WINDOW = 0x08000000;
+ public const uint CREATE_NEW_CONSOLE = 0x00000010;
+ public const uint NORMAL_PRIORITY_CLASS = 0x00000020;
+
+ public const int STARTF_USESTDHANDLES = 0x00000100;
+
+ public const int HANDLE_FLAG_INHERIT = 1;
+
+ public const uint INFINITE = 0xFFFFFFFF;
+
+ public enum SECURITY_IMPERSONATION_LEVEL
+ {
+ SecurityAnonymous = 0,
+ SecurityIdentification = 1,
+ SecurityImpersonation = 2,
+ SecurityDelegation = 3,
+ }
+
+ public enum TOKEN_TYPE
+ {
+ TokenPrimary = 1,
+ TokenImpersonation = 2,
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct SECURITY_ATTRIBUTES
+ {
+ public int nLength;
+ public IntPtr lpSecurityDescriptor;
+ public int bInheritHandle;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ public struct STARTUPINFO
+ {
+ public int cb;
+ public string? lpReserved;
+ public string? lpDesktop;
+ public string? lpTitle;
+ public uint dwX;
+ public uint dwY;
+ public uint dwXSize;
+ public uint dwYSize;
+ public uint dwXCountChars;
+ public uint dwYCountChars;
+ public uint dwFillAttribute;
+ public uint dwFlags;
+ public ushort wShowWindow;
+ public ushort cbReserved2;
+ public IntPtr lpReserved2;
+ public IntPtr hStdInput;
+ public IntPtr hStdOutput;
+ public IntPtr hStdError;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct PROCESS_INFORMATION
+ {
+ public IntPtr hProcess;
+ public IntPtr hThread;
+ public uint dwProcessId;
+ public uint dwThreadId;
+ }
+
+ [DllImport("kernel32.dll")]
+ public static extern uint WTSGetActiveConsoleSessionId();
+
+ [DllImport("wtsapi32.dll", SetLastError = true)]
+ public static extern bool WTSQueryUserToken(uint sessionId, out IntPtr phToken);
+
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern bool DuplicateTokenEx(
+ IntPtr hExistingToken,
+ uint dwDesiredAccess,
+ IntPtr lpTokenAttributes,
+ SECURITY_IMPERSONATION_LEVEL impersonationLevel,
+ TOKEN_TYPE tokenType,
+ out IntPtr phNewToken);
+
+ [DllImport("userenv.dll", SetLastError = true)]
+ public static extern bool CreateEnvironmentBlock(
+ out IntPtr lpEnvironment,
+ IntPtr hToken,
+ bool bInherit);
+
+ [DllImport("userenv.dll", SetLastError = true)]
+ public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
+
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern bool CreateProcessAsUser(
+ IntPtr hToken,
+ string? lpApplicationName,
+ string? lpCommandLine,
+ IntPtr lpProcessAttributes,
+ IntPtr lpThreadAttributes,
+ bool bInheritHandles,
+ uint dwCreationFlags,
+ IntPtr lpEnvironment,
+ string? lpCurrentDirectory,
+ ref STARTUPINFO lpStartupInfo,
+ out PROCESS_INFORMATION lpProcessInformation);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ public static extern bool CreatePipe(
+ out IntPtr hReadPipe,
+ out IntPtr hWritePipe,
+ ref SECURITY_ATTRIBUTES lpPipeAttributes,
+ uint nSize);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ public static extern bool SetHandleInformation(IntPtr hObject, int dwMask, int dwFlags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ public static extern bool CloseHandle(IntPtr hObject);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ public static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
+
+ public static Win32Exception LastError(string what) =>
+ new(Marshal.GetLastWin32Error(), $"{what} failed");
+}
diff --git a/src/WebhookServer.Core/Execution/ProcessExecutor.cs b/src/WebhookServer.Core/Execution/ProcessExecutor.cs
index ad8d6c5..d627669 100644
--- a/src/WebhookServer.Core/Execution/ProcessExecutor.cs
+++ b/src/WebhookServer.Core/Execution/ProcessExecutor.cs
@@ -1,9 +1,13 @@
using System.Diagnostics;
+using System.Runtime.Versioning;
+using System.Security;
using System.Text;
+using WebhookServer.Core.Execution.Native;
using WebhookServer.Core.Models;
namespace WebhookServer.Core.Execution;
+[SupportedOSPlatform("windows")]
public sealed class ProcessExecutor : IExecutor
{
/// Per-stream cap on captured output (excess is dropped and StdoutTruncated set).
@@ -12,23 +16,40 @@ public sealed class ProcessExecutor : IExecutor
public async Task RunAsync(EndpointConfig endpoint, ExecutionContext ctx, CancellationToken ct)
{
var startedAt = DateTimeOffset.UtcNow;
- var psi = BuildStartInfo(endpoint, ctx);
+ var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service;
+ return mode switch
+ {
+ RunAsMode.InteractiveUser => await RunInteractiveAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false),
+ _ => await RunWithProcessAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false),
+ };
+ }
+
+ // ---------------- Process path: handles Service (default) and SpecificUser. ----------------
+
+ private async Task RunWithProcessAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct)
+ {
+ var (psi, envVars) = BuildStartInfo(endpoint, ctx);
+ foreach (var (k, v) in envVars)
+ psi.Environment[k] = v;
+
+ if (endpoint.RunAs?.Mode == RunAsMode.SpecificUser)
+ {
+ try { ApplySpecificUser(psi, endpoint.RunAs); }
+ catch (Exception ex) { return Failed(ctx.RunId, startedAt, ex.Message); }
+ }
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
try
{
if (!process.Start())
- {
return Failed(ctx.RunId, startedAt, "process failed to start");
- }
}
catch (Exception ex)
{
return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
}
- // stdin
if (endpoint.DataPassing.StdinJson)
{
try
@@ -42,15 +63,14 @@ public sealed class ProcessExecutor : IExecutor
}
finally
{
- try { process.StandardInput.Close(); } catch { /* swallow */ }
+ try { process.StandardInput.Close(); } catch { }
}
}
else
{
- try { process.StandardInput.Close(); } catch { /* swallow */ }
+ try { process.StandardInput.Close(); } catch { }
}
- // Capture stdout/stderr in parallel, with per-stream cap.
var stdoutTask = ReadCappedAsync(process.StandardOutput, ct);
var stderrTask = ReadCappedAsync(process.StandardError, ct);
@@ -66,8 +86,8 @@ public sealed class ProcessExecutor : IExecutor
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
timedOut = true;
- try { process.Kill(entireProcessTree: true); } catch { /* swallow */ }
- try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* swallow */ }
+ try { process.Kill(entireProcessTree: true); } catch { }
+ try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch { }
}
var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
@@ -87,7 +107,72 @@ public sealed class ProcessExecutor : IExecutor
};
}
- private static ProcessStartInfo BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
+ // ---------------- Interactive path: launches into the active console session. ----------------
+
+ private static async Task RunInteractiveAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct)
+ {
+ var (psi, envVars) = BuildStartInfo(endpoint, ctx);
+
+ InteractiveProcessLauncher.LaunchResult launch;
+ try
+ {
+ launch = InteractiveProcessLauncher.Launch(new InteractiveProcessLauncher.LaunchOptions
+ {
+ FileName = psi.FileName,
+ Arguments = psi.ArgumentList.ToList(),
+ WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory,
+ ExtraEnvVars = envVars,
+ StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null,
+ });
+ }
+ catch (Exception ex)
+ {
+ return Failed(ctx.RunId, startedAt, $"interactive launch error: {ex.Message}");
+ }
+
+ try
+ {
+ var stdoutTask = ReadCappedAsync(launch.Stdout, ct);
+ var stderrTask = ReadCappedAsync(launch.Stderr, ct);
+
+ var timeout = TimeSpan.FromSeconds(Math.Max(1, endpoint.TimeoutSeconds));
+ bool timedOut = false;
+ int exitCode = -1;
+ try
+ {
+ exitCode = await InteractiveProcessLauncher.WaitAsync(launch.ProcessHandle, timeout, ct).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ timedOut = true;
+ InteractiveProcessLauncher.Kill(launch.ProcessHandle);
+ }
+
+ var (stdout, stdoutTrunc) = await stdoutTask.ConfigureAwait(false);
+ var (stderr, stderrTrunc) = await stderrTask.ConfigureAwait(false);
+
+ return new ExecutionResult
+ {
+ RunId = ctx.RunId,
+ ExitCode = timedOut ? -1 : exitCode,
+ Stdout = stdout,
+ Stderr = stderr,
+ StdoutTruncated = stdoutTrunc,
+ StderrTruncated = stderrTrunc,
+ StartedAt = startedAt,
+ CompletedAt = DateTimeOffset.UtcNow,
+ TimedOut = timedOut,
+ };
+ }
+ finally
+ {
+ launch.Dispose();
+ }
+ }
+
+ // ---------------- Shared psi construction. ----------------
+
+ private static (ProcessStartInfo psi, Dictionary envVars) BuildStartInfo(EndpointConfig endpoint, ExecutionContext ctx)
{
var psi = new ProcessStartInfo
{
@@ -131,18 +216,19 @@ public sealed class ProcessExecutor : IExecutor
psi.ArgumentList.Add(arg);
}
+ var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["WEBHOOK_RUN_ID"] = ctx.RunId,
+ ["WEBHOOK_SLUG"] = ctx.Slug,
+ };
+
if (endpoint.DataPassing.EnvVars)
{
- foreach (var (k, v) in ctx.Headers)
- psi.Environment[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
- foreach (var (k, v) in ctx.Query)
- psi.Environment[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
+ foreach (var (k, v) in ctx.Headers) envVars[$"WEBHOOK_HEADER_{Sanitize(k)}"] = v;
+ foreach (var (k, v) in ctx.Query) envVars[$"WEBHOOK_QUERY_{Sanitize(k)}"] = v;
}
- psi.Environment["WEBHOOK_RUN_ID"] = ctx.RunId;
- psi.Environment["WEBHOOK_SLUG"] = ctx.Slug;
-
- return psi;
+ return (psi, envVars);
}
private static void AddPwshArgs(ProcessStartInfo psi, EndpointConfig endpoint)
@@ -175,6 +261,32 @@ public sealed class ProcessExecutor : IExecutor
return endpoint.InlineCommand ?? "";
}
+ private static void ApplySpecificUser(ProcessStartInfo psi, RunAsConfig runAs)
+ {
+ if (string.IsNullOrEmpty(runAs.Username))
+ throw new InvalidOperationException("RunAs.Username is required when Mode = SpecificUser");
+ if (runAs.Password?.Plaintext is not { Length: > 0 } password)
+ throw new InvalidOperationException("RunAs.Password is required when Mode = SpecificUser");
+
+ var (domain, user) = ParseUserSpec(runAs.Username);
+ psi.UserName = user;
+ if (!string.IsNullOrEmpty(domain)) psi.Domain = domain;
+
+ var ss = new SecureString();
+ foreach (var ch in password) ss.AppendChar(ch);
+ ss.MakeReadOnly();
+ psi.Password = ss;
+
+ psi.LoadUserProfile = runAs.LoadProfile;
+ }
+
+ private static (string Domain, string User) ParseUserSpec(string spec)
+ {
+ var bs = spec.IndexOf('\\');
+ if (bs > 0) return (spec.Substring(0, bs), spec.Substring(bs + 1));
+ return ("", spec);
+ }
+
private static string Sanitize(string key)
{
var sb = new StringBuilder(key.Length);
@@ -203,7 +315,6 @@ public sealed class ProcessExecutor : IExecutor
catch (IOException) { break; }
if (n == 0) break;
- // Cheap byte estimate (ASCII-ish); good enough as a guard rail.
if (!truncated)
{
if (byteEstimate + n > MaxOutputBytes)
@@ -218,7 +329,6 @@ public sealed class ProcessExecutor : IExecutor
byteEstimate += n;
}
}
- // Else keep draining without storing to keep the pipe from blocking.
}
return (sb.ToString(), truncated);
diff --git a/src/WebhookServer.Core/Models/EndpointConfig.cs b/src/WebhookServer.Core/Models/EndpointConfig.cs
index 87c0f7b..3c9b415 100644
--- a/src/WebhookServer.Core/Models/EndpointConfig.cs
+++ b/src/WebhookServer.Core/Models/EndpointConfig.cs
@@ -42,4 +42,6 @@ public sealed class EndpointConfig
public bool Serialize { get; set; }
public CallbackConfig? Callback { get; set; }
+
+ public RunAsConfig? RunAs { get; set; }
}
diff --git a/src/WebhookServer.Core/Models/Enums.cs b/src/WebhookServer.Core/Models/Enums.cs
index 3fd094b..429c8bf 100644
--- a/src/WebhookServer.Core/Models/Enums.cs
+++ b/src/WebhookServer.Core/Models/Enums.cs
@@ -53,3 +53,18 @@ public enum HttpsBindingKind
PfxFile = 1,
CertStoreThumbprint = 2,
}
+
+public enum RunAsMode
+{
+ /// Run as whatever account the service itself runs under (default).
+ Service = 0,
+
+ /// Run as a specific username + password (batch logon, no UI).
+ SpecificUser = 1,
+
+ ///
+ /// Run in the active console session under whoever is logged in at the keyboard.
+ /// Lets hooks pop interactive UI on the user's desktop.
+ ///
+ InteractiveUser = 2,
+}
diff --git a/src/WebhookServer.Core/Models/RunAsConfig.cs b/src/WebhookServer.Core/Models/RunAsConfig.cs
new file mode 100644
index 0000000..47d648d
--- /dev/null
+++ b/src/WebhookServer.Core/Models/RunAsConfig.cs
@@ -0,0 +1,21 @@
+namespace WebhookServer.Core.Models;
+
+public sealed class RunAsConfig
+{
+ public RunAsMode Mode { get; set; } = RunAsMode.Service;
+
+ ///
+ /// "DOMAIN\user" or "user@upn" or just "user" (local). Required when
+ /// is .
+ ///
+ public string? Username { get; set; }
+
+ /// DPAPI-protected password for SpecificUser mode.
+ public ProtectedString? Password { get; set; }
+
+ ///
+ /// When true, load the user's profile (HKCU + AppData) before running.
+ /// Slower; only needed for hooks that read user-scope settings.
+ ///
+ public bool LoadProfile { get; set; }
+}
diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs
index e83aaf6..7e8b9a4 100644
--- a/src/WebhookServer.Core/Storage/ConfigStore.cs
+++ b/src/WebhookServer.Core/Storage/ConfigStore.cs
@@ -55,6 +55,7 @@ public sealed class ConfigStore
{
ClearOne(ep.Bearer?.Secret);
ClearOne(ep.Hmac?.Secret);
+ ClearOne(ep.RunAs?.Password);
if (ep.Callback is { } cb)
{
ClearOne(cb.Bearer?.Secret);
@@ -76,6 +77,7 @@ public sealed class ConfigStore
{
DecryptOne(ep.Bearer?.Secret);
DecryptOne(ep.Hmac?.Secret);
+ DecryptOne(ep.RunAs?.Password);
if (ep.Callback is { } cb)
{
DecryptOne(cb.Bearer?.Secret);
@@ -91,6 +93,7 @@ public sealed class ConfigStore
{
EncryptOne(ep.Bearer?.Secret);
EncryptOne(ep.Hmac?.Secret);
+ EncryptOne(ep.RunAs?.Password);
if (ep.Callback is { } cb)
{
EncryptOne(cb.Bearer?.Secret);
diff --git a/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
index dd63d0b..bafcd7a 100644
--- a/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
+++ b/src/WebhookServer.Gui/ViewModels/EndpointEditorViewModel.cs
@@ -23,6 +23,8 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
Endpoint = JsonSerializer.Deserialize(json, ConfigJson.Compact)!;
Endpoint.Bearer ??= new BearerOptions();
Endpoint.Hmac ??= new HmacOptions();
+ Endpoint.RunAs ??= new RunAsConfig();
+ Endpoint.RunAs.Password ??= new ProtectedString();
IsNew = isNew;
}
@@ -53,6 +55,58 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
public Visibility HmacVisible =>
Endpoint.AuthMode == AuthMode.Hmac ? Visibility.Visible : Visibility.Collapsed;
+ public Array RunAsModes { get; } = Enum.GetValues(typeof(RunAsMode));
+
+ public RunAsMode SelectedRunAsMode
+ {
+ get => Endpoint.RunAs?.Mode ?? RunAsMode.Service;
+ set
+ {
+ Endpoint.RunAs ??= new RunAsConfig();
+ if (Endpoint.RunAs.Mode == value) return;
+ Endpoint.RunAs.Mode = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SpecificUserVisible));
+ }
+ }
+
+ public Visibility SpecificUserVisible =>
+ SelectedRunAsMode == RunAsMode.SpecificUser ? Visibility.Visible : Visibility.Collapsed;
+
+ public string RunAsUsername
+ {
+ get => Endpoint.RunAs?.Username ?? "";
+ set
+ {
+ Endpoint.RunAs ??= new RunAsConfig();
+ Endpoint.RunAs.Username = string.IsNullOrEmpty(value) ? null : value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string RunAsPassword
+ {
+ get => Endpoint.RunAs?.Password?.Plaintext ?? "";
+ set
+ {
+ Endpoint.RunAs ??= new RunAsConfig();
+ Endpoint.RunAs.Password ??= new ProtectedString();
+ Endpoint.RunAs.Password.Plaintext = string.IsNullOrEmpty(value) ? null : value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool RunAsLoadProfile
+ {
+ get => Endpoint.RunAs?.LoadProfile ?? false;
+ set
+ {
+ Endpoint.RunAs ??= new RunAsConfig();
+ Endpoint.RunAs.LoadProfile = value;
+ OnPropertyChanged();
+ }
+ }
+
public string AllowedClientsText
{
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);
diff --git a/src/WebhookServer.Gui/Views/EndpointEditor.xaml b/src/WebhookServer.Gui/Views/EndpointEditor.xaml
index 9afce5b..0d3ef95 100644
--- a/src/WebhookServer.Gui/Views/EndpointEditor.xaml
+++ b/src/WebhookServer.Gui/Views/EndpointEditor.xaml
@@ -130,6 +130,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs
index 780b4e9..2e7d25b 100644
--- a/src/WebhookServer.Service/AdminPipeServer.cs
+++ b/src/WebhookServer.Service/AdminPipeServer.cs
@@ -255,6 +255,7 @@ internal sealed class AdminPipeServer : BackgroundService
{
if (incoming.Bearer is { } a) MergeProtected(a.Secret, prior.Bearer?.Secret);
if (incoming.Hmac is { } h) MergeProtected(h.Secret, prior.Hmac?.Secret);
+ if (incoming.RunAs is { Password: { } runAsPwd }) MergeProtected(runAsPwd, prior.RunAs?.Password);
if (incoming.Callback is { } cb)
{
if (cb.Bearer is { } cba) MergeProtected(cba.Secret, prior.Callback?.Bearer?.Secret);