diff --git a/README.md b/README.md
index d73701b2..d2aca671 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
This repository builds a Windows MSIX package containing:
-- one .NET 10 NativeAOT launcher exposed through the `openclaw` and `clawctl`
- app execution aliases;
+- one .NET 10 NativeAOT launcher exposed through separate packaged
+ `openclaw` and `clawctl` application identities and app execution aliases;
- a pinned, verified build of
[`openclaw/openclaw`](https://github.com/openclaw/openclaw);
- the official Node.js archive matching the upstream build's runtime version
@@ -17,9 +17,10 @@ packages.
## Command model
-Both aliases activate the same packaged `openclaw.exe`. The launcher recovers
-the alias used to start it from the native process command line and selects one
-of two deliberately separate surfaces.
+Both application identities activate the same packaged `openclaw.exe`. The
+launcher recovers either the package-qualified application identity or the alias
+used to start it from the native process command line and selects one of two
+deliberately separate surfaces.
### `openclaw`
diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1
index 849869aa..82c823a7 100644
--- a/scripts/Build-MSIX.ps1
+++ b/scripts/Build-MSIX.ps1
@@ -560,17 +560,18 @@ try {
"//*[local-name()='Extension' and @Category='windows.appExecutionAlias']"
)
)
- if ($aliasExtension.Count -ne 1) {
- throw 'The MSIX must contain one app execution alias extension.'
+ if ($aliasExtension.Count -ne 2) {
+ throw 'The MSIX must contain public and control app execution alias extensions.'
}
- if ($aliasExtension[0].Executable -ne 'openclaw.exe') {
- throw 'Both command aliases must target openclaw.exe.'
+ foreach ($extension in $aliasExtension) {
+ if ($extension.Executable -ne 'openclaw.exe') {
+ throw 'Both command aliases must target openclaw.exe.'
+ }
}
$registeredAliases = @(
- $aliasExtension[0].SelectNodes(
- ".//*[local-name()='ExecutionAlias']"
- ) |
+ $aliasExtension |
+ ForEach-Object { $_.SelectNodes(".//*[local-name()='ExecutionAlias']") } |
ForEach-Object { $_.Alias }
)
foreach ($requiredAlias in @('openclaw.exe', 'clawctl.exe')) {
diff --git a/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs
new file mode 100644
index 00000000..979b9c26
--- /dev/null
+++ b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs
@@ -0,0 +1,189 @@
+namespace OpenClaw.Launcher.Gateway;
+
+///
+/// Generates the launcher files both persistence lanes invoke.
+///
+internal static class GatewayLauncherScript
+{
+ ///
+ /// Identifies a file this installation generated, so a file that happens to
+ /// share the name is reported rather than silently overwritten.
+ ///
+ public const string Marker = "@rem openclaw-gateway-launcher v1";
+
+ private const string PowerShellMarker = "# openclaw-gateway-launcher v1";
+
+ public const string ControlApplicationId = "Control";
+
+ public const string ControlArguments = "gateway-service start";
+
+ public static string Create(string workingDirectory, string activationScriptPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(activationScriptPath);
+
+ // At logon a task's working directory is the system directory. The
+ // gateway is given an explicit, controlled one instead, so it never
+ // writes relative paths into a system location.
+ return string.Join(
+ "\r\n",
+ "@echo off",
+ Marker,
+ "@rem Generated by OpenClaw. Edits are overwritten on the next install.",
+ "chcp 65001 >nul",
+ "setlocal",
+ $"cd /d \"{workingDirectory}\" || exit /b 1",
+ $"\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"{activationScriptPath}\"",
+ "exit /b %ERRORLEVEL%",
+ string.Empty);
+ }
+
+ public static string CreateActivationScript(string packageFamilyName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageFamilyName);
+
+ string applicationUserModelId =
+ $"{packageFamilyName}!{ControlApplicationId}";
+ return string.Join(
+ "\r\n",
+ PowerShellMarker,
+ "$ErrorActionPreference = 'Stop'",
+ $"$packageFamilyName = '{QuotePowerShell(packageFamilyName)}'",
+ $"$applicationId = '{ControlApplicationId}'",
+ $"$arguments = '{ControlArguments}'",
+ $"$applicationUserModelId = '{QuotePowerShell(applicationUserModelId)}'",
+ "$package = Get-AppxPackage -Name 'OpenClaw.Gateway' | " +
+ "Where-Object { $_.PackageFamilyName -eq $packageFamilyName } | " +
+ "Select-Object -First 1",
+ "if ($null -eq $package) { throw \"Package '$packageFamilyName' is not registered for the current user.\" }",
+ "if ($package.Status -inotmatch '^(Ok|Ready)$') { throw \"Package '$packageFamilyName' is not ready (status $($package.Status)).\" }",
+ "$manifestPath = Join-Path $package.InstallLocation 'AppxManifest.xml'",
+ "if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw \"Package '$packageFamilyName' has no AppxManifest.xml at $manifestPath.\" }",
+ "[xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw",
+ "$application = @($manifest.Package.Applications.Application | Where-Object { $_.Id -eq $applicationId }) | Select-Object -First 1",
+ "if ($null -eq $application) { throw \"Package '$packageFamilyName' does not declare application '$applicationId'.\" }",
+ "if ([string]$application.Executable -ne 'openclaw.exe') { throw \"Package '$packageFamilyName' application '$applicationId' does not target openclaw.exe.\" }",
+ "$source = @'",
+ "using System;",
+ "using System.ComponentModel;",
+ "using System.Runtime.InteropServices;",
+ "namespace OpenClaw.PackageActivation",
+ "{",
+ " [ComImport]",
+ " [Guid(\"2e941141-7f97-4756-ba1d-9decde894a3d\")]",
+ " [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
+ " public interface IApplicationActivationManager",
+ " {",
+ " [PreserveSig]",
+ " int ActivateApplication(",
+ " [MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,",
+ " [MarshalAs(UnmanagedType.LPWStr)] string arguments,",
+ " uint options,",
+ " out uint processId);",
+ " }",
+ "",
+ " [ComImport]",
+ " [Guid(\"45ba127d-10a8-46ea-8ab7-56ea9078943c\")]",
+ " public class ApplicationActivationManager",
+ " {",
+ " }",
+ "",
+ " public static class ApplicationActivator",
+ " {",
+ " private const uint Synchronize = 0x00100000;",
+ " private const uint QueryLimitedInformation = 0x1000;",
+ " private const uint Infinite = 0xffffffff;",
+ " private const uint WaitObject0 = 0;",
+ "",
+ " [DllImport(\"kernel32.dll\", SetLastError = true)]",
+ " private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId);",
+ "",
+ " [DllImport(\"kernel32.dll\", SetLastError = true)]",
+ " private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);",
+ "",
+ " [DllImport(\"kernel32.dll\", SetLastError = true)]",
+ " [return: MarshalAs(UnmanagedType.Bool)]",
+ " private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);",
+ "",
+ " [DllImport(\"kernel32.dll\")]",
+ " [return: MarshalAs(UnmanagedType.Bool)]",
+ " private static extern bool CloseHandle(IntPtr handle);",
+ "",
+ " public static int ActivateAndWait(string appUserModelId, string arguments)",
+ " {",
+ " var manager =",
+ " (IApplicationActivationManager)new ApplicationActivationManager();",
+ " uint processId;",
+ " int result = manager.ActivateApplication(",
+ " appUserModelId,",
+ " arguments,",
+ " 0,",
+ " out processId);",
+ " if (result != 0)",
+ " {",
+ " Marshal.ThrowExceptionForHR(result);",
+ " }",
+ "",
+ " IntPtr process = OpenProcess(",
+ " Synchronize | QueryLimitedInformation,",
+ " false,",
+ " processId);",
+ " if (process == IntPtr.Zero)",
+ " {",
+ " throw new Win32Exception(Marshal.GetLastWin32Error());",
+ " }",
+ "",
+ " try",
+ " {",
+ " uint exitCode;",
+ " if (WaitForSingleObject(process, Infinite) != WaitObject0 ||",
+ " !GetExitCodeProcess(process, out exitCode))",
+ " {",
+ " throw new Win32Exception(Marshal.GetLastWin32Error());",
+ " }",
+ "",
+ " return checked((int)exitCode);",
+ " }",
+ " finally",
+ " {",
+ " CloseHandle(process);",
+ " }",
+ " }",
+ " }",
+ "}",
+ "'@",
+ "Add-Type -TypeDefinition $source -Language CSharp",
+ "$exitCode = [OpenClaw.PackageActivation.ApplicationActivator]::ActivateAndWait($applicationUserModelId, $arguments)",
+ "exit $exitCode",
+ string.Empty);
+ }
+
+ ///
+ /// The Startup-folder fallback calls the same launcher rather than
+ /// repeating its contents, so the two lanes cannot drift apart.
+ ///
+ public static string CreateFallback(string launcherPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(launcherPath);
+
+ return string.Join(
+ "\r\n",
+ "@echo off",
+ Marker,
+ "@rem Generated by OpenClaw. Edits are overwritten on the next install.",
+ "chcp 65001 >nul",
+ $"call \"{launcherPath}\"",
+ "exit /b %ERRORLEVEL%",
+ string.Empty);
+ }
+
+ public static bool LooksGenerated(string content)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+ return content.Contains(Marker, StringComparison.Ordinal) ||
+ content.Contains(PowerShellMarker, StringComparison.Ordinal);
+ }
+
+ private static string QuotePowerShell(string value) =>
+ value.Replace("'", "''", StringComparison.Ordinal);
+}
diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs
index 5c9847b3..a8e138bc 100644
--- a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs
+++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs
@@ -78,10 +78,6 @@ internal sealed record GatewayPersistenceRemovalResult(
/// The directory the gateway is given, rather than the system directory a
/// logon task would otherwise inherit.
///
-///
-/// The control command the launcher invokes. Held as configuration so the
-/// launcher file can be regenerated without re-registering the task.
-///
///
/// The absolute path to the inbox command processor the task runs.
///
@@ -91,5 +87,4 @@ internal sealed record GatewayPersistenceOptions(
string LauncherPath,
string StartupFolderPath,
string WorkingDirectory,
- string AliasCommand,
string CommandProcessorPath);
diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs
new file mode 100644
index 00000000..49185eea
--- /dev/null
+++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs
@@ -0,0 +1,518 @@
+using System.Text;
+using OpenClaw.Launcher.Session;
+
+namespace OpenClaw.Launcher.Gateway;
+
+///
+/// Registers, inspects, and removes this installation's logon recovery.
+///
+///
+///
+/// Persistence is three files' worth of state: a rewritable launcher script, a
+/// logon task that calls it, and an optional Startup-folder fallback that calls
+/// the same script. Only the task is the intended lane.
+///
+///
+/// This type never elevates and never repairs behind the user's back. An
+/// explicit install rewrites what it owns; status only reports,
+/// and names the command that repairs what it found.
+///
+///
+internal sealed class GatewayPersistenceManager
+{
+ /// The command a reported problem tells the user to run.
+ public const string RepairCommand = "clawctl gateway-service install";
+
+ private readonly IGatewayTaskScheduler _scheduler;
+ private readonly GatewayPersistenceOptions _options;
+ private readonly GatewayTaskIdentity _identity;
+ private readonly Action _log;
+ private readonly Func _resolveUserSid;
+
+ public GatewayPersistenceManager(
+ IGatewayTaskScheduler scheduler,
+ GatewayPersistenceOptions options,
+ Action? log = null,
+ Func? resolveUserSid = null)
+ {
+ ArgumentNullException.ThrowIfNull(scheduler);
+ ArgumentNullException.ThrowIfNull(options);
+
+ _scheduler = scheduler;
+ _options = options;
+ _identity = GatewayTaskIdentity.Create(
+ options.PackageFamilyName,
+ options.UserSid);
+ _log = log ?? (_ => { });
+ _resolveUserSid = resolveUserSid ?? (_ => null);
+ _ = LauncherPath;
+ _ = FallbackPath;
+ }
+
+ public string TaskName => _identity.Name;
+
+ ///
+ /// The Startup-folder file this installation owns. Scoped by package family
+ /// name so the public and internal packages cannot overwrite each other's
+ /// fallback in a folder they both write to.
+ ///
+ public string FallbackPath => ResolveOwnedFilePath(
+ _options.StartupFolderPath,
+ $"{GatewayTaskIdentity.DisplayName} {_options.PackageFamilyName}.cmd");
+
+ private string LauncherPath => ResolveOwnedFilePath(
+ _options.WorkingDirectory,
+ _options.LauncherPath);
+
+ private string ActivationScriptPath => ResolveOwnedFilePath(
+ _options.WorkingDirectory,
+ Path.ChangeExtension(LauncherPath, ".ps1"));
+
+ public async Task GetStatusAsync(
+ CancellationToken cancellationToken)
+ {
+ GatewayTaskProbe probe = await _scheduler.QueryAsync(
+ _identity.Name,
+ cancellationToken).ConfigureAwait(false);
+
+ if (probe.Presence == GatewayTaskPresence.Unreadable)
+ {
+ return new GatewayPersistenceStatus(
+ GatewayPersistenceState.Unknown,
+ GatewayPersistenceLane.None,
+ "Logon recovery could not be read.",
+ probe.Detail,
+ RepairCommand);
+ }
+
+ bool fallbackPresent = FallbackMatches() && LauncherMatches();
+
+ if (probe.Presence == GatewayTaskPresence.Missing)
+ {
+ return fallbackPresent
+ ? new GatewayPersistenceStatus(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.StartupFolderFallback,
+ "Logon recovery is configured through the Startup folder.",
+ "The logon task is not registered, so the Startup-folder " +
+ "fallback is in use. It runs later than the task and only " +
+ "for interactive sign-ins.",
+ RepairCommand)
+ : new GatewayPersistenceStatus(
+ GatewayPersistenceState.NotInstalled,
+ GatewayPersistenceLane.None,
+ "Logon recovery is not configured.",
+ null,
+ RepairCommand);
+ }
+
+ string? drift = DescribeDrift(probe.Snapshot!);
+ if (drift is not null)
+ {
+ return new GatewayPersistenceStatus(
+ GatewayPersistenceState.ActionRequired,
+ GatewayPersistenceLane.TaskScheduler,
+ "Logon recovery is registered but does not match this installation.",
+ drift,
+ RepairCommand);
+ }
+
+ if (!LauncherMatches())
+ {
+ return new GatewayPersistenceStatus(
+ GatewayPersistenceState.ActionRequired,
+ GatewayPersistenceLane.TaskScheduler,
+ "The logon task is registered but its launcher is missing or modified.",
+ $"'{_options.LauncherPath}' does not contain the expected " +
+ "generated launcher, so the task would not start the gateway.",
+ RepairCommand);
+ }
+
+ return new GatewayPersistenceStatus(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.TaskScheduler,
+ "Logon recovery is configured.");
+ }
+
+ public async Task InstallAsync(
+ CancellationToken cancellationToken)
+ {
+ bool changed;
+ try
+ {
+ changed = WriteLauncher();
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ return new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.ActionRequired,
+ GatewayPersistenceLane.None,
+ "The gateway launcher could not be written.",
+ Changed: false,
+ $"'{_options.LauncherPath}': {exception.Message}",
+ RepairCommand);
+ }
+
+ GatewayTaskProbe probe = await _scheduler.QueryAsync(
+ _identity.Name,
+ cancellationToken).ConfigureAwait(false);
+
+ // An unreadable probe is not a missing task. Registering anyway is an
+ // unbounded retry: the write is as likely to be refused as the read was.
+ if (probe.Presence == GatewayTaskPresence.Unreadable)
+ {
+ return new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.Unknown,
+ GatewayPersistenceLane.None,
+ "Logon recovery could not be read, so it was left unchanged.",
+ changed,
+ probe.Detail,
+ RepairCommand);
+ }
+
+ bool alreadyCorrect =
+ probe.Presence == GatewayTaskPresence.Present &&
+ DescribeDrift(probe.Snapshot!) is null;
+
+ if (alreadyCorrect)
+ {
+ _ = RemoveFallback();
+ return new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.TaskScheduler,
+ "Logon recovery is configured.",
+ changed);
+ }
+
+ GatewayTaskOperation registration = await _scheduler.RegisterAsync(
+ _identity.Name,
+ GatewayTaskDefinition.CreateXml(DesiredSnapshot(), _identity.Name),
+ cancellationToken).ConfigureAwait(false);
+
+ if (registration.Succeeded)
+ {
+ _log($"Registered the logon task '{_identity.Name}'.");
+ _ = RemoveFallback();
+ return new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.TaskScheduler,
+ "Logon recovery is configured.",
+ Changed: true);
+ }
+
+ return InstallFallback(registration.Detail);
+ }
+
+ public async Task UninstallAsync(
+ CancellationToken cancellationToken)
+ {
+ GatewayTaskOperation deletion = await _scheduler.DeleteAsync(
+ _identity.Name,
+ cancellationToken).ConfigureAwait(false);
+
+ CleanupResult fallback = RemoveFallback();
+ CleanupResult launcher = RemoveLauncher();
+ CleanupResult activationScript = RemoveActivationScript();
+ bool changed =
+ fallback == CleanupResult.Removed ||
+ launcher == CleanupResult.Removed ||
+ activationScript == CleanupResult.Removed;
+
+ if (!deletion.Succeeded ||
+ fallback == CleanupResult.Failed ||
+ launcher == CleanupResult.Failed ||
+ activationScript == CleanupResult.Failed)
+ {
+ return new GatewayPersistenceRemovalResult(
+ Succeeded: false,
+ changed,
+ "Logon recovery could not be fully removed.",
+ Combine(
+ deletion.Succeeded ? null : deletion.Detail,
+ Combine(
+ fallback == CleanupResult.Failed
+ ? $"'{FallbackPath}' could not be removed."
+ : null,
+ launcher == CleanupResult.Failed
+ ? $"'{_options.LauncherPath}' could not be removed."
+ : null,
+ activationScript == CleanupResult.Failed
+ ? $"'{ActivationScriptPath}' could not be removed."
+ : null)));
+ }
+
+ return new GatewayPersistenceRemovalResult(
+ Succeeded: true,
+ changed,
+ "Logon recovery is removed.");
+ }
+
+ private GatewayTaskSnapshot DesiredSnapshot() =>
+ GatewayTaskDefinition.CreateSnapshot(
+ _options.UserSid,
+ _options.CommandProcessorPath,
+ _options.LauncherPath);
+
+ private string? DescribeDrift(GatewayTaskSnapshot actual)
+ {
+ GatewayTaskSnapshot desired = DesiredSnapshot();
+ List differences = [];
+
+ if (!actual.Enabled)
+ {
+ differences.Add("The task is disabled.");
+ }
+
+ if (!actual.HasSingleLogonTrigger)
+ {
+ differences.Add("The task does not have exactly one logon trigger.");
+ }
+ else if (!actual.LogonTriggerEnabled)
+ {
+ differences.Add("The logon trigger is disabled.");
+ }
+ else if (!MatchesUserSid(actual.LogonTriggerUserId, desired.LogonTriggerUserId))
+ {
+ differences.Add("The logon trigger is scoped to a different user.");
+ }
+
+ if (!Same(actual.UserId, desired.UserId))
+ {
+ differences.Add("The task runs as a different user.");
+ }
+
+ if (!Same(actual.LogonType, desired.LogonType))
+ {
+ differences.Add($"The logon type is '{actual.LogonType}'.");
+ }
+
+ if (!Same(actual.RunLevel, desired.RunLevel))
+ {
+ differences.Add($"The task runs at '{actual.RunLevel}'.");
+ }
+
+ if (!Same(actual.MultipleInstancesPolicy, desired.MultipleInstancesPolicy))
+ {
+ differences.Add(
+ "A second sign-in would not be suppressed by the " +
+ "duplicate-instance policy.");
+ }
+
+ if (actual.DisallowStartIfOnBatteries || actual.StopIfGoingOnBatteries)
+ {
+ differences.Add("The task is gated on AC power.");
+ }
+
+ if (!Same(actual.ExecutionTimeLimit, desired.ExecutionTimeLimit))
+ {
+ differences.Add(
+ $"The task would be terminated after '{actual.ExecutionTimeLimit}'.");
+ }
+
+ if (!actual.HasSingleExecAction)
+ {
+ differences.Add("The task does not have exactly one action.");
+ }
+ else if (!Same(actual.Command, desired.Command) ||
+ !Same(actual.Arguments, desired.Arguments))
+ {
+ differences.Add("The task runs a different command.");
+ }
+
+ return differences.Count == 0 ? null : string.Join(" ", differences);
+ }
+
+ private GatewayPersistenceInstallResult InstallFallback(string? taskDetail)
+ {
+ try
+ {
+ Directory.CreateDirectory(_options.StartupFolderPath);
+ WriteGeneratedFile(
+ FallbackPath,
+ GatewayLauncherScript.CreateFallback(_options.LauncherPath));
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ return new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.ActionRequired,
+ GatewayPersistenceLane.None,
+ "Logon recovery could not be configured.",
+ Changed: true,
+ Combine(taskDetail, $"'{FallbackPath}': {exception.Message}"),
+ RepairCommand);
+ }
+
+ _log($"Registered the Startup-folder fallback at '{FallbackPath}'.");
+ return new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.StartupFolderFallback,
+ "Logon recovery is configured through the Startup folder.",
+ Changed: true,
+ Combine(
+ taskDetail,
+ "The logon task could not be registered, so the Startup-folder " +
+ "fallback was used. It runs later than the task and only for " +
+ "interactive sign-ins."),
+ RepairCommand);
+ }
+
+ private bool WriteLauncher()
+ {
+ string content = GatewayLauncherScript.Create(
+ _options.WorkingDirectory,
+ ActivationScriptPath);
+ string activationScript =
+ GatewayLauncherScript.CreateActivationScript(_options.PackageFamilyName);
+
+ Directory.CreateDirectory(
+ Path.GetDirectoryName(LauncherPath)
+ ?? throw new InvalidOperationException(
+ $"'{LauncherPath}' has no parent directory."));
+ bool launcherChanged = WriteGeneratedFile(LauncherPath, content);
+ bool activationChanged = WriteGeneratedFile(ActivationScriptPath, activationScript);
+ return launcherChanged || activationChanged;
+ }
+
+ private static bool WriteGeneratedFile(string path, string content)
+ {
+ if (File.Exists(path))
+ {
+ string existing = File.ReadAllText(path);
+ if (string.Equals(existing, content, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (!GatewayLauncherScript.LooksGenerated(existing))
+ {
+ throw new IOException(
+ $"'{path}' exists but was not generated by OpenClaw.");
+ }
+ }
+
+ File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ return true;
+ }
+
+ private bool LauncherMatches()
+ {
+ try
+ {
+ return File.Exists(LauncherPath) &&
+ string.Equals(
+ File.ReadAllText(LauncherPath),
+ GatewayLauncherScript.Create(
+ _options.WorkingDirectory,
+ ActivationScriptPath),
+ StringComparison.Ordinal) &&
+ File.Exists(ActivationScriptPath) &&
+ string.Equals(
+ File.ReadAllText(ActivationScriptPath),
+ GatewayLauncherScript.CreateActivationScript(
+ _options.PackageFamilyName),
+ StringComparison.Ordinal);
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ private bool FallbackMatches()
+ {
+ try
+ {
+ return File.Exists(FallbackPath) &&
+ string.Equals(
+ File.ReadAllText(FallbackPath),
+ GatewayLauncherScript.CreateFallback(_options.LauncherPath),
+ StringComparison.Ordinal);
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ private CleanupResult RemoveFallback() =>
+ RemoveGeneratedFile(FallbackPath);
+
+ private CleanupResult RemoveLauncher() =>
+ RemoveGeneratedFile(LauncherPath);
+
+ private CleanupResult RemoveActivationScript() =>
+ RemoveGeneratedFile(ActivationScriptPath);
+
+ private static CleanupResult RemoveGeneratedFile(string path)
+ {
+ // Only a file this installation generated is deleted. A same-named file
+ // someone else placed there is left alone.
+ try
+ {
+ if (!File.Exists(path) ||
+ !GatewayLauncherScript.LooksGenerated(File.ReadAllText(path)))
+ {
+ return CleanupResult.Absent;
+ }
+
+ File.Delete(path);
+ return CleanupResult.Removed;
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ return CleanupResult.Failed;
+ }
+ }
+
+ private bool MatchesUserSid(string userId, string desiredSid) =>
+ Same(userId, desiredSid) ||
+ Same(_resolveUserSid(userId) ?? string.Empty, desiredSid);
+
+ private static string ResolveOwnedFilePath(string directory, string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(directory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ string root = Path.GetFullPath(directory)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ string candidate = Path.GetFullPath(
+ Path.IsPathRooted(path) ? path : Path.Combine(root, path));
+ string? parent = Path.GetDirectoryName(candidate);
+ if (!string.Equals(parent, root, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException(
+ $"'{path}' is not a direct child of '{directory}'.",
+ nameof(path));
+ }
+
+ TrustedPath.EnsureNoReparsePoints(root, candidate);
+ return candidate;
+ }
+
+ private static string? Combine(string? first, string? second) =>
+ (string.IsNullOrWhiteSpace(first), string.IsNullOrWhiteSpace(second)) switch
+ {
+ (true, true) => null,
+ (true, false) => second,
+ (false, true) => first,
+ _ => $"{first} {second}"
+ };
+
+ private static string? Combine(string? first, string? second, string? third) =>
+ Combine(first, Combine(second, third));
+
+ private static bool Same(string left, string right) =>
+ string.Equals(left, right, StringComparison.OrdinalIgnoreCase);
+
+ private enum CleanupResult
+ {
+ Absent,
+ Removed,
+ Failed,
+ }
+}
diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs
index f63bace5..c0d00559 100644
--- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs
+++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs
@@ -1,3 +1,5 @@
+using System.Runtime.InteropServices;
+using System.Security.Principal;
using OpenClaw.Launcher.Session;
namespace OpenClaw.Launcher.Gateway;
@@ -5,6 +7,9 @@ namespace OpenClaw.Launcher.Gateway;
/// Assembles gateway management from the running installation.
internal sealed class GatewayRuntime
{
+ private static readonly Guid StartupFolderId =
+ new("B97D20BB-F46A-4C97-BA10-5E3608430854");
+
private GatewayRuntime(GatewayController controller, string helperPath)
{
Controller = controller;
@@ -15,6 +20,78 @@ private GatewayRuntime(GatewayController controller, string helperPath)
public string HelperPath { get; }
+ public static GatewayPersistenceManager CreateRecoveryManager(Action log)
+ {
+ ArgumentNullException.ThrowIfNull(log);
+
+ HostPaths paths = HostPaths.Create();
+ string packageFamilyName = paths.PackageFamilyName
+ ?? throw new SessionException(
+ "OpenClaw is not running from its installed package, so it cannot configure gateway recovery.");
+ string userSid = WindowsIdentity.GetCurrent().User?.Value
+ ?? throw new SessionException(
+ "The signed-in user's security identifier is unavailable, so gateway recovery cannot be configured.");
+
+ return new GatewayPersistenceManager(
+ new SchTasksGatewayScheduler(),
+ new GatewayPersistenceOptions(
+ userSid,
+ packageFamilyName,
+ paths.GatewayLauncherPath,
+ GetStartupFolderPath(),
+ paths.StateRoot,
+ Path.Combine(Environment.SystemDirectory, "cmd.exe")),
+ log,
+ ResolveUserSid);
+ }
+
+ private static string GetStartupFolderPath()
+ {
+ int result = SHGetKnownFolderPath(
+ StartupFolderId,
+ flags: 0,
+ token: IntPtr.Zero,
+ out IntPtr path);
+ if (result < 0)
+ {
+ throw new SessionException(
+ $"Windows could not resolve the Startup folder " +
+ $"(HRESULT 0x{result:X8}).");
+ }
+
+ try
+ {
+ return Marshal.PtrToStringUni(path)
+ ?? throw new SessionException(
+ "Windows returned an empty Startup folder path.");
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(path);
+ }
+ }
+
+ [DllImport("shell32.dll")]
+ private static extern int SHGetKnownFolderPath(
+ in Guid folderId,
+ uint flags,
+ IntPtr token,
+ out IntPtr path);
+
+ private static string? ResolveUserSid(string accountName)
+ {
+ try
+ {
+ return new NTAccount(accountName)
+ .Translate(typeof(SecurityIdentifier))
+ .Value;
+ }
+ catch (IdentityNotMappedException)
+ {
+ return null;
+ }
+ }
+
public static GatewayRuntime Create(
HostOptions options,
Action log,
diff --git a/src/OpenClaw.Launcher/Gateway/GatewayTaskContracts.cs b/src/OpenClaw.Launcher/Gateway/GatewayTaskContracts.cs
new file mode 100644
index 00000000..424c466a
--- /dev/null
+++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskContracts.cs
@@ -0,0 +1,70 @@
+namespace OpenClaw.Launcher.Gateway;
+
+///
+/// What a task probe found. Deliberately three-valued.
+///
+internal enum GatewayTaskPresence
+{
+ /// Task Scheduler reported no such task.
+ Missing,
+
+ /// The task exists and its definition was read.
+ Present,
+
+ ///
+ /// The task could not be read. This is not the same as missing: the query
+ /// may have been refused, and re-registering on a refused read is an
+ /// unbounded retry whose write is as likely to be refused as the read was.
+ ///
+ Unreadable,
+}
+
+internal sealed record GatewayTaskProbe(
+ GatewayTaskPresence Presence,
+ GatewayTaskSnapshot? Snapshot,
+ string? Detail)
+{
+ public static GatewayTaskProbe Missing { get; } =
+ new(GatewayTaskPresence.Missing, null, null);
+
+ public static GatewayTaskProbe Present(GatewayTaskSnapshot snapshot) =>
+ new(GatewayTaskPresence.Present, snapshot, null);
+
+ public static GatewayTaskProbe Unreadable(string detail) =>
+ new(GatewayTaskPresence.Unreadable, null, detail);
+}
+
+internal sealed record GatewayTaskOperation(bool Succeeded, string? Detail)
+{
+ public static GatewayTaskOperation Success { get; } = new(true, null);
+
+ public static GatewayTaskOperation Failure(string detail) => new(false, detail);
+}
+
+///
+/// The Task Scheduler operations gateway persistence needs.
+///
+///
+/// Behind an interface so lifecycle behavior is testable without registering
+/// anything on the developer's machine. Tests must never create, run, or delete
+/// a real scheduled task.
+///
+internal interface IGatewayTaskScheduler
+{
+ Task QueryAsync(
+ string taskName,
+ CancellationToken cancellationToken);
+
+ Task RegisterAsync(
+ string taskName,
+ string taskXml,
+ CancellationToken cancellationToken);
+
+ Task DeleteAsync(
+ string taskName,
+ CancellationToken cancellationToken);
+
+ Task RunAsync(
+ string taskName,
+ CancellationToken cancellationToken);
+}
diff --git a/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs b/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs
new file mode 100644
index 00000000..819ebc1d
--- /dev/null
+++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs
@@ -0,0 +1,243 @@
+using System.Xml;
+using System.Xml.Linq;
+
+namespace OpenClaw.Launcher.Gateway;
+
+///
+/// The settings of a registered logon task that this installation cares about.
+///
+///
+/// Drift is compared field by field rather than by comparing XML text, because
+/// Task Scheduler normalizes what it stores: it reorders elements, fills in
+/// defaults, and rewrites the principal. A text comparison would therefore
+/// report drift on every single probe.
+///
+internal sealed record GatewayTaskSnapshot(
+ string UserId,
+ string LogonType,
+ string RunLevel,
+ bool Enabled,
+ bool HasSingleLogonTrigger,
+ bool LogonTriggerEnabled,
+ string LogonTriggerUserId,
+ bool HasSingleExecAction,
+ string MultipleInstancesPolicy,
+ bool DisallowStartIfOnBatteries,
+ bool StopIfGoingOnBatteries,
+ string ExecutionTimeLimit,
+ string Command,
+ string Arguments);
+
+///
+/// Builds the logon task this installation registers.
+///
+internal static class GatewayTaskDefinition
+{
+ private const string TaskNamespace =
+ "http://schemas.microsoft.com/windows/2004/02/mit/task";
+
+ ///
+ /// No execution time limit. The gateway is long-running, and Task
+ /// Scheduler's default would terminate it after 72 hours.
+ ///
+ public const string NoExecutionTimeLimit = "PT0S";
+
+ ///
+ /// A second logon trigger must not start a second gateway. The existing
+ /// instance is authoritative, so the new one is dropped rather than
+ /// queued or allowed to run beside it.
+ ///
+ public const string IgnoreNewInstances = "IgnoreNew";
+
+ public static GatewayTaskSnapshot CreateSnapshot(
+ string userSid,
+ string commandProcessorPath,
+ string launcherPath) =>
+ new(
+ UserId: userSid,
+ // InteractiveToken runs with the user's interactive session and
+ // desktop but without storing a password, which is what a per-user
+ // logon task needs.
+ LogonType: "InteractiveToken",
+ RunLevel: "LeastPrivilege",
+ Enabled: true,
+ HasSingleLogonTrigger: true,
+ LogonTriggerEnabled: true,
+ LogonTriggerUserId: userSid,
+ HasSingleExecAction: true,
+ MultipleInstancesPolicy: IgnoreNewInstances,
+ // A gateway that refuses to start on battery, or dies when the
+ // charger is unplugged, is a laptop user losing their agent for a
+ // reason they will never connect to power.
+ DisallowStartIfOnBatteries: false,
+ StopIfGoingOnBatteries: false,
+ ExecutionTimeLimit: NoExecutionTimeLimit,
+ Command: commandProcessorPath,
+ Arguments: $"/d /c \"\"{launcherPath}\"\"");
+
+ public static string CreateXml(GatewayTaskSnapshot snapshot, string taskName)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ XNamespace ns = TaskNamespace;
+ var document = new XDocument(
+ new XDeclaration("1.0", "UTF-16", null),
+ new XElement(
+ ns + "Task",
+ new XAttribute("version", "1.3"),
+ new XElement(
+ ns + "RegistrationInfo",
+ new XElement(ns + "URI", "\\" + taskName),
+ new XElement(
+ ns + "Description",
+ "Starts the OpenClaw gateway when this user signs in.")),
+ new XElement(
+ ns + "Triggers",
+ new XElement(
+ ns + "LogonTrigger",
+ new XElement(ns + "Enabled", Bool(snapshot.LogonTriggerEnabled)),
+ new XElement(ns + "UserId", snapshot.LogonTriggerUserId))),
+ new XElement(
+ ns + "Principals",
+ new XElement(
+ ns + "Principal",
+ new XAttribute("id", "Author"),
+ new XElement(ns + "UserId", snapshot.UserId),
+ new XElement(ns + "LogonType", snapshot.LogonType),
+ new XElement(ns + "RunLevel", snapshot.RunLevel))),
+ new XElement(
+ ns + "Settings",
+ new XElement(ns + "Enabled", Bool(snapshot.Enabled)),
+ new XElement(ns + "Hidden", "false"),
+ new XElement(
+ ns + "MultipleInstancesPolicy",
+ snapshot.MultipleInstancesPolicy),
+ new XElement(
+ ns + "DisallowStartIfOnBatteries",
+ Bool(snapshot.DisallowStartIfOnBatteries)),
+ new XElement(
+ ns + "StopIfGoingOnBatteries",
+ Bool(snapshot.StopIfGoingOnBatteries)),
+ new XElement(ns + "StartWhenAvailable", "true"),
+ new XElement(ns + "RunOnlyIfNetworkAvailable", "false"),
+ new XElement(ns + "AllowHardTerminate", "true"),
+ new XElement(ns + "AllowStartOnDemand", "true"),
+ new XElement(
+ ns + "ExecutionTimeLimit",
+ snapshot.ExecutionTimeLimit),
+ new XElement(ns + "Priority", "7"),
+ new XElement(
+ ns + "IdleSettings",
+ new XElement(ns + "StopOnIdleEnd", "false"),
+ new XElement(ns + "RestartOnIdle", "false"))),
+ new XElement(
+ ns + "Actions",
+ new XAttribute("Context", "Author"),
+ new XElement(
+ ns + "Exec",
+ new XElement(ns + "Command", snapshot.Command),
+ new XElement(ns + "Arguments", snapshot.Arguments)))));
+
+ using var writer = new StringWriter();
+ using (var xml = XmlWriter.Create(
+ writer,
+ new XmlWriterSettings { Indent = true, OmitXmlDeclaration = false }))
+ {
+ document.Save(xml);
+ }
+
+ return writer.ToString();
+ }
+
+ ///
+ /// Reads a registered task back into a snapshot. Returns false when the XML
+ /// cannot be understood, which is reported as unreadable rather than as a
+ /// missing or mismatched task.
+ ///
+ public static bool TryParse(
+ string xml,
+ out GatewayTaskSnapshot? snapshot,
+ out string? detail)
+ {
+ snapshot = null;
+ detail = null;
+
+ try
+ {
+ XNamespace ns = TaskNamespace;
+ XElement root = XDocument.Parse(xml).Root
+ ?? throw new InvalidOperationException("The task XML is empty.");
+
+ XElement? principal = root
+ .Element(ns + "Principals")?
+ .Elements(ns + "Principal")
+ .FirstOrDefault();
+ List logonTriggers = root
+ .Element(ns + "Triggers")?
+ .Elements(ns + "LogonTrigger")
+ .ToList() ?? [];
+ List actions = root
+ .Element(ns + "Actions")?
+ .Elements()
+ .ToList() ?? [];
+ List execActions =
+ [.. actions.Where(element => element.Name == ns + "Exec")];
+ XElement? settings = root.Element(ns + "Settings");
+ XElement? trigger = logonTriggers.Count == 1 ? logonTriggers[0] : null;
+ XElement? action = actions.Count == 1 && execActions.Count == 1
+ ? execActions[0]
+ : null;
+
+ snapshot = new GatewayTaskSnapshot(
+ UserId: Text(principal, ns + "UserId"),
+ LogonType: Text(principal, ns + "LogonType"),
+ RunLevel: Text(principal, ns + "RunLevel", "LeastPrivilege"),
+ Enabled: Flag(settings, ns + "Enabled", true),
+ HasSingleLogonTrigger: logonTriggers.Count == 1,
+ LogonTriggerEnabled: Flag(trigger, ns + "Enabled", true),
+ LogonTriggerUserId: Text(trigger, ns + "UserId"),
+ HasSingleExecAction: actions.Count == 1 && execActions.Count == 1,
+ MultipleInstancesPolicy: Text(
+ settings,
+ ns + "MultipleInstancesPolicy",
+ "IgnoreNew"),
+ DisallowStartIfOnBatteries: Flag(
+ settings,
+ ns + "DisallowStartIfOnBatteries",
+ true),
+ StopIfGoingOnBatteries: Flag(
+ settings,
+ ns + "StopIfGoingOnBatteries",
+ true),
+ ExecutionTimeLimit: Text(
+ settings,
+ ns + "ExecutionTimeLimit",
+ "PT72H"),
+ Command: Text(action, ns + "Command"),
+ Arguments: Text(action, ns + "Arguments", string.Empty));
+ return true;
+ }
+ catch (Exception ex) when (
+ ex is XmlException or InvalidOperationException or FormatException)
+ {
+ detail = ex.Message;
+ return false;
+ }
+ }
+
+ private static string Bool(bool value) => value ? "true" : "false";
+
+ private static string Text(
+ XElement? parent,
+ XName name,
+ string fallback = "") =>
+ parent?.Element(name)?.Value.Trim() ?? fallback;
+
+ private static bool Flag(XElement? parent, XName name, bool fallback)
+ {
+ string? value = parent?.Element(name)?.Value.Trim();
+ return value is null
+ ? fallback
+ : bool.TryParse(value, out bool parsed) ? parsed : fallback;
+ }
+}
diff --git a/src/OpenClaw.Launcher/Gateway/GatewayTaskIdentity.cs b/src/OpenClaw.Launcher/Gateway/GatewayTaskIdentity.cs
new file mode 100644
index 00000000..9ce79106
--- /dev/null
+++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskIdentity.cs
@@ -0,0 +1,68 @@
+namespace OpenClaw.Launcher.Gateway;
+
+///
+/// Identifies this installation's logon task.
+///
+///
+///
+/// Task Scheduler names are machine-wide while gateway persistence is per-user
+/// and per-installation. A name scoped by neither would let one user's install
+/// overwrite another's task and one user's uninstall delete it; a name scoped
+/// only by user would let this package and the internal MSIX fight over the
+/// same entry.
+///
+///
+/// The SID is the identifier, not the account name: it is the only one
+/// guaranteed unique across local, domain, and Entra accounts, and it does not
+/// change when an account is renamed.
+///
+///
+internal sealed record GatewayTaskIdentity
+{
+ public const string DisplayName = "OpenClaw Gateway";
+
+ private GatewayTaskIdentity(string name, string packageFamilyName, string userSid)
+ {
+ Name = name;
+ PackageFamilyName = packageFamilyName;
+ UserSid = userSid;
+ }
+
+ /// The Task Scheduler name, without a folder path.
+ public string Name { get; }
+
+ public string PackageFamilyName { get; }
+
+ public string UserSid { get; }
+
+ public static GatewayTaskIdentity Create(string packageFamilyName, string userSid)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageFamilyName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(userSid);
+
+ // Task Scheduler treats a backslash as a folder separator, so a name
+ // carrying one would silently address a different folder.
+ if (packageFamilyName.Contains('\\', StringComparison.Ordinal) ||
+ userSid.Contains('\\', StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "A task name component may not contain a backslash.",
+ nameof(packageFamilyName));
+ }
+
+ return new GatewayTaskIdentity(
+ $"{DisplayName} {packageFamilyName} {userSid}",
+ packageFamilyName,
+ userSid);
+ }
+
+ ///
+ /// True when belongs to this installation, for
+ /// any user. Used to recognize our own tasks without assuming who
+ /// registered them, and never to claim another package's task.
+ ///
+ public static bool BelongsToPackage(string taskName, string packageFamilyName) =>
+ taskName.StartsWith(
+ $"{DisplayName} {packageFamilyName} ",
+ StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/OpenClaw.Launcher/Gateway/SchTasksGatewayScheduler.cs b/src/OpenClaw.Launcher/Gateway/SchTasksGatewayScheduler.cs
new file mode 100644
index 00000000..c56e84e7
--- /dev/null
+++ b/src/OpenClaw.Launcher/Gateway/SchTasksGatewayScheduler.cs
@@ -0,0 +1,438 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace OpenClaw.Launcher.Gateway;
+
+///
+/// Drives Task Scheduler through the inbox schtasks.exe.
+///
+///
+///
+/// The COM Task Scheduler API is not usable here: this launcher is published
+/// with NativeAOT, and the interop that API needs is exactly what NativeAOT
+/// cannot generate. The inbox executable is present on every supported Windows
+/// installation and takes the same structured task XML.
+///
+///
+/// Nothing in this type elevates. Registration must run as the signed-in user:
+/// a task registered elevated is owned by BUILTIN\Administrators, and
+/// the user can then neither overwrite nor delete their own gateway task.
+///
+///
+internal sealed class SchTasksGatewayScheduler : IGatewayTaskScheduler
+{
+ private readonly string _executablePath;
+ private readonly Func>? _run;
+
+ public SchTasksGatewayScheduler()
+ : this(ResolveDefaultExecutablePath())
+ {
+ }
+
+ internal SchTasksGatewayScheduler(string executablePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(executablePath);
+ _executablePath = executablePath;
+ }
+
+ // Tests drive classification without launching schtasks.exe, which must
+ // never register, run, or delete a real task on a developer's machine.
+ internal SchTasksGatewayScheduler(
+ Func> run)
+ {
+ ArgumentNullException.ThrowIfNull(run);
+ _executablePath = "schtasks.exe";
+ _run = run;
+ }
+
+ public async Task QueryAsync(
+ string taskName,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(taskName);
+
+ SchTasksOutcome outcome;
+ try
+ {
+ outcome = await RunAsync(
+ ["/Query", "/TN", taskName, "/XML", "ONE"],
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (SchTasksLaunchException exception)
+ {
+ return GatewayTaskProbe.Unreadable(exception.Message);
+ }
+
+ if (outcome.ExitCode != 0)
+ {
+ // "Not found" and "refused" must not collapse into one answer. A
+ // caller that re-registers on a refused read retries forever: the
+ // write is as likely to be refused as the read was.
+ return await ClassifyQueryFailureAsync(taskName, outcome, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return GatewayTaskDefinition.TryParse(
+ outcome.StandardOutput,
+ out GatewayTaskSnapshot? snapshot,
+ out string? detail)
+ ? GatewayTaskProbe.Present(snapshot!)
+ : GatewayTaskProbe.Unreadable(
+ $"The registered task definition could not be read: {detail}");
+ }
+
+ public async Task RegisterAsync(
+ string taskName,
+ string taskXml,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(taskName);
+ ArgumentNullException.ThrowIfNull(taskXml);
+
+ string xmlPath = Path.Combine(
+ Path.GetTempPath(),
+ $"openclaw-gateway-task-{Guid.NewGuid():n}.xml");
+
+ try
+ {
+ // The definition declares UTF-16, so the file has to be UTF-16 with
+ // a byte-order mark or schtasks rejects it.
+ await File.WriteAllTextAsync(
+ xmlPath,
+ taskXml,
+ new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
+ cancellationToken).ConfigureAwait(false);
+
+ SchTasksOutcome outcome = await RunAsync(
+ ["/Create", "/TN", taskName, "/XML", xmlPath, "/F"],
+ cancellationToken).ConfigureAwait(false);
+
+ return outcome.ExitCode == 0
+ ? GatewayTaskOperation.Success
+ : GatewayTaskOperation.Failure(Describe(outcome));
+ }
+ catch (SchTasksLaunchException exception)
+ {
+ return GatewayTaskOperation.Failure(exception.Message);
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ return GatewayTaskOperation.Failure(
+ $"The task definition could not be staged at '{xmlPath}': " +
+ exception.Message);
+ }
+ finally
+ {
+ TryDelete(xmlPath);
+ }
+ }
+
+ public async Task DeleteAsync(
+ string taskName,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(taskName);
+
+ SchTasksOutcome outcome;
+ try
+ {
+ outcome = await RunAsync(
+ ["/Delete", "/TN", taskName, "/F"],
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (SchTasksLaunchException exception)
+ {
+ return GatewayTaskOperation.Failure(exception.Message);
+ }
+
+ // Deleting what is already gone is the requested end state.
+ if (outcome.ExitCode == 0)
+ {
+ return GatewayTaskOperation.Success;
+ }
+
+ return await IsAbsentAsync(taskName, cancellationToken).ConfigureAwait(false)
+ ? GatewayTaskOperation.Success
+ : GatewayTaskOperation.Failure(Describe(outcome));
+ }
+
+ public async Task RunAsync(
+ string taskName,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(taskName);
+
+ SchTasksOutcome outcome;
+ try
+ {
+ outcome = await RunAsync(["/Run", "/TN", taskName], cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (SchTasksLaunchException exception)
+ {
+ return GatewayTaskOperation.Failure(exception.Message);
+ }
+
+ return outcome.ExitCode == 0
+ ? GatewayTaskOperation.Success
+ : GatewayTaskOperation.Failure(Describe(outcome));
+ }
+
+ private static string ResolveDefaultExecutablePath()
+ {
+ // An absolute path to the inbox executable, so a same-named program
+ // earlier on PATH can never be the one that registers a logon task.
+ string system = Environment.GetFolderPath(Environment.SpecialFolder.System);
+ return string.IsNullOrEmpty(system)
+ ? "schtasks.exe"
+ : Path.Combine(system, "schtasks.exe");
+ }
+
+ ///
+ /// Separates a missing task from a refused one without reading localized
+ /// diagnostics.
+ ///
+ ///
+ ///
+ /// schtasks.exe writes its errors in the console's display language
+ /// and returns exit code 1 for both "no such task" and "access denied", so
+ /// neither the text nor the exit code can classify the failure. Matching
+ /// English phrases made every absent task look unreadable on a localized
+ /// Windows installation, which suppressed both registration and the
+ /// Startup-folder fallback and left setup unable to reach Ready.
+ ///
+ ///
+ /// Enumeration answers the same question structurally. The listing reports
+ /// the task names this account can see, and the name being looked for is
+ /// one this package generated, so the comparison is ordinal and carries no
+ /// language. A successful listing that omits the name proves absence; a
+ /// listing that contains it proves the earlier read was refused rather
+ /// than empty; a listing that fails leaves the question open.
+ ///
+ ///
+ private async Task ClassifyQueryFailureAsync(
+ string taskName,
+ SchTasksOutcome queryOutcome,
+ CancellationToken cancellationToken)
+ {
+ SchTasksOutcome listing;
+ try
+ {
+ listing = await RunAsync(
+ ["/Query", "/FO", "CSV", "/NH"],
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (SchTasksLaunchException exception)
+ {
+ return GatewayTaskProbe.Unreadable(
+ Combine(Describe(queryOutcome), exception.Message));
+ }
+
+ if (listing.ExitCode != 0)
+ {
+ return GatewayTaskProbe.Unreadable(
+ Combine(Describe(queryOutcome), Describe(listing)));
+ }
+
+ return ListingContains(listing.StandardOutput, taskName)
+ ? GatewayTaskProbe.Unreadable(Describe(queryOutcome))
+ : GatewayTaskProbe.Missing;
+ }
+
+ ///
+ /// Reports whether this account can see that the task is gone.
+ ///
+ ///
+ /// Answers false when the listing itself fails, because an unanswerable
+ /// question is not evidence of absence.
+ ///
+ private async Task IsAbsentAsync(
+ string taskName,
+ CancellationToken cancellationToken)
+ {
+ SchTasksOutcome listing;
+ try
+ {
+ listing = await RunAsync(
+ ["/Query", "/FO", "CSV", "/NH"],
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (SchTasksLaunchException)
+ {
+ return false;
+ }
+
+ return listing.ExitCode == 0 &&
+ !ListingContains(listing.StandardOutput, taskName);
+ }
+
+ ///
+ /// Reports whether the CSV listing names this task.
+ ///
+ ///
+ /// Only the first column is considered. The remaining columns carry the
+ /// next run time and a localized status, neither of which identifies a
+ /// task. Task Scheduler reports names as absolute paths, so a caller's
+ /// leading separator is optional.
+ ///
+ internal static bool ListingContains(string listing, string taskName)
+ {
+ ArgumentNullException.ThrowIfNull(listing);
+ ArgumentException.ThrowIfNullOrWhiteSpace(taskName);
+
+ string wanted = taskName.TrimStart('\\');
+ foreach (string line in listing.Split(
+ ['\r', '\n'],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (!line.StartsWith('"'))
+ {
+ continue;
+ }
+
+ int closing = line.IndexOf('"', 1);
+ if (closing <= 1)
+ {
+ continue;
+ }
+
+ string name = line[1..closing].TrimStart('\\');
+ if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string Combine(string first, string second) =>
+ string.IsNullOrWhiteSpace(second) ? first : $"{first} {second}";
+
+ private static string Describe(SchTasksOutcome outcome)
+ {
+ string message = FirstMeaningfulLine(outcome.StandardError)
+ ?? FirstMeaningfulLine(outcome.StandardOutput)
+ ?? "schtasks.exe reported no diagnostics.";
+ return $"schtasks.exe exited with code {outcome.ExitCode}. {message}";
+ }
+
+ private static string? FirstMeaningfulLine(string text)
+ {
+ foreach (string line in text.Split(
+ ['\r', '\n'],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ return line;
+ }
+
+ return null;
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ // A leftover temporary file is not worth failing a registration
+ // that already succeeded.
+ }
+ }
+
+ private async Task RunAsync(
+ string[] arguments,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (_run is not null)
+ {
+ return await _run(arguments, cancellationToken).ConfigureAwait(false);
+ }
+
+ ProcessStartInfo startInfo = new()
+ {
+ FileName = _executablePath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+
+ // schtasks uses the console output code page when redirected.
+ StandardOutputEncoding = Console.OutputEncoding,
+ StandardErrorEncoding = Console.OutputEncoding
+ };
+
+ foreach (string argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using Process process = new() { StartInfo = startInfo };
+ try
+ {
+ process.Start();
+ }
+ catch (Exception exception) when (
+ exception is System.ComponentModel.Win32Exception or
+ InvalidOperationException)
+ {
+ throw new SchTasksLaunchException(
+ $"'{_executablePath}' could not be started: {exception.Message}",
+ exception);
+ }
+
+ // Both streams are drained concurrently so a full pipe buffer on one
+ // cannot block the child before it exits.
+ Task standardOutput = process.StandardOutput.ReadToEndAsync(
+ cancellationToken);
+ Task standardError = process.StandardError.ReadToEndAsync(
+ cancellationToken);
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ throw;
+ }
+
+ return new SchTasksOutcome(
+ process.ExitCode,
+ await standardOutput.ConfigureAwait(false),
+ await standardError.ConfigureAwait(false));
+ }
+
+ internal sealed record SchTasksOutcome(
+ int ExitCode,
+ string StandardOutput,
+ string StandardError);
+
+ private sealed class SchTasksLaunchException : Exception
+ {
+ public SchTasksLaunchException()
+ {
+ }
+
+ public SchTasksLaunchException(string message)
+ : base(message)
+ {
+ }
+
+ public SchTasksLaunchException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/src/OpenClaw.Launcher/HostEntrypoint.cs b/src/OpenClaw.Launcher/HostEntrypoint.cs
index 5ee95594..d881f7f1 100644
--- a/src/OpenClaw.Launcher/HostEntrypoint.cs
+++ b/src/OpenClaw.Launcher/HostEntrypoint.cs
@@ -17,10 +17,19 @@ internal static class HostEntrypointResolver
private static extern IntPtr GetCommandLineW();
public static HostEntrypoint Resolve() =>
- Resolve(TryGetNativeCommandLine());
+ Resolve(TryGetNativeCommandLine(), PackageIdentity.TryGetApplicationUserModelId());
- internal static HostEntrypoint Resolve(string? commandLine)
+ internal static HostEntrypoint Resolve(
+ string? commandLine,
+ string? applicationUserModelId = null)
{
+ if (applicationUserModelId?.EndsWith(
+ "!" + Gateway.GatewayLauncherScript.ControlApplicationId,
+ StringComparison.OrdinalIgnoreCase) == true)
+ {
+ return HostEntrypoint.Control;
+ }
+
return TryMatch(GetInvokedName(commandLine), out HostEntrypoint invoked)
? invoked
: HostEntrypoint.Agent;
diff --git a/src/OpenClaw.Launcher/HostPaths.cs b/src/OpenClaw.Launcher/HostPaths.cs
index 886c9ebc..32c1dd1f 100644
--- a/src/OpenClaw.Launcher/HostPaths.cs
+++ b/src/OpenClaw.Launcher/HostPaths.cs
@@ -9,6 +9,7 @@ internal static class PackageIdentity
{
private const int ErrorInsufficientBuffer = 122;
private const int AppModelErrorNoPackage = 15700;
+ private const int AppModelErrorNoApplication = 15703;
///
/// Prefix required by MXC when a packaged caller provisions a sandbox.
@@ -29,9 +30,25 @@ internal static class PackageIdentity
return null;
}
+ return TryGetPackageIdentity(GetCurrentPackageFamilyName);
+ }
+
+ public static string? TryGetApplicationUserModelId()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return null;
+ }
+
+ return TryGetPackageIdentity(GetCurrentApplicationUserModelId);
+ }
+
+ private static string? TryGetPackageIdentity(
+ PackageIdentityReader readIdentity)
+ {
uint length = 0;
- int result = GetCurrentPackageFamilyName(ref length, null);
- if (result == AppModelErrorNoPackage)
+ int result = readIdentity(ref length, null);
+ if (result is AppModelErrorNoPackage or AppModelErrorNoApplication)
{
return null;
}
@@ -43,7 +60,7 @@ internal static class PackageIdentity
}
var value = new char[length];
- result = GetCurrentPackageFamilyName(ref length, value);
+ result = readIdentity(ref length, value);
if (result != 0)
{
throw new InvalidOperationException(
@@ -69,6 +86,16 @@ private static extern int GetCurrentPackageFamilyName(
ref uint packageFamilyNameLength,
[Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)]
char[]? packageFamilyName);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetCurrentApplicationUserModelId(
+ ref uint applicationUserModelIdLength,
+ [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)]
+ char[]? applicationUserModelId);
+
+ private delegate int PackageIdentityReader(
+ ref uint length,
+ char[]? value);
}
///
diff --git a/src/OpenClaw.Launcher/Package.appxmanifest b/src/OpenClaw.Launcher/Package.appxmanifest
index 5781f265..c686cbf0 100644
--- a/src/OpenClaw.Launcher/Package.appxmanifest
+++ b/src/OpenClaw.Launcher/Package.appxmanifest
@@ -47,6 +47,27 @@
EntryPoint="Windows.FullTrustApplication">
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs
index 20d19130..2fea084c 100644
--- a/src/OpenClaw.Launcher/Program.cs
+++ b/src/OpenClaw.Launcher/Program.cs
@@ -255,7 +255,11 @@ internal static async Task RunControlAsync(
TextWriter output,
TextWriter error,
Func>? resolveNode = null,
- Func? createSessionRuntime = null)
+ Func? createSessionRuntime = null,
+ Func>?
+ installRecovery = null,
+ Func>?
+ removeRecovery = null)
{
Session.SessionRuntime? sessionRuntime = null;
Session.SessionRuntime GetSessionRuntime() =>
@@ -305,10 +309,25 @@ await runtime.Executor.InstallRuntimeAsync(
GetPackagedNodeArchivePath(options),
cancellationToken)
.ConfigureAwait(false);
- runtime.CompleteSetup(
- record,
- agentRuntime,
- startupEnabled: false);
+ Gateway.GatewayPersistenceInstallResult recovery =
+ installRecovery is null
+ ? await Gateway.GatewayRuntime.CreateRecoveryManager(log)
+ .InstallAsync(cancellationToken)
+ .ConfigureAwait(false)
+ : await installRecovery(cancellationToken)
+ .ConfigureAwait(false);
+ await output.WriteLineAsync(recovery.Message).ConfigureAwait(false);
+ if (!string.IsNullOrWhiteSpace(recovery.Detail))
+ {
+ await output.WriteLineAsync(recovery.Detail).ConfigureAwait(false);
+ }
+
+ if (recovery.State != Gateway.GatewayPersistenceState.Ready)
+ {
+ return 1;
+ }
+
+ runtime.CompleteSetup(record, agentRuntime, startupEnabled: true);
await output.WriteLineAsync("OpenClaw isolated session is ready.")
.ConfigureAwait(false);
return 0;
@@ -348,6 +367,22 @@ await error.WriteLineAsync(
Session.SessionRuntime runtime = GetSessionRuntime();
using Session.ISessionLockHandle handle =
runtime.AcquireLifecycleLock();
+ Gateway.GatewayPersistenceRemovalResult recoveryRemoval =
+ removeRecovery is null
+ ? await Gateway.GatewayRuntime
+ .CreateRecoveryManager(log)
+ .UninstallAsync(cancellationToken)
+ .ConfigureAwait(false)
+ : await removeRecovery(cancellationToken)
+ .ConfigureAwait(false);
+ if (!recoveryRemoval.Succeeded)
+ {
+ await error.WriteLineAsync(
+ $"OpenClaw recovery could not be removed: {recoveryRemoval.Detail}")
+ .ConfigureAwait(false);
+ return 1;
+ }
+
await runtime.Coordinator.RemoveAsync(cancellationToken)
.ConfigureAwait(false);
runtime.GatewayState.Clear();
diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/FakeGatewayTaskScheduler.cs b/tests/OpenClaw.Launcher.Tests/Gateway/FakeGatewayTaskScheduler.cs
new file mode 100644
index 00000000..de046edb
--- /dev/null
+++ b/tests/OpenClaw.Launcher.Tests/Gateway/FakeGatewayTaskScheduler.cs
@@ -0,0 +1,59 @@
+using OpenClaw.Launcher.Gateway;
+
+namespace OpenClaw.Launcher.Tests.Gateway;
+
+///
+/// A Task Scheduler that records what it was asked to do. Tests must never
+/// register, run, or delete a real scheduled task on the developer's machine.
+///
+internal sealed class FakeGatewayTaskScheduler : IGatewayTaskScheduler
+{
+ public List Calls { get; } = [];
+
+ public GatewayTaskProbe Probe { get; set; } = GatewayTaskProbe.Missing;
+
+ public GatewayTaskOperation RegisterResult { get; set; } =
+ GatewayTaskOperation.Success;
+
+ public GatewayTaskOperation DeleteResult { get; set; } =
+ GatewayTaskOperation.Success;
+
+ public GatewayTaskOperation RunResult { get; set; } =
+ GatewayTaskOperation.Success;
+
+ public string? RegisteredXml { get; private set; }
+
+ public Task QueryAsync(
+ string taskName,
+ CancellationToken cancellationToken)
+ {
+ Calls.Add($"query:{taskName}");
+ return Task.FromResult(Probe);
+ }
+
+ public Task RegisterAsync(
+ string taskName,
+ string taskXml,
+ CancellationToken cancellationToken)
+ {
+ Calls.Add($"register:{taskName}");
+ RegisteredXml = taskXml;
+ return Task.FromResult(RegisterResult);
+ }
+
+ public Task DeleteAsync(
+ string taskName,
+ CancellationToken cancellationToken)
+ {
+ Calls.Add($"delete:{taskName}");
+ return Task.FromResult(DeleteResult);
+ }
+
+ public Task RunAsync(
+ string taskName,
+ CancellationToken cancellationToken)
+ {
+ Calls.Add($"run:{taskName}");
+ return Task.FromResult(RunResult);
+ }
+}
diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs
new file mode 100644
index 00000000..5125142a
--- /dev/null
+++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs
@@ -0,0 +1,630 @@
+using System.Diagnostics;
+using OpenClaw.Launcher.Gateway;
+
+namespace OpenClaw.Launcher.Tests.Gateway;
+
+public sealed class GatewayPersistenceManagerTests : IDisposable
+{
+ private readonly string _root = TestDirectory.Create();
+ private readonly FakeGatewayTaskScheduler _scheduler = new();
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+
+ private string StateRoot => Path.Combine(_root, "state");
+
+ private string StartupFolder => Path.Combine(_root, "startup");
+
+ private string LauncherPath => Path.Combine(StateRoot, "gateway-launcher.cmd");
+
+ private string ActivationScriptPath => Path.ChangeExtension(LauncherPath, ".ps1");
+
+ private GatewayPersistenceManager CreateManager(
+ Func? resolveUserSid = null) =>
+ new(
+ _scheduler,
+ new GatewayPersistenceOptions(
+ UserSid: "S-1-5-21-1",
+ PackageFamilyName: "OpenClaw.Gateway_test",
+ LauncherPath: LauncherPath,
+ StartupFolderPath: StartupFolder,
+ WorkingDirectory: StateRoot,
+ CommandProcessorPath: @"C:\Windows\System32\cmd.exe"),
+ resolveUserSid: resolveUserSid);
+
+ private GatewayTaskSnapshot DesiredSnapshot() =>
+ GatewayTaskDefinition.CreateSnapshot(
+ "S-1-5-21-1",
+ @"C:\Windows\System32\cmd.exe",
+ LauncherPath);
+
+ [Theory]
+ [InlineData(@"C:\outside\gateway-launcher.cmd")]
+ [InlineData(@"state\child\gateway-launcher.cmd")]
+ public void RejectsLauncherPathOutsideTheStateRoot(string launcherPath)
+ {
+ GatewayPersistenceOptions options = new(
+ UserSid: "S-1-5-21-1",
+ PackageFamilyName: "OpenClaw.Gateway_test",
+ LauncherPath: Path.IsPathRooted(launcherPath)
+ ? launcherPath
+ : Path.Combine(_root, launcherPath),
+ StartupFolderPath: StartupFolder,
+ WorkingDirectory: StateRoot,
+ CommandProcessorPath: @"C:\Windows\System32\cmd.exe");
+
+ Assert.Throws(() =>
+ new GatewayPersistenceManager(_scheduler, options));
+ }
+
+ [Fact]
+ public async Task InstallingWritesTheLauncherAndRegistersTheTask()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+
+ GatewayPersistenceInstallResult result =
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.Equal(GatewayPersistenceLane.TaskScheduler, result.Lane);
+ Assert.True(result.Changed);
+ Assert.True(File.Exists(LauncherPath));
+ Assert.True(File.Exists(ActivationScriptPath));
+ Assert.Contains($"register:{manager.TaskName}", _scheduler.Calls);
+ }
+
+ [Fact]
+ public async Task TheRegisteredActionRunsTheLauncherNotTheAliasDirectly()
+ {
+ // The indirection is the point: the launcher can be rewritten
+ // unelevated, whereas changing the task's own action needs another
+ // registration and another consent prompt.
+ await CreateManager().InstallAsync(CancellationToken.None);
+
+ Assert.NotNull(_scheduler.RegisteredXml);
+ Assert.Contains(LauncherPath, _scheduler.RegisteredXml, StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "clawctl.exe",
+ _scheduler.RegisteredXml,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task TheGeneratedLauncherActivatesTheOwningPackageControlApplication()
+ {
+ await CreateManager().InstallAsync(CancellationToken.None);
+
+ string launcher = await File.ReadAllTextAsync(LauncherPath, CancellationToken.None);
+ string activation = await File.ReadAllTextAsync(ActivationScriptPath, CancellationToken.None);
+
+ Assert.Contains("chcp 65001", launcher, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("OpenClaw.Gateway_test!Control", activation, StringComparison.Ordinal);
+ Assert.Contains("ActivateApplication", activation, StringComparison.Ordinal);
+ Assert.Contains("gateway-service start", activation, StringComparison.Ordinal);
+ Assert.DoesNotContain("WindowsApps", launcher + activation, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task TheControlActivationScriptFailsClosedOnMissingOrMismatchedTargets()
+ {
+ await CreateManager().InstallAsync(CancellationToken.None);
+
+ string activation = await File.ReadAllTextAsync(ActivationScriptPath, CancellationToken.None);
+
+ Assert.Contains("PackageFamilyName -eq $packageFamilyName", activation, StringComparison.Ordinal);
+ Assert.Contains("does not declare application '$applicationId'", activation, StringComparison.Ordinal);
+ Assert.Contains("does not target openclaw.exe", activation, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AMatchingRegistrationIsNotRewritten()
+ {
+ _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot());
+ GatewayPersistenceManager manager = CreateManager();
+
+ GatewayPersistenceInstallResult result =
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.DoesNotContain(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task AnAccountNameTriggerResolvingToTheOwnerSidIsNotRewritten()
+ {
+ GatewayTaskSnapshot scheduledTask = DesiredSnapshot() with
+ {
+ LogonTriggerUserId = @"CONTOSO\agent",
+ };
+ _scheduler.Probe = GatewayTaskProbe.Present(scheduledTask);
+
+ GatewayPersistenceInstallResult result = await CreateManager(
+ account => account == @"CONTOSO\agent" ? "S-1-5-21-1" : null)
+ .InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.DoesNotContain(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task AnUnresolvableAccountNameTriggerIsRewritten()
+ {
+ GatewayTaskSnapshot scheduledTask = DesiredSnapshot() with
+ {
+ LogonTriggerUserId = @"CONTOSO\former-agent",
+ };
+ _scheduler.Probe = GatewayTaskProbe.Present(scheduledTask);
+
+ GatewayPersistenceInstallResult result = await CreateManager(_ => null)
+ .InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.Contains(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task ATaskPointingAtAnOldPackageVersionIsRewritten()
+ {
+ _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot() with
+ {
+ Command = Path.Combine(_root, "old-package", "openclaw.exe"),
+ Arguments = "gateway-service start",
+ });
+
+ GatewayPersistenceInstallResult result =
+ await CreateManager().InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.Contains(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task AnUnreadableProbeLeavesTheRegistrationAlone()
+ {
+ // Re-registering on a refused read is an unbounded retry: the write is
+ // as likely to be refused as the read was.
+ _scheduler.Probe = GatewayTaskProbe.Unreadable("Access is denied.");
+
+ GatewayPersistenceInstallResult result =
+ await CreateManager().InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Unknown, result.State);
+ Assert.DoesNotContain(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ Assert.Equal(GatewayPersistenceManager.RepairCommand, result.Remediation);
+ }
+
+ [Fact]
+ public async Task AFailedRegistrationFallsBackToTheStartupFolderAndSaysSo()
+ {
+ _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied.");
+ GatewayPersistenceManager manager = CreateManager();
+
+ GatewayPersistenceInstallResult result =
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.Equal(GatewayPersistenceLane.StartupFolderFallback, result.Lane);
+ Assert.True(File.Exists(manager.FallbackPath));
+ Assert.Contains("Access is denied.", result.Detail, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task TheFallbackCallsTheSameLauncherAsTheTask()
+ {
+ _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied.");
+ GatewayPersistenceManager manager = CreateManager();
+
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.Contains(
+ LauncherPath,
+ await File.ReadAllTextAsync(
+ manager.FallbackPath,
+ CancellationToken.None),
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task FallbackStatusRequiresAnUnmodifiedLauncher()
+ {
+ _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied.");
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+ await File.WriteAllTextAsync(
+ LauncherPath,
+ GatewayLauncherScript.Create(StateRoot, "stale.ps1"),
+ CancellationToken.None);
+
+ GatewayPersistenceStatus status =
+ await manager.GetStatusAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.NotInstalled, status.State);
+ }
+
+ [Fact]
+ public async Task FallbackStatusRejectsAGeneratedMarkerWithARedirectedTarget()
+ {
+ _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied.");
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+ await File.WriteAllTextAsync(
+ manager.FallbackPath,
+ GatewayLauncherScript.CreateFallback(Path.Combine(_root, "redirected.cmd")),
+ CancellationToken.None);
+
+ GatewayPersistenceStatus status =
+ await manager.GetStatusAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.NotInstalled, status.State);
+ }
+
+ [Fact]
+ public async Task GeneratedCommandFilesUseUtf8WithoutABom()
+ {
+ _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied.");
+ GatewayPersistenceManager manager = CreateManager();
+
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.DoesNotContain((byte)0, await File.ReadAllBytesAsync(LauncherPath));
+ Assert.DoesNotContain((byte)0, await File.ReadAllBytesAsync(manager.FallbackPath));
+ Assert.NotEqual(new byte[] { 0xEF, 0xBB, 0xBF },
+ (await File.ReadAllBytesAsync(LauncherPath))[..3]);
+ }
+
+ [Fact]
+ public async Task ASucceedingRegistrationRemovesAPreviousFallback()
+ {
+ _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied.");
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+
+ _scheduler.RegisterResult = GatewayTaskOperation.Success;
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.False(File.Exists(manager.FallbackPath));
+ }
+
+ [Fact]
+ public async Task StatusReportsNotInstalledWithoutRegisteringAnything()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+
+ GatewayPersistenceStatus status =
+ await manager.GetStatusAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.NotInstalled, status.State);
+ Assert.Equal(GatewayPersistenceLane.None, status.Lane);
+ Assert.DoesNotContain(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ Assert.False(File.Exists(LauncherPath));
+ }
+
+ [Fact]
+ public async Task StatusReportsAnUnreadableProbeAsUnknown()
+ {
+ _scheduler.Probe = GatewayTaskProbe.Unreadable("Access is denied.");
+
+ GatewayPersistenceStatus status =
+ await CreateManager().GetStatusAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Unknown, status.State);
+ }
+
+ [Theory]
+ [InlineData("disabled")]
+ [InlineData("battery")]
+ [InlineData("elevated")]
+ [InlineData("other-user")]
+ [InlineData("time-limit")]
+ [InlineData("other-command")]
+ public async Task StatusReportsDriftWithTheCommandThatRepairsIt(string kind)
+ {
+ await CreateManager().InstallAsync(CancellationToken.None);
+ GatewayTaskSnapshot desired = DesiredSnapshot();
+ _scheduler.Probe = GatewayTaskProbe.Present(kind switch
+ {
+ "disabled" => desired with { Enabled = false },
+ "battery" => desired with { DisallowStartIfOnBatteries = true },
+ "elevated" => desired with { RunLevel = "HighestAvailable" },
+ "other-user" => desired with { UserId = "S-1-5-21-999" },
+ "time-limit" => desired with { ExecutionTimeLimit = "PT72H" },
+ _ => desired with { Command = @"C:\Windows\System32\notepad.exe" }
+ });
+
+ GatewayPersistenceStatus status =
+ await CreateManager().GetStatusAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.ActionRequired, status.State);
+ Assert.Equal(GatewayPersistenceManager.RepairCommand, status.Remediation);
+ Assert.NotNull(status.Detail);
+ }
+
+ [Fact]
+ public async Task AMissingLauncherIsReportedEvenWhenTheTaskIsCorrect()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+ File.Delete(LauncherPath);
+ _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot());
+
+ GatewayPersistenceStatus status =
+ await manager.GetStatusAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.ActionRequired, status.State);
+ }
+
+ [Fact]
+ public async Task AnInstalledInstallationIsReportedAsReady()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+ _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot());
+
+ GatewayPersistenceStatus status =
+ await manager.GetStatusAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, status.State);
+ Assert.Equal(GatewayPersistenceLane.TaskScheduler, status.Lane);
+ Assert.Null(status.Detail);
+ }
+
+ [Fact]
+ public async Task UninstallingRemovesTheTaskLauncherAndFallback()
+ {
+ _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied.");
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+
+ GatewayPersistenceRemovalResult result =
+ await manager.UninstallAsync(CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ Assert.True(result.Changed);
+ Assert.False(File.Exists(LauncherPath));
+ Assert.False(File.Exists(ActivationScriptPath));
+ Assert.False(File.Exists(manager.FallbackPath));
+ Assert.Contains($"delete:{manager.TaskName}", _scheduler.Calls);
+ }
+
+ [Fact]
+ public async Task UninstallingLeavesAFileThisInstallationDidNotGenerate()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+ Directory.CreateDirectory(StartupFolder);
+ await File.WriteAllTextAsync(
+ manager.FallbackPath,
+ "@echo someone else's script",
+ CancellationToken.None);
+
+ await manager.UninstallAsync(CancellationToken.None);
+
+ Assert.True(File.Exists(manager.FallbackPath));
+ }
+
+ [Fact]
+ public async Task InstallingLeavesALauncherThisInstallationDidNotGenerate()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+ Directory.CreateDirectory(StateRoot);
+ await File.WriteAllTextAsync(
+ LauncherPath,
+ "@echo someone else's launcher",
+ CancellationToken.None);
+
+ GatewayPersistenceInstallResult result =
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.ActionRequired, result.State);
+ Assert.Equal(
+ "@echo someone else's launcher",
+ await File.ReadAllTextAsync(LauncherPath, CancellationToken.None));
+ Assert.DoesNotContain(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task UninstallingWithNoArtifactsIsIdempotent()
+ {
+ GatewayPersistenceRemovalResult result =
+ await CreateManager().UninstallAsync(CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ Assert.False(result.Changed);
+ Assert.Null(result.Detail);
+ }
+
+ [Fact]
+ public async Task AFailedDeletionIsReportedRatherThanClaimedAsSuccess()
+ {
+ _scheduler.DeleteResult = GatewayTaskOperation.Failure("Access is denied.");
+
+ GatewayPersistenceRemovalResult result =
+ await CreateManager().UninstallAsync(CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains("Access is denied.", result.Detail, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AGeneratedLauncherThatCannotBeDeletedIsReported()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+
+ using (FileStream handle = new(
+ LauncherPath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read))
+ {
+ GatewayPersistenceRemovalResult result =
+ await manager.UninstallAsync(CancellationToken.None).ConfigureAwait(true);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(LauncherPath, result.Detail, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public async Task ThePackageActivationScriptCanChangeWithoutReRegisteringTheTask()
+ {
+ GatewayPersistenceManager manager = CreateManager();
+ await manager.InstallAsync(CancellationToken.None);
+ _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot());
+ string before = _scheduler.RegisteredXml!;
+
+ await File.WriteAllTextAsync(
+ ActivationScriptPath,
+ GatewayLauncherScript.CreateActivationScript("OpenClaw.Gateway_stale"),
+ CancellationToken.None);
+ GatewayPersistenceInstallResult result =
+ await manager.InstallAsync(CancellationToken.None);
+
+ Assert.True(result.Changed);
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.Equal(before, _scheduler.RegisteredXml);
+ Assert.Contains(
+ "OpenClaw.Gateway_test!Control",
+ await File.ReadAllTextAsync(
+ ActivationScriptPath,
+ CancellationToken.None),
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ThePackageActivationSourceCompilesWithWindowsPowerShell()
+ {
+ string script = GatewayLauncherScript.CreateActivationScript(
+ "OpenClaw.Gateway_test");
+ int sourceIndex = script.IndexOf(
+ "$source = @'",
+ StringComparison.Ordinal);
+ int addTypeIndex = script.IndexOf(
+ "Add-Type -TypeDefinition $source -Language CSharp",
+ sourceIndex,
+ StringComparison.Ordinal);
+ Assert.True(sourceIndex > 0);
+ Assert.True(addTypeIndex > sourceIndex);
+ int addTypeEnd = script.IndexOf("\r\n", addTypeIndex, StringComparison.Ordinal);
+ Assert.True(addTypeEnd > addTypeIndex);
+ string compileOnlyPath = Path.Combine(_root, "compile-activation.ps1");
+ File.WriteAllText(
+ compileOnlyPath,
+ string.Concat(
+ "$ErrorActionPreference = 'Stop'\r\n",
+ script.AsSpan(sourceIndex, addTypeEnd - sourceIndex),
+ "\r\nexit 0\r\n"));
+
+ using Process process = Process.Start(new ProcessStartInfo
+ {
+ FileName = Path.Combine(
+ Environment.SystemDirectory,
+ "WindowsPowerShell",
+ "v1.0",
+ "powershell.exe"),
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ ArgumentList =
+ {
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ compileOnlyPath
+ }
+ })!;
+ string output = process.StandardOutput.ReadToEnd();
+ string error = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ Assert.True(
+ process.ExitCode == 0,
+ $"Windows PowerShell exited {process.ExitCode}.{Environment.NewLine}" +
+ $"{output}{Environment.NewLine}{error}");
+ }
+
+ [Fact]
+ public async Task TheLauncherEntersAControlledWorkingDirectory()
+ {
+ // At logon the task's working directory is the system directory.
+ await CreateManager().InstallAsync(CancellationToken.None);
+
+ Assert.Contains(
+ $"cd /d \"{StateRoot}\"",
+ await File.ReadAllTextAsync(
+ LauncherPath,
+ CancellationToken.None),
+ StringComparison.Ordinal);
+ }
+
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("C:\\Windows\\System32\\cmd.exe/d /c \"\"x\"\"C:\\Windows\\System32\\cmd.exe")]
+ [InlineData("{00000000-0000-0000-0000-000000000000}C:\\Windows\\System32\\cmd.exe/d /c \"\"x\"\"")]
+ [InlineData("{00000000-0000-0000-0000-000000000000}")]
+ public void TaskParsingRequiresExactlyOneActionAndItMustBeExec(string actions)
+ {
+ string xml = ReplaceActions(
+ GatewayTaskDefinition.CreateXml(DesiredSnapshot(), "fixture"),
+ actions);
+
+ Assert.True(GatewayTaskDefinition.TryParse(
+ xml,
+ out GatewayTaskSnapshot? snapshot,
+ out string? detail), detail);
+ Assert.False(snapshot!.HasSingleExecAction);
+ }
+
+ [Fact]
+ public async Task InstallingReRegistersATaskWithMixedExecAndComHandlerActions()
+ {
+ _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot() with
+ {
+ HasSingleExecAction = false
+ });
+
+ GatewayPersistenceInstallResult result =
+ await CreateManager().InstallAsync(CancellationToken.None);
+
+ Assert.Equal(GatewayPersistenceState.Ready, result.State);
+ Assert.Contains(
+ _scheduler.Calls,
+ call => call.StartsWith("register:", StringComparison.Ordinal));
+ }
+
+ private static string ReplaceActions(string xml, string actions)
+ {
+ const string start = "";
+ const string end = "";
+ int startIndex = xml.IndexOf(start, StringComparison.Ordinal);
+ int contentStart = startIndex + start.Length;
+ int endIndex = xml.IndexOf(end, contentStart, StringComparison.Ordinal);
+ return xml[..contentStart] + actions + xml[endIndex..];
+ }
+}
diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs
new file mode 100644
index 00000000..038d8b8c
--- /dev/null
+++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs
@@ -0,0 +1,132 @@
+using OpenClaw.Launcher.Gateway;
+
+namespace OpenClaw.Launcher.Tests.Gateway;
+
+public sealed class GatewayTaskIdentityTests
+{
+ [Fact]
+ public void TaskNameIsScopedByPackageAndUser()
+ {
+ GatewayTaskIdentity first = GatewayTaskIdentity.Create("Public_abc", "S-1-5-21-1");
+ GatewayTaskIdentity second = GatewayTaskIdentity.Create("Public_abc", "S-1-5-21-2");
+ GatewayTaskIdentity other = GatewayTaskIdentity.Create("Internal_abc", "S-1-5-21-1");
+
+ Assert.NotEqual(first.Name, second.Name);
+ Assert.NotEqual(first.Name, other.Name);
+ }
+
+ [Theory]
+ [InlineData("Public\\abc", "S-1-5-21-1")]
+ [InlineData("Public_abc", "DOMAIN\\user")]
+ public void ABackslashInANameComponentIsRejected(string packageFamilyName, string sid)
+ {
+ // Task Scheduler reads a backslash as a folder separator, so such a
+ // name would silently address a different folder.
+ Assert.Throws(
+ () => GatewayTaskIdentity.Create(packageFamilyName, sid));
+ }
+
+ [Fact]
+ public void OnlyThisPackagesTasksAreRecognized()
+ {
+ GatewayTaskIdentity identity =
+ GatewayTaskIdentity.Create("Public_abc", "S-1-5-21-1");
+
+ Assert.True(GatewayTaskIdentity.BelongsToPackage(identity.Name, "Public_abc"));
+ Assert.False(GatewayTaskIdentity.BelongsToPackage(identity.Name, "Internal_abc"));
+ }
+}
+
+public sealed class GatewayTaskDefinitionTests
+{
+ private static GatewayTaskSnapshot Desired() =>
+ GatewayTaskDefinition.CreateSnapshot(
+ "S-1-5-21-1",
+ @"C:\Windows\System32\cmd.exe",
+ @"C:\state\gateway-launcher.cmd");
+
+ [Fact]
+ public void TheGeneratedDefinitionRoundTrips()
+ {
+ GatewayTaskSnapshot desired = Desired();
+ string xml = GatewayTaskDefinition.CreateXml(desired, "OpenClaw Gateway test");
+
+ Assert.True(GatewayTaskDefinition.TryParse(
+ xml,
+ out GatewayTaskSnapshot? parsed,
+ out string? detail));
+ Assert.Null(detail);
+ Assert.Equal(desired, parsed);
+ }
+
+ [Fact]
+ public void TheGatewayIsNotGatedOnPowerOrATimeLimit()
+ {
+ GatewayTaskSnapshot desired = Desired();
+
+ Assert.False(desired.DisallowStartIfOnBatteries);
+ Assert.False(desired.StopIfGoingOnBatteries);
+ Assert.Equal(GatewayTaskDefinition.NoExecutionTimeLimit, desired.ExecutionTimeLimit);
+ }
+
+ [Fact]
+ public void ASecondSignInDoesNotStartASecondGateway()
+ {
+ Assert.Equal(GatewayTaskDefinition.IgnoreNewInstances, Desired().MultipleInstancesPolicy);
+ }
+
+ [Fact]
+ public void TheTaskRunsUnelevatedAsTheSignedInUser()
+ {
+ GatewayTaskSnapshot desired = Desired();
+
+ Assert.Equal("LeastPrivilege", desired.RunLevel);
+ Assert.Equal("InteractiveToken", desired.LogonType);
+ Assert.Equal("S-1-5-21-1", desired.UserId);
+ Assert.Equal("S-1-5-21-1", desired.LogonTriggerUserId);
+ }
+
+ [Fact]
+ public void TheActionRunsTheLauncherThroughTheCommandProcessor()
+ {
+ GatewayTaskSnapshot desired = Desired();
+
+ Assert.Equal(@"C:\Windows\System32\cmd.exe", desired.Command);
+ Assert.Contains(@"C:\state\gateway-launcher.cmd", desired.Arguments, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void UnreadableXmlIsReportedRatherThanGuessed()
+ {
+ Assert.False(GatewayTaskDefinition.TryParse(
+ "",
+ out GatewayTaskSnapshot? parsed,
+ out string? detail));
+ Assert.Null(parsed);
+ Assert.NotNull(detail);
+ }
+
+ [Fact]
+ public void AbsentSettingsParseToTaskSchedulersOwnDefaults()
+ {
+ const string xml = """
+
+
+
+
+
+
+ """;
+
+ Assert.True(GatewayTaskDefinition.TryParse(xml, out GatewayTaskSnapshot? parsed, out _));
+ Assert.NotNull(parsed);
+
+ // A definition that omits these is not equivalent to ours, so drift
+ // must not be hidden by optimistic defaults.
+ Assert.True(parsed.DisallowStartIfOnBatteries);
+ Assert.True(parsed.StopIfGoingOnBatteries);
+ Assert.Equal("PT72H", parsed.ExecutionTimeLimit);
+ Assert.False(parsed.HasSingleLogonTrigger);
+ Assert.False(parsed.HasSingleExecAction);
+ }
+}
diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/SchTasksGatewaySchedulerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/SchTasksGatewaySchedulerTests.cs
new file mode 100644
index 00000000..b55a636b
--- /dev/null
+++ b/tests/OpenClaw.Launcher.Tests/Gateway/SchTasksGatewaySchedulerTests.cs
@@ -0,0 +1,178 @@
+using OpenClaw.Launcher.Gateway;
+
+namespace OpenClaw.Launcher.Tests.Gateway;
+
+///
+/// Covers the classification that separates a missing scheduled task from one
+/// this account may not read.
+///
+///
+/// The runner is injected, so nothing here registers, runs, or deletes a real
+/// scheduled task. The diagnostics are deliberately German: the point of these
+/// tests is that classification never reads the message.
+///
+public sealed class SchTasksGatewaySchedulerTests
+{
+ private const string TaskName = @"\OpenClaw Gateway OpenClaw.Gateway_test";
+
+ private const string GermanNotFound =
+ "FEHLER: Das System kann die angegebene Datei nicht finden.";
+
+ private const string GermanAccessDenied =
+ "FEHLER: Der Zugriff wurde verweigert.";
+
+ private static string Listing(params string[] taskNames) =>
+ string.Join(
+ "\r\n",
+ taskNames.Select(name => $"\"{name}\",\"N/A\",\"Bereit\""));
+
+ private static SchTasksGatewayScheduler CreateScheduler(
+ SchTasksGatewayScheduler.SchTasksOutcome query,
+ SchTasksGatewayScheduler.SchTasksOutcome listing,
+ List? invocations = null) =>
+ new((arguments, _) =>
+ {
+ invocations?.Add(string.Join(' ', arguments));
+ bool isListing = arguments.Contains("CSV");
+ return Task.FromResult(isListing ? listing : query);
+ });
+
+ [Fact]
+ public async Task AnAlreadyCancelledRequestDoesNotInvokeTheScheduler()
+ {
+ bool invoked = false;
+ SchTasksGatewayScheduler scheduler = new((_, _) =>
+ {
+ invoked = true;
+ return Task.FromResult(new SchTasksGatewayScheduler.SchTasksOutcome(0, string.Empty, string.Empty));
+ });
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ () => scheduler.QueryAsync(TaskName, cancellation.Token));
+
+ Assert.False(invoked);
+ }
+
+ [Fact]
+ public async Task AnAbsentTaskIsMissingWhenDiagnosticsAreNotEnglish()
+ {
+ SchTasksGatewayScheduler scheduler = CreateScheduler(
+ new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanNotFound),
+ new SchTasksGatewayScheduler.SchTasksOutcome(
+ 0,
+ Listing(@"\SomeoneElsesTask"),
+ string.Empty));
+
+ GatewayTaskProbe probe = await scheduler.QueryAsync(TaskName, CancellationToken.None);
+
+ Assert.Equal(GatewayTaskPresence.Missing, probe.Presence);
+ }
+
+ // A refused read must never be reported as missing: the caller re-registers
+ // on missing, and that write would be refused too.
+ [Fact]
+ public async Task ARefusedReadOfAnExistingTaskStaysUnreadable()
+ {
+ SchTasksGatewayScheduler scheduler = CreateScheduler(
+ new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanAccessDenied),
+ new SchTasksGatewayScheduler.SchTasksOutcome(
+ 0,
+ Listing(TaskName),
+ string.Empty));
+
+ GatewayTaskProbe probe = await scheduler.QueryAsync(TaskName, CancellationToken.None);
+
+ Assert.Equal(GatewayTaskPresence.Unreadable, probe.Presence);
+ }
+
+ [Fact]
+ public async Task AFailedListingLeavesTheQuestionOpen()
+ {
+ SchTasksGatewayScheduler scheduler = CreateScheduler(
+ new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanNotFound),
+ new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanAccessDenied));
+
+ GatewayTaskProbe probe = await scheduler.QueryAsync(TaskName, CancellationToken.None);
+
+ Assert.Equal(GatewayTaskPresence.Unreadable, probe.Presence);
+ }
+
+ // The listing costs a second process launch, so it must not run when the
+ // task was read successfully.
+ [Fact]
+ public async Task ASuccessfulReadDoesNotEnumerate()
+ {
+ List invocations = [];
+ SchTasksGatewayScheduler scheduler = CreateScheduler(
+ new SchTasksGatewayScheduler.SchTasksOutcome(0, "", string.Empty),
+ new SchTasksGatewayScheduler.SchTasksOutcome(0, string.Empty, string.Empty),
+ invocations);
+
+ await scheduler.QueryAsync(TaskName, CancellationToken.None);
+
+ Assert.DoesNotContain(invocations, call => call.Contains("CSV", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task DeletingAnAlreadyAbsentTaskSucceedsOnLocalizedWindows()
+ {
+ SchTasksGatewayScheduler scheduler = CreateScheduler(
+ new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanNotFound),
+ new SchTasksGatewayScheduler.SchTasksOutcome(
+ 0,
+ Listing(@"\SomeoneElsesTask"),
+ string.Empty));
+
+ GatewayTaskOperation result = await scheduler.DeleteAsync(TaskName, CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ }
+
+ [Fact]
+ public async Task DeletingATaskThatStillExistsReportsFailure()
+ {
+ SchTasksGatewayScheduler scheduler = CreateScheduler(
+ new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanAccessDenied),
+ new SchTasksGatewayScheduler.SchTasksOutcome(
+ 0,
+ Listing(TaskName),
+ string.Empty));
+
+ GatewayTaskOperation result = await scheduler.DeleteAsync(TaskName, CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ }
+
+ // Task Scheduler reports absolute names; a caller's leading separator is
+ // incidental and must not change the answer.
+ [Theory]
+ [InlineData(@"\OpenClaw Gateway", @"\OpenClaw Gateway")]
+ [InlineData(@"OpenClaw Gateway", @"\OpenClaw Gateway")]
+ [InlineData(@"\OpenClaw Gateway", @"OpenClaw Gateway")]
+ public void ListingMatchesRegardlessOfLeadingSeparator(string listed, string wanted)
+ {
+ Assert.True(SchTasksGatewayScheduler.ListingContains(Listing(listed), wanted));
+ }
+
+ // Only the first column identifies a task. The localized status column must
+ // never produce a match.
+ [Fact]
+ public void ListingIgnoresColumnsOtherThanTheName()
+ {
+ string listing = "\"\\Other\",\"N/A\",\"OpenClaw Gateway\"";
+
+ Assert.False(
+ SchTasksGatewayScheduler.ListingContains(listing, @"\OpenClaw Gateway"));
+ }
+
+ [Fact]
+ public void ListingDoesNotMatchAPrefixOfAnotherTask()
+ {
+ Assert.False(
+ SchTasksGatewayScheduler.ListingContains(
+ Listing(@"\OpenClaw Gateway Extra"),
+ @"\OpenClaw Gateway"));
+ }
+}
diff --git a/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs b/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs
index ef7b7542..3b090add 100644
--- a/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs
+++ b/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs
@@ -16,6 +16,22 @@ public void AgentAliasSelectsThePassthroughEntrypoint() =>
HostEntrypointResolver.Resolve(
"\"C:\\Users\\someone\\AppData\\Local\\Microsoft\\WindowsApps\\openclaw.exe\" setup"));
+ [Fact]
+ public void ControlApplicationUserModelIdSelectsTheManagementEntrypoint() =>
+ Assert.Equal(
+ HostEntrypoint.Control,
+ HostEntrypointResolver.Resolve(
+ "\"C:\\Program Files\\WindowsApps\\OpenClaw\\openclaw.exe\" setup",
+ "OpenClaw.Gateway_abc123!Control"));
+
+ [Fact]
+ public void AgentApplicationUserModelIdDoesNotSelectTheManagementEntrypoint() =>
+ Assert.Equal(
+ HostEntrypoint.Agent,
+ HostEntrypointResolver.Resolve(
+ "\"C:\\Program Files\\WindowsApps\\OpenClaw\\openclaw.exe\" setup",
+ "OpenClaw.Gateway_abc123!App"));
+
[Fact]
public void UnknownOrMalformedInvocationDefaultsToAgent()
{
diff --git a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs
index f4619e24..d59fa78f 100644
--- a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs
+++ b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs
@@ -5,25 +5,37 @@ namespace OpenClaw.Launcher.Tests;
public sealed class PackageManifestTests
{
[Fact]
- public void ManifestRegistersBothAliasesToTheSingleExecutable()
+ public void ManifestRegistersThePublicAndControlApplications()
{
XDocument manifest = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "Package.appxmanifest"));
- XElement extension = Assert.Single(
+ XElement publicApplication = Assert.Single(
manifest.Descendants(),
- element =>
- element.Name.LocalName == "Extension" &&
- (string?)element.Attribute("Category") ==
- "windows.appExecutionAlias");
+ element => element.Name.LocalName == "Application" &&
+ (string?)element.Attribute("Id") == "App");
+ XElement controlApplication = Assert.Single(
+ manifest.Descendants(),
+ element => element.Name.LocalName == "Application" &&
+ (string?)element.Attribute("Id") == "Control");
+ Assert.Equal(
+ "openclaw.exe",
+ (string?)publicApplication.Attribute("Executable"));
Assert.Equal(
"openclaw.exe",
- (string?)extension.Attribute("Executable"));
+ (string?)controlApplication.Attribute("Executable"));
+
+ Assert.Equal(
+ ["openclaw.exe"],
+ Aliases(publicApplication));
+ Assert.Equal(
+ ["clawctl.exe"],
+ Aliases(controlApplication));
+ }
- string[] aliases = [.. extension.Descendants()
+ private static string[] Aliases(XElement application) =>
+ [.. application.Descendants()
.Where(element => element.Name.LocalName == "ExecutionAlias")
.Select(element => (string?)element.Attribute("Alias"))
.OfType()
.OrderBy(value => value, StringComparer.Ordinal)];
- Assert.Equal(["clawctl.exe", "openclaw.exe"], aliases);
- }
}
diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs
index d766df68..0e9e12ad 100644
--- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs
+++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs
@@ -85,7 +85,12 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication()
output,
TextWriter.Null,
_ => Task.FromResult(nodeRuntime),
- () => runtime);
+ () => runtime,
+ _ => Task.FromResult(new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.TaskScheduler,
+ "Logon recovery is configured.",
+ Changed: true)));
Assert.Equal(0, exitCode);
Assert.True(File.Exists(entryPoint));
@@ -100,7 +105,7 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication()
StringComparison.Ordinal);
SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!;
Assert.Equal(SetupPhase.Ready, setup.Phase);
- Assert.False(setup.StartupEnabled);
+ Assert.True(setup.StartupEnabled);
Assert.Equal("24.15.0", setup.AgentNodeVersion);
Assert.Contains(
_lastSessionBackend!.Calls,
@@ -134,7 +139,14 @@ await File.WriteAllTextAsync(
TextWriter.Null,
TextWriter.Null,
_ => Task.FromResult(hostNode),
- () => runtime);
+ () => runtime,
+ // Setup only reaches Ready once logon recovery is configured, and
+ // a test must never register a real scheduled task.
+ _ => Task.FromResult(new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.TaskScheduler,
+ "Logon recovery is configured.",
+ Changed: true)));
Assert.Equal(0, setupExitCode);
string expectedAgentNode = runtime.SetupState
@@ -330,6 +342,7 @@ await Assert.ThrowsAsync(
public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval()
{
SessionRuntime runtime = CreateSessionRuntime();
+ bool recoveryRemoved = false;
runtime.GatewayState.Write(new GatewayRecord
{
SchemaVersion = GatewayStateStore.CurrentSchemaVersion,
@@ -344,9 +357,16 @@ public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval()
_ => { },
TextWriter.Null,
TextWriter.Null,
- createSessionRuntime: () => runtime);
+ createSessionRuntime: () => runtime,
+ removeRecovery: _ =>
+ {
+ recoveryRemoved = true;
+ return Task.FromResult(
+ new GatewayPersistenceRemovalResult(true, false, "removed"));
+ });
Assert.Equal(0, exitCode);
+ Assert.True(recoveryRemoved);
GatewayStateResult state = runtime.GatewayState.Read();
Assert.Equal(GatewayStateFault.Missing, state.Fault);
}
@@ -451,7 +471,14 @@ await File.WriteAllTextAsync(
"node.exe",
new Version(24, 15, 0),
System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)),
- () => runtime).ConfigureAwait(false);
+ () => runtime,
+ // Setup only reaches Ready once logon recovery is configured, and
+ // a test must never register a real scheduled task.
+ _ => Task.FromResult(new GatewayPersistenceInstallResult(
+ GatewayPersistenceState.Ready,
+ GatewayPersistenceLane.TaskScheduler,
+ "Logon recovery is configured.",
+ Changed: true))).ConfigureAwait(false);
Assert.Equal(0, exitCode);
return runtime;
diff --git a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs
index b1946fd9..3823f555 100644
--- a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs
+++ b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs
@@ -69,4 +69,10 @@ public void PackageFamilyNameIsAbsentWhenRunningUnpackaged()
// is reported rather than thrown.
Assert.Null(PackageIdentity.TryGetPackageFamilyName());
}
+
+ [Fact]
+ public void ApplicationUserModelIdIsAbsentWithoutAnApplicationIdentity()
+ {
+ Assert.Null(PackageIdentity.TryGetApplicationUserModelId());
+ }
}