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()); + } }