diff --git a/README.md b/README.md index d2aca671..7ba28568 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ the read-only application directory the workspace. | Command | Behavior | |---|---| -| `clawctl setup` | Extract the bundled Node.js runtime when needed and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl setup` | On a session-capable Windows build, provision the isolated agent session, install its bundled Node.js runtime, and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl setup --no-isolation` | On a session-capable Windows build, prepare the host runtime without provisioning the isolated session. It still refuses unsupported Windows builds. | | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 1a383a78..0737bcb6 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -16,7 +16,7 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStop { get; init; } } -internal sealed record SetupOptions(bool NoIsolation); +internal sealed record SetupOptions(bool Fresh, bool Force, bool NoIsolation = false); // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the @@ -62,9 +62,31 @@ public static RootCommand Create(ClawCtlHandlers handlers) { Description = "Prepare the bundled runtime without provisioning an isolated session." }; + Option fresh = new("--fresh") + { + Description = "Remove this installation's owned session and local state before setting it up again." + }; setup.Options.Add(noIsolation); + Option force = new("--force") + { + Description = "Continue with package-local cleanup when owned external cleanup cannot be confirmed. Requires --fresh." + }; + setup.Options.Add(fresh); + setup.Options.Add(force); + setup.Validators.Add(result => + { + if (result.GetValue(force) && !result.GetValue(fresh)) + { + result.AddError("Option '--force' requires option '--fresh'."); + } + }); setup.SetAction((parsed, cancellationToken) => - handlers.Setup(new SetupOptions(parsed.GetValue(noIsolation)), cancellationToken)); + handlers.Setup( + new SetupOptions( + parsed.GetValue(fresh), + parsed.GetValue(force), + parsed.GetValue(noIsolation)), + cancellationToken)); Command status = new( StatusCommandName, "Show the isolated-session record and MXC-observed provision state without provisioning a replacement."); @@ -79,11 +101,11 @@ public static RootCommand Create(ClawCtlHandlers handlers) collectLogs.Options.Add(outputPath); collectLogs.SetAction((parsed, cancellationToken) => handlers.CollectLogs(parsed.GetValue(outputPath), cancellationToken)); - Option force = new("--force") { Description = "Skip confirmation and remove the owned session." }; + Option teardownForce = new("--force") { Description = "Skip confirmation and remove the owned session." }; Command teardown = new("teardown", "Stop and remove the owned isolated session."); - teardown.Options.Add(force); + teardown.Options.Add(teardownForce); teardown.SetAction((parsed, cancellationToken) => - handlers.Teardown(parsed.GetValue(force), cancellationToken)); + handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken)); Command powerShell = new( "pwsh", "Open an interactive PowerShell session inside the isolated agent."); diff --git a/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs new file mode 100644 index 00000000..60b3cf9d --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs @@ -0,0 +1,145 @@ +using System.Text; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// Writes gateway command results without trusting guest-produced log text. +internal static class GatewayControlOutput +{ + private const int MaximumLogTailBytes = 16 * 1024; + private const int LogTailLineCount = 10; + + public static async Task WriteStatusAsync( + TextWriter output, + GatewayStatusReport result, + string? workspacePath, + CancellationToken cancellationToken) + { + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + await WriteDetailAsync(output, result.Detail).ConfigureAwait(false); + if (result.State is GatewayState.Stopped or GatewayState.Unhealthy) + { + await WriteLogTailAsync( + output, + workspacePath, + result.Record?.LogPath, + cancellationToken).ConfigureAwait(false); + } + } + + public static async Task WriteStopAsync( + TextWriter output, + GatewayStopResult result) + { + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + await WriteDetailAsync(output, result.Detail).ConfigureAwait(false); + } + + private static async Task WriteDetailAsync(TextWriter output, string? detail) + { + if (!string.IsNullOrWhiteSpace(detail)) + { + await output.WriteLineAsync(detail).ConfigureAwait(false); + } + } + + private static async Task WriteLogTailAsync( + TextWriter output, + string? workspacePath, + string? logPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workspacePath) || + string.IsNullOrWhiteSpace(logPath)) + { + return; + } + + try + { + using FileStream stream = TrustedPath.OpenRead(workspacePath, logPath); + if (stream.Length == 0) + { + return; + } + + stream.Seek(Math.Max(0, stream.Length - MaximumLogTailBytes), SeekOrigin.Begin); + byte[] bytes = new byte[checked((int)Math.Min(MaximumLogTailBytes, stream.Length - stream.Position))]; + int offset = 0; + while (offset < bytes.Length) + { + int read = await stream.ReadAsync(bytes.AsMemory(offset), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + offset += read; + } + + string text = Encoding.UTF8.GetString(bytes, 0, offset); + string[] lines = text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + string[] tail = + [ + .. lines.TakeLast(LogTailLineCount) + .Select(Sanitize) + .Where(line => line.Length > 0) + ]; + if (tail.Length == 0) + { + return; + } + + await output.WriteLineAsync($"Gateway log tail ({logPath}):").ConfigureAwait(false); + foreach (string line in tail) + { + await output.WriteLineAsync(line).ConfigureAwait(false); + } + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or SessionException) + { + // A guest-writable diagnostic must not change a status command's result. + await output.WriteLineAsync($"Gateway log unavailable: {logPath}").ConfigureAwait(false); + } + } + + private static string Sanitize(string value) + { + StringBuilder builder = new(value.Length); + bool afterEscape = false; + bool inControlSequence = false; + foreach (char character in value) + { + if (afterEscape) + { + inControlSequence = character == '['; + afterEscape = false; + continue; + } + + if (inControlSequence) + { + if (character is >= '@' and <= '~') + { + inControlSequence = false; + } + + continue; + } + + if (character == '\x1b') + { + afterEscape = true; + } + else if (!char.IsControl(character)) + { + builder.Append(character); + } + } + + return builder.ToString(); + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index d6670747..0f58094f 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -32,6 +32,11 @@ private GatewayRuntime( public string HelperPath { get; } + internal HostPaths Paths => _paths; + + internal string? GetRecordedWorkspacePath() => + _session.Coordinator.GetRecordedStatus().Record?.WorkspacePath; + private SessionRuntime Session => _session; private static bool FileExists(string path) => File.Exists(path); @@ -197,9 +202,7 @@ Task CreateRequestAsync(CancellationToken cancellationToken applicationDirectory, launch.Port) { - WorkingDirectory = launch.WorkingDirectory ?? sessionRecord.WorkspacePath - ?? throw new SessionException( - "The isolated session has no shared workspace for the gateway.") + WorkingDirectory = launch.WorkingDirectory }); } diff --git a/src/OpenClaw.Launcher/HostDiagnosticLog.cs b/src/OpenClaw.Launcher/HostDiagnosticLog.cs index 103866e2..79b68c09 100644 --- a/src/OpenClaw.Launcher/HostDiagnosticLog.cs +++ b/src/OpenClaw.Launcher/HostDiagnosticLog.cs @@ -80,6 +80,9 @@ public void Write(string message) "Timed out waiting to append to the diagnostic log."); } + // A fresh setup deliberately clears the installation state + // root. Recreate the log directory before reopening the file. + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); using var stream = new FileStream( Path, FileMode.Append, diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index 4233eef6..89713e4f 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -25,7 +25,7 @@ internal sealed class HostStartup public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; } - public Func, Session.SessionRuntime>? CreateSessionRuntime { get; init; } + public Session.IInstallationLifecycle? InstallationLifecycle { get; init; } public Func? ReadEnvironmentVariable { get; init; } @@ -36,6 +36,7 @@ internal sealed class HostStartup BaseDirectory = AppContext.BaseDirectory, Output = Console.Out, Error = Console.Error, - InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled + InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled, + InstallationLifecycle = Session.InstallationLifecycle.Production }; } diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index d17796af..b0f87568 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -109,7 +109,8 @@ PlatformNotSupportedException or WriteDiagnostic, startup.Output, startup.Error, - startup.ResolveNode).ConfigureAwait(false) + startup.ResolveNode, + startup.InstallationLifecycle).ConfigureAwait(false) : await RunAgentAsync( options, WriteDiagnostic, @@ -118,7 +119,9 @@ PlatformNotSupportedException or GetPackagedNodeArchivePath(options), WriteDiagnostic))), startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, - startup.CreateSessionRuntime, + startup.InstallationLifecycle is null + ? null + : startup.InstallationLifecycle.CreateRuntime, readEnvironmentVariable: startup.ReadEnvironmentVariable) .ConfigureAwait(false); } @@ -147,9 +150,8 @@ internal static async Task RunAgentAsync( await RunAgentAsync( options, log, - resolveNode ?? (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( - GetPackagedNodeArchivePath(options), - log))), + resolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( + GetPackagedNodeArchivePath(options)))), GatewayLauncher.RunAsync).ConfigureAwait(false); // launchOpenClaw is a test seam: tests substitute a fake in place of @@ -171,84 +173,74 @@ internal static async Task RunAgentAsync( Session.SessionMode mode = Session.SessionRoutingPolicy.ReadMode( readEnvironmentVariable ?? Environment.GetEnvironmentVariable); + Session.SessionRoutingDecision routing; + string? packageFamilyName = null; if (mode == Session.SessionMode.Disabled) { - return await RunDirectAsync().ConfigureAwait(false); + routing = new Session.SessionRoutingDecision( + Session.SessionRouting.Direct, + $"{Session.SessionRoutingPolicy.ModeVariable} is set to 0."); } - - Session.SessionRuntime? runtime = null; - if (createSessionRuntime is null || probeReadiness is not null) + else { Mxc.MxcReadinessReport readiness = await (probeReadiness ?? Mxc.MxcReadiness.ProbeAsync)(CancellationToken.None).ConfigureAwait(false); - string? packageFamilyName = + packageFamilyName = (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(); - Session.SessionRoutingDecision routing = Session.SessionRoutingPolicy.Decide( + routing = Session.SessionRoutingPolicy.Decide( mode, packageFamilyName, readiness); - log(routing.Reason); - if (routing.Routing == Session.SessionRouting.Direct) - { - if (packageFamilyName is not null) - { - runtime = (createSessionRuntime ?? Session.SessionRuntime.Create)(log); - runtime.ValidateSavedOwnershipForHostFallback(); - } - - return await RunDirectAsync().ConfigureAwait(false); - } } - Session.SessionRecord record; - try + log(routing.Reason); + if (routing.Routing == Session.SessionRouting.Session) { - runtime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); - record = await runtime.StartForExecutionAsync(CancellationToken.None) - .ConfigureAwait(false); - } - catch (Session.SessionCapabilityUnavailableException) - when (mode == Session.SessionMode.Automatic) - { - log("Isolated session unavailable; running OpenClaw directly."); - return await RunDirectAsync().ConfigureAwait(false); + Session.SessionRuntime runtime = (createSessionRuntime ?? + Session.SessionRuntime.Create)(log); + Session.SessionRecord record = + await runtime.StartForExecutionAsync(CancellationToken.None) + .ConfigureAwait(false); + string agentNodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); + return await runtime.Executor.ExecuteAsync( + record, + new Session.SessionExecutionRequest( + runtime.RequireStagedHelper(record), + agentNodePath, + applicationDirectory, + options.OpenClawArguments, + record.WorkspacePath!) + { + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), + readEnvironmentVariable ?? Environment.GetEnvironmentVariable, + GatewayIsolationMode.Enabled) + }, + CancellationToken.None).ConfigureAwait(false); } - string agentNodePath = runtime.RequireAgentNodePath( - GetPackagedNodeArchivePath(options)); - return await runtime.Executor.ExecuteAsync( - record, - new Session.SessionExecutionRequest( - runtime.RequireStagedHelper(record), - agentNodePath, - applicationDirectory, - options.OpenClawArguments, - record.WorkspacePath!) - { - AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( - (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), - readEnvironmentVariable ?? Environment.GetEnvironmentVariable, - GatewayIsolationMode.Enabled) - }, - CancellationToken.None).ConfigureAwait(false); - - async Task RunDirectAsync() + if (mode == Session.SessionMode.Automatic && + routing.Routing == Session.SessionRouting.Direct && + packageFamilyName is not null) { - NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) - .ConfigureAwait(false); - log( - $"Using Node.js {nodeRuntime.Version} from " + - $"{nodeRuntime.ExecutablePath}."); - string directApplicationDirectory = GetPackagedApplicationDirectory(options); - log("Using the OpenClaw application directly from the package."); - return await launchOpenClaw( - nodeRuntime.ExecutablePath, - directApplicationDirectory, - options.OpenClawArguments, - GatewayIsolationMode.Disabled, - CancellationToken.None, - log).ConfigureAwait(false); + Session.SessionRuntime runtime = (createSessionRuntime ?? + Session.SessionRuntime.Create)(log); + runtime.ValidateSavedOwnershipForHostFallback(); } + + NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) + .ConfigureAwait(false); + log( + $"Using Node.js {nodeRuntime.Version} from " + + $"{nodeRuntime.ExecutablePath}."); + return await launchOpenClaw( + nodeRuntime.ExecutablePath, + applicationDirectory, + options.OpenClawArguments, + GatewayIsolationMode.Disabled, + CancellationToken.None, + log).ConfigureAwait(false); } // output and error are required parameters (not Console defaults) so tests @@ -261,134 +253,28 @@ internal static async Task RunControlAsync( TextWriter output, TextWriter error, Func>? resolveNode = null, - Func, Session.SessionRuntime>? createSessionRuntime = null, - Func, Session.TeardownOrchestrator>? - createTeardownOrchestrator = null, - Func, CancellationToken, Task>? - installRecovery = null, - Func>? probeReadiness = null, - Func? getPackageFamilyName = null, - Func>? - removeRecovery = null) + Session.IInstallationLifecycle? installationLifecycle = null, + Func? readEnvironmentVariable = null) { + _ = resolveNode; + Session.IInstallationLifecycle lifecycle = + installationLifecycle ?? Session.InstallationLifecycle.Production; Session.SessionRuntime? sessionRuntime = null; Session.SessionRuntime GetSessionRuntime() => - sessionRuntime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + sessionRuntime ??= lifecycle.CreateRuntime(log); RootCommand command = ClawCtlCommandLine.Create( new ClawCtlHandlers { - Setup = async (setupOptions, cancellationToken) => - { - string applicationDirectory = GetPackagedApplicationDirectory(options); - log("Confirmed the packaged OpenClaw application is present."); - ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); - - Mxc.MxcReadinessReport readiness = await (probeReadiness ?? - Mxc.MxcReadiness.ProbeAsync)(cancellationToken).ConfigureAwait(false); - Session.SessionRoutingDecision routing = Session.SessionRoutingPolicy.Decide( - Session.SessionMode.Automatic, - (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(), - readiness); - if (routing.Routing != Session.SessionRouting.Session) - { - await output.WriteLineAsync( - $"OpenClaw setup requires isolated-session support. {routing.Reason}") - .ConfigureAwait(false); - return 1; - } - - if (setupOptions.NoIsolation || - Session.SessionRoutingPolicy.ReadMode( - Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) - { - NodeRuntime nodeRuntime = await (resolveNode ?? - (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( - GetPackagedNodeArchivePath(options), - log))))(cancellationToken).ConfigureAwait(false); - ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); - await output.WriteLineAsync( - "OpenClaw setup completed without isolated-session provisioning.") - .ConfigureAwait(false); - return 0; - } - - try - { - Session.SessionRuntime runtime = GetSessionRuntime(); - using Session.ISessionLockHandle handle = runtime.AcquireLifecycleLock(); - runtime.SetupState.Write(new Session.SetupRecord - { - ApplicationId = runtime.ApplicationId, - Phase = Session.SetupPhase.Preparing - }); - Session.SessionStartResult session = await runtime.Coordinator - .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); - - // A replacement whose first start failed leaves the - // superseded identity only in the persisted record, so - // a retry still reconciles the stale gateway. - IEnumerable supersededSandboxIds = - (session.SupersededRecord is null - ? Enumerable.Empty() - : [session.SupersededRecord.SandboxId]) - .Concat(session.Record.SupersededSandboxIds ?? []) - .Append(session.Record.SupersededSandboxId) - .Where(static id => !string.IsNullOrWhiteSpace(id)) - .Select(static id => id!) - .Distinct(StringComparer.Ordinal); - foreach (string supersededSandboxId in supersededSandboxIds) - { - if (runtime.GatewayState.ClearForSupersededSession( - supersededSandboxId)) - { - log("Removed the gateway record for the superseded session."); - } - } - - Session.SessionRecord record = session.Record; - string helperPath = runtime.StageHelper(record); - SessionRuntimeInstallResult agentRuntime = await runtime.Executor - .InstallRuntimeAsync( - record, - helperPath, - GetPackagedNodeArchivePath(options), - cancellationToken) - .ConfigureAwait(false); - await output.WriteLineAsync( - $"Node.js {agentRuntime.Version} is ready in the isolated agent session.") - .ConfigureAwait(false); - await output.WriteLineAsync("OpenClaw isolated session is ready.") - .ConfigureAwait(false); - - Gateway.GatewayPersistenceInstallResult recovery = await ( - installRecovery ?? ((writeLog, token) => - Gateway.GatewayRuntime.CreateRecoveryManager(writeLog) - .InstallAsync(token)))(log, 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); - return 0; - } - catch (Session.SessionException exception) - { - log($"Isolated session setup is unavailable: {exception.Message}"); - await output.WriteLineAsync( - $"OpenClaw setup could not complete: {exception.Message}") - .ConfigureAwait(false); - return 1; - } - }, + Setup = (setupOptions, cancellationToken) => RunSetupAsync( + setupOptions, + options, + GetSessionRuntime, + lifecycle, + log, + output, + readEnvironmentVariable ?? Environment.GetEnvironmentVariable, + cancellationToken), Status = async cancellationToken => { Session.SessionStatus status = await GetSessionRuntime() @@ -442,33 +328,9 @@ await error.WriteLineAsync( } Session.SessionRuntime runtime = GetSessionRuntime(); - if (removeRecovery is not null) - { - using Session.ISessionLockHandle handle = - runtime.AcquireLifecycleLock(); - Gateway.GatewayPersistenceRemovalResult recovery = - await removeRecovery(cancellationToken).ConfigureAwait(false); - if (!recovery.Succeeded) - { - await error.WriteLineAsync( - $"OpenClaw recovery could not be removed: {recovery.Detail}") - .ConfigureAwait(false); - return 1; - } - - await runtime.Coordinator.RemoveAsync(cancellationToken) - .ConfigureAwait(false); - runtime.GatewayState.Clear(); - await output.WriteLineAsync("OpenClaw isolated session was removed.") - .ConfigureAwait(false); - return 0; - } - - Session.TeardownOrchestrator teardown = (createTeardownOrchestrator ?? - ((current, writeLog) => Gateway.GatewayRuntime - .CreateTeardownOrchestrator(options, current, writeLog)))(runtime, log); - Session.TeardownResult result = await teardown.RunAsync( - runtime.HelperPath, force, cancellationToken).ConfigureAwait(false); + Session.TeardownResult result = await lifecycle.TeardownAsync( + options, runtime, log, lockAlreadyHeld: false, cancellationToken) + .ConfigureAwait(false); await output.WriteLineAsync(result.Message).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(result.Detail)) { @@ -494,16 +356,15 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") }, GatewayStatus = async cancellationToken => { - Gateway.GatewayStatusReport result = await Gateway.GatewayRuntime - .Create(options, log) - .Controller + Gateway.GatewayRuntime runtime = Gateway.GatewayRuntime.Create(options, log); + Gateway.GatewayStatusReport result = await runtime.Controller .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(result.Message).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(result.Detail)) - { - await output.WriteLineAsync(result.Detail).ConfigureAwait(false); - } + await Gateway.GatewayControlOutput.WriteStatusAsync( + output, + result, + runtime.GetRecordedWorkspacePath(), + cancellationToken).ConfigureAwait(false); return result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted ? 0 : 1; }, @@ -514,11 +375,8 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .Controller .StopAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(result.Message).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(result.Detail)) - { - await output.WriteLineAsync(result.Detail).ConfigureAwait(false); - } + await Gateway.GatewayControlOutput.WriteStopAsync(output, result) + .ConfigureAwait(false); return result.Succeeded ? 0 : 1; } }); @@ -545,6 +403,262 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); } + private static async Task RunSetupAsync( + SetupOptions setupOptions, + HostOptions options, + Func getSessionRuntime, + Session.IInstallationLifecycle lifecycle, + Action log, + TextWriter output, + Func readEnvironmentVariable, + CancellationToken cancellationToken) + { + string applicationDirectory = GetPackagedApplicationDirectory(options); + log("Confirmed the packaged OpenClaw application is present."); + ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); + + Session.SessionRoutingDecision routing = await lifecycle.CheckSessionSupportAsync( + cancellationToken).ConfigureAwait(false); + if (routing.Routing != Session.SessionRouting.Session) + { + await output.WriteLineAsync( + $"OpenClaw setup requires isolated-session support. {routing.Reason}") + .ConfigureAwait(false); + return 1; + } + + if (setupOptions.NoIsolation || + Session.SessionRoutingPolicy.ReadMode( + readEnvironmentVariable) == Session.SessionMode.Disabled) + { + if (setupOptions.Fresh) + { + await output.WriteLineAsync( + "OpenClaw setup --fresh requires isolated-session provisioning. Remove --fresh or enable isolation before retrying.") + .ConfigureAwait(false); + return 1; + } + + NodeRuntime nodeRuntime = lifecycle.PrepareHostRuntime(options, log); + ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); + await output.WriteLineAsync( + "OpenClaw setup completed without isolated-session provisioning.") + .ConfigureAwait(false); + return 0; + } + + try + { + Session.SessionRuntime runtime = getSessionRuntime(); + if (setupOptions.Fresh) + { + // Resolve every required package input before removing state. + _ = lifecycle.ValidatePackageRuntime(options, runtime); + + using Session.ISessionLockHandle handle = lifecycle.AcquireLifecycleLock(runtime); + string reportPath = WriteFreshDiagnosticReport(runtime, log); + await output.WriteLineAsync($"Pre-reset diagnostic report: {reportPath}") + .ConfigureAwait(false); + Session.TeardownResult teardownResult; + try + { + teardownResult = await lifecycle.TeardownAsync( + options, runtime, log, lockAlreadyHeld: true, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + setupOptions.Force && + exception is Session.SessionException or Mxc.MxcException) + { + teardownResult = new Session.TeardownResult( + false, + "Teardown failed before external cleanup could be confirmed.", + exception.Message); + } + if (!teardownResult.Succeeded) + { + if (!setupOptions.Force) + { + await output.WriteLineAsync( + $"Warning: Fresh setup stopped because teardown is incomplete: {teardownResult.Message}") + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(teardownResult.Detail)) + { + await output.WriteLineAsync(teardownResult.Detail).ConfigureAwait(false); + } + + return 1; + } + + await output.WriteLineAsync( + $"WARNING: Forced fresh setup will continue without confirming external cleanup: {teardownResult.Message}") + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(teardownResult.Detail)) + { + await output.WriteLineAsync(teardownResult.Detail).ConfigureAwait(false); + } + } + + Session.IInstallationStateCleaner cleaner = lifecycle.CreateStateCleaner(runtime); + cleaner.Clear(); + log("Fresh setup cleared package-owned local state."); + if (!teardownResult.Succeeded) + { + const string residualWarning = + "WARNING: Forced fresh setup did not prove a pristine machine because owned external cleanup remains unresolved. " + + "Review the pre-reset report for residual sandbox or gateway identifiers. " + + "A later setup reset cannot remove resources whose ownership record was cleared; " + + "remove them through the backend's administrative cleanup path before treating this machine as pristine."; + log(residualWarning); + await output.WriteLineAsync(residualWarning).ConfigureAwait(false); + } + + return await RunSetupCoreAsync( + runtime, options, lifecycle, output, log, lockAlreadyHeld: true, cancellationToken) + .ConfigureAwait(false); + } + + return await RunSetupCoreAsync( + runtime, options, lifecycle, output, log, lockAlreadyHeld: false, cancellationToken) + .ConfigureAwait(false); + } + catch (Session.SessionException exception) + { + log($"Isolated session setup is unavailable: {exception.Message}"); + await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + catch (IOException exception) + { + log($"Fresh setup local cleanup failed: {exception.Message}"); + await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + catch (UnauthorizedAccessException exception) + { + log($"Fresh setup local cleanup was denied: {exception.Message}"); + await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + catch (OperationCanceledException) + { + string retryCommand = setupOptions.Fresh + ? "clawctl setup --fresh" + : "clawctl setup"; + log($"Setup was cancelled before it completed. Retry `{retryCommand}`."); + await output.WriteLineAsync( + $"OpenClaw setup was cancelled; setup may be incomplete. Rerun `{retryCommand}` to retry.") + .ConfigureAwait(false); + return 1; + } + } + + internal static async Task RunSetupCoreAsync( + Session.SessionRuntime runtime, + HostOptions options, + Session.IInstallationLifecycle lifecycle, + TextWriter output, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) + { + using Session.ISessionLockHandle? handle = lockAlreadyHeld + ? null + : runtime.AcquireLifecycleLock(); + runtime.SetupState.Write(new Session.SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = Session.SetupPhase.Preparing + }); + Session.SessionStartResult session = await runtime.Coordinator + .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); + IEnumerable supersededSandboxIds = (session.Record.SupersededSandboxIds ?? []) + .Append(session.Record.SupersededSandboxId) + .Append(session.SupersededRecord?.SandboxId) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Select(static id => id!) + .Where(id => !string.Equals( + id, + session.Record.SandboxId, + StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal); + foreach (string supersededSandboxId in supersededSandboxIds) + { + if (runtime.GatewayState.ClearForSupersededSession(supersededSandboxId)) + { + log($"Removed the gateway record for superseded session '{supersededSandboxId}'."); + } + } + + Session.SessionRecord record = session.Record; + string helperPath = runtime.StageHelper(record); + SessionRuntimeInstallResult agentRuntime = await runtime.Executor.InstallRuntimeAsync( + record, helperPath, GetPackagedNodeArchivePath(options), cancellationToken).ConfigureAwait(false); + await output.WriteLineAsync($"Node.js {agentRuntime.Version} is ready in the isolated agent session.") + .ConfigureAwait(false); + Gateway.GatewayPersistenceInstallResult recovery = await lifecycle + .InstallRecoveryAsync(log, 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; + } + + private static string WriteFreshDiagnosticReport( + Session.SessionRuntime runtime, + Action log) + { + Session.SessionStatus session = runtime.Coordinator.GetRecordedStatus(); + Gateway.GatewayStateResult gateway = runtime.GatewayState.Read(); + string directory = Path.Combine(Path.GetTempPath(), "OpenClawGatewayMSIX", "fresh-reset"); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, $"pre-reset-{Guid.NewGuid():N}.log"); + List lines = + [ + $"timestampUtc={DateTimeOffset.UtcNow:O}", + $"applicationId={runtime.ApplicationId}", + "report=pre-reset diagnostic metadata; credentials and local file contents are excluded" + ]; + if (session.Record is not null) + { + lines.Add($"sessionSandboxId={session.Record.SandboxId}"); + lines.Add($"sessionAgentUserName={session.Record.AgentUserName ?? string.Empty}"); + lines.Add($"sessionAgentUserSid={session.Record.AgentUserSid ?? string.Empty}"); + } + else + { + lines.Add($"sessionRecordFault={session.Fault?.ToString() ?? "none"}"); + } + + if (gateway.Record is not null) + { + lines.Add($"gatewaySandboxId={gateway.Record.SandboxId}"); + lines.Add($"gatewayProcessId={gateway.Record.ProcessId}"); + lines.Add($"gatewayLaunchPending={gateway.Record.LaunchPending}"); + } + else + { + lines.Add($"gatewayRecordFault={gateway.Fault?.ToString() ?? "none"}"); + } + + File.WriteAllLines(path, lines); + log($"Captured redacted pre-reset diagnostic report at {path}."); + return path; + } + private static async Task RunPowerShellAsync( HostOptions options, Session.SessionRuntime runtime, diff --git a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs new file mode 100644 index 00000000..eb0aac2d --- /dev/null +++ b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs @@ -0,0 +1,120 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Composes the package-owned dependencies used while installing or resetting +/// the isolated OpenClaw session. +/// +internal interface IInstallationLifecycle +{ + SessionRuntime CreateRuntime(Action log); + + Task CheckSessionSupportAsync( + CancellationToken cancellationToken); + + NodeRuntime PrepareHostRuntime(HostOptions options, Action log); + + PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime); + + ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime); + + Task TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken); + + IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime); + + Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken); +} + +internal sealed class InstallationLifecycle : IInstallationLifecycle +{ + public static InstallationLifecycle Production { get; } = new(); + + public SessionRuntime CreateRuntime(Action log) => SessionRuntime.Create(log); + + public async Task CheckSessionSupportAsync( + CancellationToken cancellationToken) + { + MxcReadinessReport readiness = await MxcReadiness.ProbeAsync(cancellationToken) + .ConfigureAwait(false); + return SessionRoutingPolicy.Decide( + SessionMode.Automatic, + HostPaths.Create().PackageFamilyName, + readiness); + } + + public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => + NodeRuntimeInstaller.EnsureInstalled( + options.PackagedNodeArchivePath + ?? throw new FileNotFoundException("The packaged Node.js runtime archive was not found."), + log); + + public PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime) + { + string nodeArchive = options.PackagedNodeArchivePath + ?? throw new FileNotFoundException("The packaged Node.js runtime archive was not found."); + Version version = NodeRuntimeInstaller.GetArchiveVersion( + nodeArchive, + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + if (!File.Exists(runtime.HelperPath)) + { + throw new FileNotFoundException( + "The packaged session helper was not found.", + runtime.HelperPath); + } + + return new PackageRuntimeMetadata(nodeArchive, version, runtime.HelperPath); + } + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime) => + runtime.AcquireLifecycleLock(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + RunTeardownAsync(options, runtime, log, lockAlreadyHeld, cancellationToken); + + private static Task RunTeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) + { + TeardownOrchestrator teardown = + GatewayRuntime.CreateTeardownOrchestrator(options, runtime, log); + return lockAlreadyHeld + ? teardown.RunUnderLockAsync(runtime.HelperPath, force: true, cancellationToken) + : teardown.RunAsync(runtime.HelperPath, force: true, cancellationToken); + } + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) => + new InstallationStateCleaner( + [runtime.Paths.StateRoot, HostDataPaths.GetProductLocalStateRoot()]); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => + GatewayRuntime.CreateRecoveryManager(log).InstallAsync(cancellationToken); +} + +internal sealed record PackageRuntimeMetadata( + string NodeArchivePath, + Version NodeVersion, + string HelperPath); + +internal interface IInstallationStateCleaner +{ + void Clear(); +} diff --git a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs new file mode 100644 index 00000000..220c466e --- /dev/null +++ b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs @@ -0,0 +1,192 @@ +namespace OpenClaw.Launcher.Session; + +/// Deletes only the contents of the two package-owned state roots. +internal sealed class InstallationStateCleaner : IInstallationStateCleaner +{ + private readonly string[] _roots; + private readonly Dictionary _rootIdentities; + private readonly IInstallationFileSystem _fileSystem; + private readonly Action? _beforeTraversal; + private readonly Action? _beforeDeleteEntry; + + public InstallationStateCleaner(HostPaths paths, string productLocalStateRoot) + : this([paths.StateRoot, productLocalStateRoot], paths.PackageFamilyName) + { + } + + internal InstallationStateCleaner( + IReadOnlyList trustedRoots, + string? packageFamilyName = "test", + IInstallationFileSystem? fileSystem = null, + Action? beforeTraversal = null, + Action? beforeDeleteEntry = null) + { + ArgumentNullException.ThrowIfNull(trustedRoots); + _fileSystem = fileSystem ?? PhysicalInstallationFileSystem.Instance; + _beforeTraversal = beforeTraversal; + _beforeDeleteEntry = beforeDeleteEntry; + if (packageFamilyName is null) + { + throw new SessionException( + "OpenClaw is not running from its installed package, so --fresh is unavailable."); + } + + _roots = [.. trustedRoots.Select(root => ValidateRoot(root, _fileSystem))]; + _rootIdentities = _fileSystem is PhysicalInstallationFileSystem + ? _roots.ToDictionary( + root => root, + root => TrustedPath.TryGetDirectoryIdentity(root), + StringComparer.OrdinalIgnoreCase) + : []; + if (_roots[0].StartsWith(_roots[1] + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + _roots[1].StartsWith(_roots[0] + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException("The installation state roots overlap and are unsafe to clear."); + } + } + + public void Clear() + { + foreach (string root in _roots.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!_fileSystem.DirectoryExists(root)) + { + continue; + } + + _beforeTraversal?.Invoke(root); + if (_fileSystem is PhysicalInstallationFileSystem) + { + using TrustedPath.ValidatedDirectory? directory = + TrustedPath.TryOpenValidatedDirectory(root, _rootIdentities[root]); + if (directory is null) + { + if (_fileSystem.DirectoryExists(root)) + { + throw new SessionException( + $"The installation state root could not be opened safely: {root}"); + } + + continue; + } + + foreach (string entry in _fileSystem.EnumerateFileSystemEntries(root)) + { + _beforeDeleteEntry?.Invoke(entry); + if (!TrustedPath.TryDeleteOwnedEntry( + directory, + entry, + deleteReparsePointLeaf: true) && + EntryExists(entry)) + { + throw new SessionException( + $"The installation state entry could not be deleted: {entry}"); + } + } + + continue; + } + + if (!_fileSystem.DirectoryExists(root) || + (_fileSystem.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + foreach (string entry in _fileSystem.EnumerateFileSystemEntries(root)) + { + DeleteEntry(entry); + } + } + } + + private static string ValidateRoot(string root, IInstallationFileSystem fileSystem) + { + string fullRoot = Path.GetFullPath(root); + string? parent = Directory.GetParent(fullRoot)?.FullName; + if (parent is null || string.Equals(fullRoot, parent, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException("The installation state root is unsafe to clear."); + } + + for (DirectoryInfo? current = new DirectoryInfo(fullRoot); + current is not null; + current = current.Parent) + { + if (fileSystem.DirectoryExists(current.FullName) && + (fileSystem.GetAttributes(current.FullName) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + "The installation state root has a reparse-point ancestor and cannot be cleared."); + } + } + + return fullRoot; + } + + private void DeleteEntry(string path) + { + FileAttributes attributes; + try + { + attributes = _fileSystem.GetAttributes(path); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return; + } + + _beforeDeleteEntry?.Invoke(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + if ((attributes & FileAttributes.Directory) != 0) + { + _fileSystem.DeleteDirectory(path); + } + else + { + _fileSystem.DeleteFile(path); + } + + return; + } + + if ((attributes & FileAttributes.Directory) == 0) + { + _fileSystem.DeleteFile(path); + return; + } + + foreach (string child in _fileSystem.EnumerateFileSystemEntries(path)) + { + DeleteEntry(child); + } + + _fileSystem.DeleteDirectory(path); + } + + private static bool EntryExists(string path) => + File.Exists(path) || Directory.Exists(path); +} + +internal interface IInstallationFileSystem +{ + bool DirectoryExists(string path); + IEnumerable EnumerateFileSystemEntries(string path); + FileAttributes GetAttributes(string path); + void DeleteFile(string path); + void DeleteDirectory(string path); +} + +internal sealed class PhysicalInstallationFileSystem : IInstallationFileSystem +{ + public static PhysicalInstallationFileSystem Instance { get; } = new(); + + public bool DirectoryExists(string path) => Directory.Exists(path); + public IEnumerable EnumerateFileSystemEntries(string path) => + Directory.EnumerateFileSystemEntries(path); + public FileAttributes GetAttributes(string path) => File.GetAttributes(path); + public void DeleteFile(string path) => File.Delete(path); + public void DeleteDirectory(string path) => Directory.Delete(path); +} diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 598b9994..295697d4 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -28,6 +28,7 @@ private SessionRuntime( IMxcSessionClient backend, string helperPath, string applicationId, + HostPaths paths, SetupStateStore setupState, GatewayStateStore gatewayState, string lifecycleLockScope) @@ -37,6 +38,7 @@ private SessionRuntime( Backend = backend; HelperPath = helperPath; ApplicationId = applicationId; + Paths = paths; SetupState = setupState; GatewayState = gatewayState; LifecycleLock = new NamedSessionLock(lifecycleLockScope); @@ -56,6 +58,8 @@ private SessionRuntime( public string ApplicationId { get; } + public HostPaths Paths { get; } + public SetupStateStore SetupState { get; } public GatewayStateStore GatewayState { get; } @@ -138,6 +142,7 @@ internal static SessionRuntime Create( client, ResolveHelperPath(baseDirectory), applicationId, + paths, new SetupStateStore(paths.SetupStatePath), new GatewayStateStore(paths.GatewayStatePath), paths.SessionStatePath + "_Installation"); @@ -197,17 +202,56 @@ session.Detail is null return session.Record; } + /// + /// Validates local ownership before automatic host fallback. + /// + /// + /// Automatic fallback is allowed only when this installation has no saved + /// session or setup state. A foreign, corrupt, or mismatched record is + /// evidence that replacement or recovery is required, not permission to + /// run the user's workload under a different profile. A session record + /// without its setup marker must be completed with clawctl setup. + /// public void ValidateSavedOwnershipForHostFallback() { - SetupStateResult setup = SetupState.Read(ApplicationId); SessionStatus session = Coordinator.GetRecordedStatus(); - if (setup.Fault == SetupStateFault.Missing && - session.Availability == SessionAvailability.None) + SetupStateResult setup = SetupState.Read(ApplicationId); + if (session.Availability == SessionAvailability.None && + setup.Fault == SetupStateFault.Missing) { return; } - RequireSetup(); + if (session.Record is null) + { + throw new SessionException( + $"{session.Detail ?? "The saved isolated-session record could not be used."} " + + "Run `clawctl setup` to repair it."); + } + + if (setup.Record is null) + { + throw new SessionException( + "The isolated session is recorded but explicit setup has not completed. " + + "Run `clawctl setup` before using automatic host fallback."); + } + + if (setup.Record.Phase != SetupPhase.Ready) + { + throw new SessionException( + "The saved isolated-session setup is incomplete. " + + "Run `clawctl setup` before using automatic host fallback."); + } + + if (!string.Equals( + setup.Record.SandboxId, + session.Record.SandboxId, + StringComparison.Ordinal)) + { + throw new SessionException( + "The saved isolated-session records name different sessions. " + + "Run `clawctl setup` to reconcile them."); + } } /// diff --git a/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs index fa07dcc3..7484e86f 100644 --- a/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs +++ b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs @@ -38,7 +38,15 @@ public async Task RunAsync( { using ISessionLockHandle handle = _lock.TryAcquire(SessionCoordinator.DefaultLockTimeout) ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); + return await RunUnderLockAsync(helperPath, force, cancellationToken).ConfigureAwait(false); + } + /// Runs teardown while the caller owns the installation lifecycle lock. + public async Task RunUnderLockAsync( + string helperPath, + bool force, + CancellationToken cancellationToken) + { GatewayPersistenceRemovalResult recovery = await _recovery.UninstallAsync(cancellationToken) .ConfigureAwait(false); if (!recovery.Succeeded) diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index d4d51611..5de0b177 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -38,6 +38,7 @@ private static async Task Main() ("--version reports the launcher", VersionReportsLauncherAssemblyAsync), ("--version wins over trailing arguments", VersionWinsOverTrailingAsync), ("unknown command fails", UnknownCommandFailsAsync), + ("setup --force requires --fresh", SetupForceRequiresFreshAsync), ("response-file token is not expanded", ResponseFileTokenIsNotExpandedAsync), ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), @@ -177,6 +178,17 @@ private static async Task UnknownCommandFailsAsync() fixture.AssertNodeWasNotResolved(); } + private static async Task SetupForceRequiresFreshAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["setup", "--force"]).ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + AssertContains(fixture.Error.ToString(), "requires option '--fresh'", fixture); + fixture.AssertNodeWasNotResolved(); + } + // Response-file expansion is disabled, so a readable file behind an `@` // token must still be rejected as an unrecognized argument. private static async Task ResponseFileTokenIsNotExpandedAsync() diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index d5f1632d..5110948f 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -132,6 +132,60 @@ public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() Normalize(ClawCtlCommandLine.SetupDescription), help, StringComparison.Ordinal); + Assert.Contains("--fresh", help, StringComparison.Ordinal); + Assert.Contains("Requires --fresh", help, StringComparison.Ordinal); + } + + [Fact] + public async Task SetupFreshPassesTheExplicitDestructiveAuthorization() + { + SetupOptions? received = null; + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (options, _) => + { + received = options; + return Task.FromResult(0); + }, + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }); + + int exitCode = await root.Parse("setup --fresh").InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(new SetupOptions(Fresh: true, Force: false), received); + } + + [Fact] + public async Task SetupFreshForcePassesTheExplicitRecoveryOverride() + { + SetupOptions? received = null; + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (options, _) => + { + received = options; + return Task.FromResult(0); + }, + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }); + + int exitCode = await root.Parse("setup --fresh --force").InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(new SetupOptions(Fresh: true, Force: true), received); } [Fact] @@ -191,6 +245,7 @@ public async Task VersionWinsOverTrailingArguments() [InlineData("gateway-service")] [InlineData("setup", "extra")] [InlineData("setup", "--bogus")] + [InlineData("setup", "--force")] public async Task RejectedInputFailsWithoutStartingSetup(params string[] args) { (int exitCode, string output, string error) = await RunAsync(args).ConfigureAwait(true); diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs new file mode 100644 index 00000000..a30b3bd0 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs @@ -0,0 +1,92 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class GatewayControlOutputTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task StatusIncludesDetailAndSanitizedBoundedLogTailForAStoppedGateway() + { + string workspace = Path.Combine(_root, "workspace"); + string logPath = Path.Combine(workspace, "gateway.log"); + Directory.CreateDirectory(workspace); + string[] lines = + [ + .. Enumerable.Range(1, 12).Select(index => + index == 12 ? "\u001b[31mfinal\u0001 line\u001b[0m" : $"line {index}") + ]; + await File.WriteAllLinesAsync(logPath, lines).ConfigureAwait(true); + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStatusAsync( + output, + new GatewayStatusReport( + GatewayState.Stopped, + new GatewayRecord { LogPath = logPath }, + "The gateway is not running.", + "the application exited with code 78"), + workspace, + CancellationToken.None).ConfigureAwait(true); + + string rendered = output.ToString(); + Assert.Contains("The gateway is not running.", rendered, StringComparison.Ordinal); + Assert.Contains("the application exited with code 78", rendered, StringComparison.Ordinal); + Assert.Contains($"Gateway log tail ({logPath}):", rendered, StringComparison.Ordinal); + Assert.DoesNotContain($"line 1{Environment.NewLine}", rendered, StringComparison.Ordinal); + Assert.Contains("line 3", rendered, StringComparison.Ordinal); + Assert.Contains("final line", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("31m", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("0m", rendered, StringComparison.Ordinal); + Assert.DoesNotContain('\u001b', rendered); + Assert.DoesNotContain('\u0001', rendered); + } + + [Fact] + public async Task StatusKeepsFailureOutputWhenTheGuestLogIsUnavailable() + { + string workspace = Path.Combine(_root, "workspace"); + string logPath = Path.Combine(workspace, "gateway.log"); + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStatusAsync( + output, + new GatewayStatusReport( + GatewayState.Unhealthy, + new GatewayRecord { LogPath = logPath }, + "The gateway is not serving.", + "Inspect diagnostics before retrying."), + workspace, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal( + $"The gateway is not serving.{Environment.NewLine}Inspect diagnostics before retrying.{Environment.NewLine}" + + $"Gateway log unavailable: {logPath}{Environment.NewLine}", + output.ToString()); + } + + [Fact] + public async Task StopIncludesTheUnconfirmedLaunchRecoveryInstruction() + { + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStopAsync( + output, + new GatewayStopResult( + Stopped: false, + Message: "The gateway was not stopped.", + Detail: "Do not start a replacement. Run `clawctl teardown` to recover.")) + .ConfigureAwait(true); + + Assert.Contains("Do not start a replacement. Run `clawctl teardown` to recover.", output.ToString(), StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs index 5f28c81c..cd7291c4 100644 --- a/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs @@ -47,6 +47,20 @@ public void ConcurrentInvocationsSerializeCompleteLogRecords() Assert.All(lines, line => Assert.Contains(" invocation ", line, StringComparison.Ordinal)); } + [Fact] + public void WriteRecreatesItsDirectoryAfterInstallationCleanup() + { + string directory = Path.Combine(_testDirectory, "logs"); + string path = Path.Combine(directory, "host.log"); + using HostDiagnosticLog log = HostDiagnosticLog.Create(path); + log.Write("Before cleanup."); + Directory.Delete(directory, recursive: true); + + log.Write("After cleanup."); + + Assert.Contains("After cleanup.", File.ReadAllText(path), StringComparison.Ordinal); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index 76a10ca8..3141b942 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -10,6 +10,12 @@ public sealed class ProgramStartupTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); + [Fact] + public void ProductionStartupProvidesTheLifecycleFactoryForFallbackValidation() + { + Assert.NotNull(HostStartup.CreateProduction().InstallationLifecycle); + } + [Fact] public async Task StartupWritesRecordsToTheSuppliedDiagnosticLog() { diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 63a92dad..58ae4219 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1,5 +1,5 @@ -using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Session; using OpenClaw.Launcher.Tests.Session; using OpenClaw.SessionProtocol; @@ -10,7 +10,6 @@ public sealed class ProgramTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); private FakeMxcSessionClient? _lastSessionBackend; - private string? _sessionStatePath; [Fact] public async Task AgentLaunchResolvesNodeAndRunsPackagedApplication() @@ -61,22 +60,19 @@ await File.WriteAllTextAsync( } [Fact] - public async Task SetupPreparesNodeAndChecksPackagedApplication() + public async Task SetupReportsAnUnavailableIsolatedSessionWithoutResolvingHostNode() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); await File.WriteAllTextAsync(entryPoint, "console.log('fixture');"); - string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); - await File.WriteAllTextAsync(archivePath, "fixture"); DateTime lastWriteTime = File.GetLastWriteTimeUtc(entryPoint); - var options = new HostOptions(applicationDirectory, archivePath, []); + var options = new HostOptions(applicationDirectory, null, []); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); using var output = new StringWriter(); - SessionRuntime runtime = CreateSessionRuntime(); int exitCode = await Program.RunControlAsync( options, @@ -84,13 +80,9 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() _ => { }, output, TextWriter.Null, - _ => Task.FromResult(nodeRuntime), - _ => runtime, - installRecovery: RecoveryConfigured, - probeReadiness: SupportedHost, - getPackageFamilyName: () => "OpenClaw.Gateway_test"); + _ => Task.FromResult(nodeRuntime)); - Assert.Equal(0, exitCode); + Assert.Equal(1, exitCode); Assert.True(File.Exists(entryPoint)); Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(entryPoint)); Assert.DoesNotContain( @@ -101,34 +93,19 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() applicationDirectory, output.ToString(), StringComparison.Ordinal); - SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; - Assert.Equal(SetupPhase.Ready, setup.Phase); - Assert.True(setup.StartupEnabled); - Assert.Equal("24.15.0", setup.AgentNodeVersion); Assert.Contains( - _lastSessionBackend!.Calls, - call => call.StartsWith("execute:", StringComparison.Ordinal)); - Assert.DoesNotContain( - _lastSessionBackend.Calls, - call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); + "requires isolated-session support", + output.ToString(), + StringComparison.OrdinalIgnoreCase); } [Fact] public async Task AgentUsesTheRuntimeInstalledForTheSessionWithoutHostFallback() { - string applicationDirectory = Path.Combine(_testDirectory, "app"); - Directory.CreateDirectory(applicationDirectory); - await File.WriteAllTextAsync( - Path.Combine(applicationDirectory, "openclaw.mjs"), - "console.log('fixture');"); - string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); - await File.WriteAllTextAsync(archivePath, "fixture"); - var options = new HostOptions(applicationDirectory, archivePath, ["gateway"]); - var hostNode = new NodeRuntime( - Path.Combine(_testDirectory, "host-node.exe"), - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions options = CreateSetupOptions(applicationDirectory); SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }; int setupExitCode = await Program.RunControlAsync( options, @@ -136,13 +113,7 @@ await File.WriteAllTextAsync( _ => { }, TextWriter.Null, TextWriter.Null, - _ => Task.FromResult(hostNode), - _ => runtime, - // Setup only reaches Ready once logon recovery is configured, and - // a test must never register a real scheduled task. - installRecovery: RecoveryConfigured, - probeReadiness: SupportedHost, - getPackageFamilyName: () => "OpenClaw.Gateway_test"); + installationLifecycle: lifecycle); Assert.Equal(0, setupExitCode); string expectedAgentNode = runtime.SetupState @@ -188,14 +159,153 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( Assert.False(directLaunchAttempted); } + [Theory] + [InlineData("corrupt")] + [InlineData("foreign")] + [InlineData("mismatched")] + public async Task AutomaticHostFallbackRefusesSavedOwnershipFailures( + string stateKind) + { + SessionRuntime runtime = CreateSessionRuntime(); + string applicationDirectory = Path.Combine(_testDirectory, "fallback-app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "fixture").ConfigureAwait(true); + Directory.CreateDirectory(Path.GetDirectoryName(runtime.Paths.SessionStatePath)!); + SessionRecord session = new() + { + ApplicationId = runtime.ApplicationId, + SandboxId = "iso:saved", + WorkspacePath = Path.Combine(_testDirectory, "workspace"), + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }; + Directory.CreateDirectory(session.WorkspacePath!); + + switch (stateKind) + { + case "corrupt": + await File.WriteAllTextAsync( + runtime.Paths.SessionStatePath, + "{ not json").ConfigureAwait(true); + break; + case "foreign": + await File.WriteAllTextAsync( + runtime.Paths.SessionStatePath, + """ + {"schemaVersion":1,"sandboxId":"iso:foreign","applicationId":"PFN:Other","createdUtc":"2026-01-01T00:00:00Z"} + """).ConfigureAwait(true); + break; + default: + new SessionStateStore(runtime.Paths.SessionStatePath).Write(session); + runtime.SetupState.Write(new SetupRecord + { + ApplicationId = runtime.ApplicationId, + SandboxId = "iso:different", + Phase = SetupPhase.Ready + }); + break; + } + + bool hostPrepared = false; + bool hostLaunched = false; + + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, ["--version"]), + _ => { }, + _ => + { + hostPrepared = true; + return Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); + }, + (_, _, _, _, _, _) => + { + hostLaunched = true; + return Task.FromResult(0); + }, + _ => runtime, + _ => Task.FromResult(new MxcReadinessReport( + null, + null, + "backend unavailable", + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + () => runtime.Paths.PackageFamilyName, + _ => null)); + + Assert.False(hostPrepared); + Assert.False(hostLaunched); + } + + [Fact] + public async Task AutomaticHostFallbackDoesNotCreateASessionRuntimeWithoutPackageIdentity() + { + string applicationDirectory = Path.Combine(_testDirectory, "unpackaged-app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "fixture").ConfigureAwait(true); + bool runtimeCreated = false; + + int exitCode = await Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, ["--version"]), + _ => { }, + _ => Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), + (_, _, _, _, _, _) => Task.FromResult(17), + createSessionRuntime: _ => + { + runtimeCreated = true; + return CreateSessionRuntime(); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + null, + null, + "backend unavailable", + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => null, + readEnvironmentVariable: _ => null); + + Assert.False(runtimeCreated); + Assert.Equal(17, exitCode); + } + + [Fact] + public void SavedSessionWithoutSetupExplainsTheRequiredRecoveryCommand() + { + SessionRuntime runtime = CreateSessionRuntime(); + new SessionStateStore(runtime.Paths.SessionStatePath).Write(new SessionRecord + { + ApplicationId = runtime.ApplicationId, + SandboxId = "iso:saved", + WorkspacePath = Path.Combine(_testDirectory, "workspace"), + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }); + + SessionException exception = Assert.Throws( + runtime.ValidateSavedOwnershipForHostFallback); + + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); + } + [Fact] public async Task AgentRefusesForeignSessionRecordWithoutHostFallback() { SessionRuntime runtime = await SetUpSessionAsync(); var directLaunches = new List(); - SessionRecord record = new SessionStateStore(_sessionStatePath!) + SessionRecord record = new SessionStateStore(runtime.Paths.SessionStatePath) .Read(runtime.ApplicationId).Record!; - new SessionStateStore(_sessionStatePath!).Write(record with + new SessionStateStore(runtime.Paths.SessionStatePath).Write(record with { SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") }); @@ -212,9 +322,9 @@ public async Task AutomaticDirectRoutingValidatesExistingOwnershipFirst() { SessionRuntime runtime = await SetUpSessionAsync(); var directLaunches = new List(); - SessionRecord record = new SessionStateStore(_sessionStatePath!) + SessionRecord record = new SessionStateStore(runtime.Paths.SessionStatePath) .Read(runtime.ApplicationId).Record!; - new SessionStateStore(_sessionStatePath!).Write(record with + new SessionStateStore(runtime.Paths.SessionStatePath).Write(record with { SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") }); @@ -257,7 +367,7 @@ public async Task AgentRefusesUnreadableSessionRecordWithoutHostFallback() { SessionRuntime runtime = await SetUpSessionAsync(); var directLaunches = new List(); - await File.WriteAllTextAsync(_sessionStatePath!, "{ not json"); + await File.WriteAllTextAsync(runtime.Paths.SessionStatePath, "{ not json"); SessionException failure = await Assert.ThrowsAsync( () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); @@ -308,17 +418,405 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() _ => { }, TextWriter.Null, error, - createSessionRuntime: _ => runtime); + installationLifecycle: new FailingFreshLifecycle(runtime)); Assert.Equal(1, exitCode); Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("--no-isolation", null)] + [InlineData(null, "0")] + public async Task FreshSetupRejectsDisabledIsolationBeforePreparingTheHostRuntime( + string? option, + string? sessionMode) + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + var lifecycle = new FailingFreshLifecycle(CreateSessionRuntime()); + using var output = new StringWriter(); + string[] arguments = option is null + ? ["setup", "--fresh"] + : ["setup", "--fresh", option]; + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + arguments, + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle, + readEnvironmentVariable: name => + name == SessionRoutingPolicy.ModeVariable ? sessionMode : null); + + Assert.Equal(1, exitCode); + Assert.Empty(lifecycle.Calls); + Assert.Contains("--fresh requires isolated-session provisioning", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task FreshSetupStopsBeforeClearingStateWhenTeardownFails() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + var lifecycle = new FailingFreshLifecycle(CreateSessionRuntime()); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session"], lifecycle.Calls); + Assert.False(lifecycle.Cleaner.Cleared); + Assert.Contains("Pre-reset diagnostic report:", output.ToString(), StringComparison.Ordinal); + Assert.Contains("teardown is incomplete", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task FreshSetupResetsInOrderBeforeProvisioningAndRecordsConsistentNewState() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + Assert.Equal( + ["validate", "lock", "recovery", "gateway", "session", "clean"], + lifecycle.Calls); + Assert.Equal(SetupPhase.Ready, runtime.SetupState.Read(runtime.ApplicationId).Record!.Phase); + Assert.NotNull(runtime.Coordinator.GetRecordedStatus().Record); + Assert.Contains( + ((FakeMxcSessionClient)runtime.Backend).Calls, + call => call.StartsWith("execute:", StringComparison.Ordinal)); Assert.DoesNotContain( - _lastSessionBackend!.Calls, - call => call.StartsWith("deprovision:", StringComparison.Ordinal)); + ((FakeMxcSessionClient)runtime.Backend).Calls, + call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); + Assert.Contains("OpenClaw isolated session is ready.", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task FreshSetupCleanerFailurePreventsProvisionAndReady() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + var lifecycle = new FailingFreshLifecycle(CreateSessionRuntime()) + { + TeardownSucceeds = true, + CleanerException = new IOException("state file is locked") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, output, TextWriter.Null, installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session", "clean"], lifecycle.Calls); + Assert.DoesNotContain( + "OpenClaw isolated session is ready.", + output.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task ForcedFreshSetupContinuesAfterUnresolvedOwnedCleanupAndKeepsTheWarning() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult( + Succeeded: false, + Message: "MXC backend is unavailable.", + Detail: "The owned sandbox record could not be verified.") + }; + List diagnostics = []; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + diagnostics.Add, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session", "clean"], lifecycle.Calls); + Assert.True(lifecycle.Cleaner.Cleared); + Assert.Equal(SetupPhase.Ready, runtime.SetupState.Read(runtime.ApplicationId).Record!.Phase); + Assert.Contains( + "did not prove a pristine machine", + output.ToString(), + StringComparison.Ordinal); + Assert.Contains( + diagnostics, + text => text.Contains("did not prove a pristine machine", StringComparison.Ordinal)); + } + + [Fact] + public async Task ForcedFreshSetupContinuesWhenTeardownThrowsARecoverableFailure() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownException = new SessionStateException( + SessionStateFault.Unreadable, + "session.json cannot be read") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + Assert.True(lifecycle.Cleaner.Cleared); + Assert.Contains( + "session.json cannot be read", + output.ToString(), + StringComparison.Ordinal); + Assert.Contains( + "cannot remove resources whose ownership record was cleared", + output.ToString(), + StringComparison.Ordinal); } [Fact] - public async Task SetupReportsMissingApplicationBeforeResolvingNode() + public async Task FreshResetReportPreservesReadableResidualIdentityOutsideClearedState() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + const string oldSandboxId = "iso:residual-session"; + new SessionStateStore(runtime.Paths.SessionStatePath).Write(new SessionRecord + { + SchemaVersion = SessionStateStore.CurrentSchemaVersion, + SandboxId = oldSandboxId, + ApplicationId = runtime.ApplicationId, + AgentUserName = "agent_old", + AgentUserSid = "S-1-5-21-0-0-0-1010", + WorkspacePath = Path.Combine(_testDirectory, "old-workspace"), + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Backend unavailable.") + }; + lifecycle.Cleaner.Cleanup = () => File.Delete(runtime.Paths.SessionStatePath); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + string reportLine = output.ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Single(line => line.StartsWith("Pre-reset diagnostic report:", StringComparison.Ordinal)); + string reportPath = reportLine["Pre-reset diagnostic report:".Length..].Trim(); + try + { + string report = await File.ReadAllTextAsync(reportPath); + Assert.Contains($"sessionSandboxId={oldSandboxId}", report, StringComparison.Ordinal); + } + finally + { + File.Delete(reportPath); + } + } + + [Fact] + public async Task ForcedFreshSetupRecreatesDiagnosticsWithTheResidualWarning() + { + SessionRuntime runtime = CreateSessionRuntime(); + string baseDirectory = Path.Combine(_testDirectory, "base"); + string applicationDirectory = Path.Combine(baseDirectory, "app"); + string runtimeDirectory = Path.Combine(baseDirectory, "runtime"); + Directory.CreateDirectory(applicationDirectory); + Directory.CreateDirectory(runtimeDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + await File.WriteAllTextAsync( + Path.Combine(runtimeDirectory, "node-v24.20.0-win-x64.zip"), + "fixture"); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Owned backend cleanup remains unresolved.") + }; + lifecycle.Cleaner.Cleanup = () => Directory.Delete(runtime.Paths.StateRoot, recursive: true); + string logPath = Path.Combine(runtime.Paths.StateRoot, "Logs", "openclaw.log"); + using var diagnostics = HostDiagnosticLog.Create(logPath); + diagnostics.Write("Host started through the clawctl entrypoint."); + using var output = new StringWriter(); + HostStartup startup = new() + { + Entrypoint = HostEntrypoint.Control, + CreateDiagnostics = () => diagnostics, + BaseDirectory = baseDirectory, + Output = output, + Error = TextWriter.Null, + InstallationLifecycle = lifecycle + }; + + int exitCode = await Program.RunAsync(["setup", "--fresh", "--force"], startup); + + Assert.True(exitCode == 0, output.ToString()); + string diagnosticsText = await File.ReadAllTextAsync(logPath); + Assert.Contains("did not prove a pristine machine", diagnosticsText, StringComparison.Ordinal); + Assert.DoesNotContain("Host started through", diagnosticsText, StringComparison.Ordinal); + } + + [Fact] + public async Task ForcedFreshSetupStillStopsWhenLocalCleanupFails() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Owned backend cleanup could not be confirmed."), + CleanerException = new UnauthorizedAccessException("state root is denied") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session", "clean"], lifecycle.Calls); + Assert.Null(runtime.SetupState.Read(runtime.ApplicationId).Record); + Assert.DoesNotContain("isolated session is ready", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ForcedFreshSetupCancellationDoesNotClearOrProvision() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownException = new OperationCanceledException() + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.False(lifecycle.Cleaner.Cleared); + Assert.Null(runtime.SetupState.Read(runtime.ApplicationId).Record); + Assert.Contains("cancelled", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task FreshSetupProvisionFailureDoesNotClaimReady() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + ((FakeMxcSessionClient)runtime.Backend).ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles( + ((FakeMxcSessionClient)runtime.Backend).Metadata!.EphemeralWorkspacePath, + "runtime-*.json").Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + Error = "installer failed" + })); + return Task.FromResult(new MxcExecutionResult(1, string.Empty, string.Empty)); + }; + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownSucceeds = true + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, output, TextWriter.Null, installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal( + ["validate", "lock", "recovery", "gateway", "session", "clean"], + lifecycle.Calls); + Assert.DoesNotContain("OpenClaw isolated session is ready.", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ConcurrentFreshSetupReportsBusyWithoutStartingAnotherReset() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + FakeMxcSessionClient backend = (FakeMxcSessionClient)runtime.Backend; + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownSucceeds = true, + }; + backend.ExecuteBehavior = async _ => + { + lifecycle.ProvisionStarted.TrySetResult(); + await lifecycle.AllowProvision.Task.ConfigureAwait(false); + WriteRuntimeInstallResult(backend, 0); + return new MxcExecutionResult(0, string.Empty, string.Empty); + }; + var firstOutput = new StringWriter(); + Task first = Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, firstOutput, TextWriter.Null, installationLifecycle: lifecycle); + await lifecycle.ProvisionStarted.Task + .WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(true); + using var secondOutput = new StringWriter(); + + int second = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, secondOutput, TextWriter.Null, installationLifecycle: lifecycle); + lifecycle.AllowProvision.TrySetResult(); + + Assert.Equal(0, await first.ConfigureAwait(true)); + firstOutput.Dispose(); + Assert.Equal(1, second); + Assert.Equal(1, lifecycle.TeardownCount); + Assert.Contains("Another OpenClaw process", secondOutput.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task SetupReportsMissingApplicationBeforeResolvingHostNode() { bool nodeResolutionAttempted = false; @@ -344,128 +842,141 @@ await Assert.ThrowsAsync( } [Fact] - public async Task NoIsolationSetupPreparesTheHostRuntimeOnASupportedMachine() + public async Task AgentReportsMissingApplicationBeforeResolvingHostNode() + { + bool nodeResolutionAttempted = false; + + await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(null, null, []), + _ => { }, + _ => + { + nodeResolutionAttempted = true; + return Task.FromResult( + new NodeRuntime( + "node.exe", + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); + })); + + Assert.False(nodeResolutionAttempted); + } + + [Fact] + public async Task AutomaticAgentLaunchUsesHostOnlyWhenReadinessReportsIsolationUnsupported() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); - await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); - bool resolvedNode = false; - using var output = new StringWriter(); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool resolvedHostNode = false; + bool launchedHost = false; - int exitCode = await Program.RunControlAsync( - new HostOptions(applicationDirectory, "node.zip", []), - ["setup", "--no-isolation"], + int exitCode = await Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), _ => { }, - output, - TextWriter.Null, _ => { - resolvedNode = true; + resolvedHostNode = true; return Task.FromResult(new NodeRuntime( "node.exe", - new Version(24, 20, 0), + new Version(24, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); }, + (_, _, _, _, _, _) => + { + launchedHost = true; + return Task.FromResult(17); + }, probeReadiness: _ => Task.FromResult(new MxcReadinessReport( "runtime", null, null, - MxcHostSupport.Supported, + MxcHostSupport.Unsupported, null, MxcSupportEvidence.HostBuild)), - getPackageFamilyName: () => "OpenClaw.Gateway_test"); + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null, + createSessionRuntime: _ => CreateSessionRuntime()); - Assert.Equal(0, exitCode); - Assert.True(resolvedNode); - Assert.Contains("without isolated-session", output.ToString(), StringComparison.Ordinal); + Assert.Equal(17, exitCode); + Assert.True(resolvedHostNode); + Assert.True(launchedHost); } [Fact] - public async Task NoIsolationSetupStillFailsOnAnUnsupportedMachine() + public async Task RequiredAgentLaunchFailsWhenReadinessReportsIsolationUnsupported() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); - await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); - bool resolvedNode = false; + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool resolvedHostNode = false; - int exitCode = await Program.RunControlAsync( - new HostOptions(applicationDirectory, "node.zip", []), - ["setup", "--no-isolation"], + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), _ => { }, - TextWriter.Null, - TextWriter.Null, _ => { - resolvedNode = true; + resolvedHostNode = true; return Task.FromResult(new NodeRuntime( "node.exe", - new Version(24, 20, 0), + new Version(24, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); }, + (_, _, _, _, _, _) => Task.FromResult(0), probeReadiness: _ => Task.FromResult(new MxcReadinessReport( "runtime", null, null, MxcHostSupport.Unsupported, null, - MxcSupportEvidence.HostBuild, - null, - "Windows build does not support sessions.")), - getPackageFamilyName: () => "OpenClaw.Gateway_test"); - - Assert.Equal(1, exitCode); - Assert.False(resolvedNode); - } - - [Fact] - public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval() - { - SessionRuntime runtime = CreateSessionRuntime(); - runtime.GatewayState.Write(new GatewayRecord - { - SchemaVersion = GatewayStateStore.CurrentSchemaVersion, - SandboxId = "iso:pending", - LaunchPending = true, - ProcessStartTimeUtc = DateTimeOffset.UtcNow - }); - - int exitCode = await Program.RunControlAsync( - new HostOptions(null, null, []), - ["teardown", "--force"], - _ => { }, - TextWriter.Null, - TextWriter.Null, - createSessionRuntime: _ => runtime, - createTeardownOrchestrator: CreateTeardownOrchestrator); + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: name => + name == SessionRoutingPolicy.ModeVariable ? "1" : null)); - Assert.Equal(0, exitCode); - GatewayStateResult state = runtime.GatewayState.Read(); - Assert.Equal(GatewayStateFault.Missing, state.Fault); + Assert.False(resolvedHostNode); } - // Preparing a runtime for an application that is not there wastes an - // extraction; the missing application is reported first. [Fact] - public async Task AgentReportsMissingApplicationBeforeResolvingHostNode() + public async Task SelectedSessionFailureDoesNotResolveOrLaunchHostNode() { - bool nodeResolutionAttempted = false; + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool resolvedHostNode = false; + bool launchedHost = false; - await Assert.ThrowsAsync( - () => Program.RunAgentAsync( - new HostOptions(null, null, []), + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), _ => { }, _ => { - nodeResolutionAttempted = true; - return Task.FromResult( - new NodeRuntime( - "node.exe", - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); - })); + resolvedHostNode = true; + return Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); + }, + (_, _, _, _, _, _) => + { + launchedHost = true; + return Task.FromResult(0); + }, + _ => throw new SessionException("setup failed"), + _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)), + () => "OpenClaw.Gateway_test", + _ => null)); - Assert.False(nodeResolutionAttempted); + Assert.False(resolvedHostNode); + Assert.False(launchedHost); } public void Dispose() @@ -536,8 +1047,8 @@ private SessionRuntime CreateSessionRuntime() { string stateRoot = Path.Combine(_testDirectory, "state"); string baseDirectory = Path.Combine(_testDirectory, "base"); - string workspace = Path.Combine(_testDirectory, "workspace"); Directory.CreateDirectory(baseDirectory); + string workspace = Path.Combine(_testDirectory, "workspace"); Directory.CreateDirectory(workspace); string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); @@ -551,33 +1062,13 @@ private SessionRuntime CreateSessionRuntime() }; backend.ExecuteBehavior = _ => { - string requestPath = Directory.GetFiles(workspace, "runtime-*.json").Single(); - SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( - File.ReadAllText(requestPath)); - File.WriteAllText( - SessionLaunchProtocol.ResultPathFor(requestPath), - SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult - { - RequestId = request.RequestId, - ExecutablePath = Path.Combine( - workspace, - "AppData", - "Local", - "OpenClaw", - "NodeJS", - "node-v24.15.0-win-x64", - "node.exe"), - Version = "24.15.0", - ArchiveName = "node-v24.15.0-win-x64.zip" - })); + WriteRuntimeInstallResult(backend, 0); return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); }; _lastSessionBackend = backend; - HostPaths paths = HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"); - _sessionStatePath = paths.SessionStatePath; return SessionRuntime.Create( - paths, - () => throw new InvalidOperationException("The test supplies its backend."), + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + () => throw new InvalidOperationException("The test backend must be supplied."), baseDirectory, _ => { }, backend); @@ -603,17 +1094,60 @@ await File.WriteAllTextAsync( "node.exe", new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), - createSessionRuntime: _ => runtime, - // Setup only reaches Ready once logon recovery is configured, and - // a test must never register a real scheduled task. - installRecovery: RecoveryConfigured, - probeReadiness: SupportedHost, - getPackageFamilyName: () => "OpenClaw.Gateway_test").ConfigureAwait(false); + new StubbedRecoveryLifecycle(runtime)).ConfigureAwait(false); Assert.Equal(0, exitCode); return runtime; } + // Setup only reaches Ready once logon recovery is configured, and a test + // must never register a real scheduled task. Everything else is left to + // production behaviour so the fixture still exercises the real path. + private sealed class StubbedRecoveryLifecycle(SessionRuntime runtime) + : IInstallationLifecycle + { + private static readonly InstallationLifecycle Inner = + InstallationLifecycle.Production; + + public SessionRuntime CreateRuntime(Action log) => runtime; + + public Task CheckSessionSupportAsync( + CancellationToken cancellationToken) => + Task.FromResult(new SessionRoutingDecision( + SessionRouting.Session, + "Test session support is available.")); + + public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => + Inner.PrepareHostRuntime(options, log); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, SessionRuntime sessionRuntime) => + Inner.ValidatePackageRuntime(options, sessionRuntime); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + Inner.AcquireLifecycleLock(sessionRuntime); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + Inner.TeardownAsync(options, sessionRuntime, log, lockAlreadyHeld, cancellationToken); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + Inner.CreateStateCleaner(sessionRuntime); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true)); + } + private static string SandboxIdFor(string applicationId) => $"iso:{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( $"{{\"appId\":\"{applicationId}\"}}")) @@ -650,6 +1184,24 @@ private Task RunAgentWithDirectLaunchProbeAsync( getPackageFamilyName: () => "OpenClaw.Gateway_test"); } + private static void WriteRuntimeInstallResult(FakeMxcSessionClient backend, int exitCode) + { + string requestPath = Directory.GetFiles(backend.Metadata!.EphemeralWorkspacePath, "runtime-*.json") + .Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + ExecutablePath = @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip", + Error = exitCode == 0 ? null : "installer failed" + })); + } + private HostOptions CreateAgentOptions() { string applicationDirectory = Path.Combine(_testDirectory, "agent-app"); @@ -659,4 +1211,154 @@ private HostOptions CreateAgentOptions() "console.log('fixture');"); return new HostOptions(applicationDirectory, null, ["--version"]); } + + private async Task CreateApplicationAsync() + { + string applicationDirectory = Path.Combine(_testDirectory, Guid.NewGuid().ToString("N"), "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture") + .ConfigureAwait(false); + return applicationDirectory; + } + + private HostOptions CreateSetupOptions(string applicationDirectory) + { + string archivePath = Path.Combine(_testDirectory, "node-v24.20.0-win-x64.zip"); + File.WriteAllText(archivePath, "fixture"); + return new HostOptions(applicationDirectory, archivePath, []); + } + + private sealed class FailingFreshLifecycle : IInstallationLifecycle + { + private readonly SessionRuntime _runtime; + + public FailingFreshLifecycle(SessionRuntime runtime) + { + _runtime = runtime; + } + + public List Calls { get; } = []; + + public RecordingCleaner Cleaner { get; } = new(); + + public Exception? CleanerException { get; init; } + + public Exception? TeardownException { get; init; } + + public TeardownResult? TeardownResult { get; init; } + + public bool TeardownSucceeds { get; init; } + + public TaskCompletionSource ProvisionStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource AllowProvision { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int TeardownCount { get; private set; } + + public SessionRuntime CreateRuntime(Action log) => _runtime; + + public Task CheckSessionSupportAsync( + CancellationToken cancellationToken) => + Task.FromResult(new SessionRoutingDecision( + SessionRouting.Session, + "The isolated-session runtime is available.")); + + public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => + new( + "node.exe", + new Version(24, 20, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime runtime) + { + Calls.Add("validate"); + return new PackageRuntimeMetadata("node.zip", new Version(24, 0), runtime.HelperPath); + } + + public Task TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) + { + Calls.Add("recovery"); + Calls.Add("gateway"); + Calls.Add("session"); + TeardownCount++; + Assert.True(lockAlreadyHeld); + if (TeardownException is not null) + { + throw TeardownException; + } + + if (TeardownResult is not null) + { + return Task.FromResult(TeardownResult); + } + + return Task.FromResult(TeardownSucceeds + ? new TeardownResult(Succeeded: true, Message: "Removed prior session.") + : new TeardownResult(Succeeded: false, Message: "Recovery removal failed.")); + } + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime) + { + Calls.Add("lock"); + if (Calls.Count(static call => call == "lock") > 1) + { + throw new SessionBusyException(TimeSpan.Zero); + } + + return new RecordingLockHandle(); + } + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) + { + Calls.Add("clean"); + Cleaner.Exception = CleanerException; + return Cleaner; + } + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready", + false)); + } + + private sealed class RecordingCleaner : IInstallationStateCleaner + { + public bool Cleared { get; private set; } + + public Exception? Exception { get; set; } + + public Action? Cleanup { get; set; } + + public void Clear() + { + if (Exception is not null) + { + throw Exception; + } + + Cleared = true; + Cleanup?.Invoke(); + } + + } + + private sealed class RecordingLockHandle : ISessionLockHandle + { + public void Dispose() + { + } + } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs new file mode 100644 index 00000000..9ca38b49 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs @@ -0,0 +1,161 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class InstallationStateCleanerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void ClearRemovesKnownCorruptAndUnknownInstallationFiles() + { + string stateRoot = Path.Combine(_root, "state"); + string dataRoot = Path.Combine(_root, "data"); + Directory.CreateDirectory(Path.Combine(stateRoot, "Logs")); + Directory.CreateDirectory(Path.Combine(dataRoot, "NodeJS")); + File.WriteAllText(Path.Combine(stateRoot, "session.json"), "{not-json"); + File.WriteAllText(Path.Combine(stateRoot, "unknown.bin"), "leftover"); + File.WriteAllText(Path.Combine(stateRoot, "Logs", "openclaw.log"), "log"); + File.WriteAllText(Path.Combine(dataRoot, "NodeJS", "node.exe"), "runtime"); + + var cleaner = new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + dataRoot); + + cleaner.Clear(); + + Assert.Empty(Directory.EnumerateFileSystemEntries(stateRoot)); + Assert.Empty(Directory.EnumerateFileSystemEntries(dataRoot)); + } + + [Fact] + public void ClearRejectsAReparsePointRoot() + { + string stateRoot = Path.Combine(_root, "state"); + string target = Path.Combine(_root, "external"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "must-survive.txt"), "outside"); + Directory.CreateSymbolicLink(stateRoot, target); + + Assert.Throws(() => new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + Path.Combine(_root, "data"))); + Assert.True(File.Exists(Path.Combine(target, "must-survive.txt"))); + } + + [Fact] + public void ClearRejectsAReplacedRootWithoutTouchingTheReplacement() + { + string stateRoot = Path.Combine(_root, "state"); + string dataRoot = Path.Combine(_root, "data"); + string movedStateRoot = Path.Combine(_root, "state-before-replacement"); + string externalRoot = Path.Combine(_root, "external"); + Directory.CreateDirectory(stateRoot); + Directory.CreateDirectory(dataRoot); + Directory.CreateDirectory(externalRoot); + File.WriteAllText(Path.Combine(stateRoot, "owned.txt"), "owned"); + File.WriteAllText(Path.Combine(dataRoot, "owned-runtime.txt"), "owned"); + File.WriteAllText(Path.Combine(externalRoot, "must-survive.txt"), "outside"); + bool replaced = false; + + var cleaner = new InstallationStateCleaner( + [stateRoot, dataRoot], + beforeTraversal: root => + { + if (!replaced && string.Equals(root, stateRoot, StringComparison.OrdinalIgnoreCase)) + { + replaced = true; + Directory.Move(stateRoot, movedStateRoot); + Directory.CreateSymbolicLink(stateRoot, externalRoot); + } + }); + + Assert.Throws(() => cleaner.Clear()); + + Assert.True(replaced); + Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); + Assert.True(File.Exists(Path.Combine(dataRoot, "owned-runtime.txt"))); + Assert.True(File.Exists(Path.Combine(movedStateRoot, "owned.txt"))); + } + + [Fact] + public void ClearRefusesADescendantReplacedAfterEnumeration() + { + string stateRoot = Path.Combine(_root, "state"); + string dataRoot = Path.Combine(_root, "data"); + string externalRoot = Path.Combine(_root, "external"); + string movedOwnedDirectory = Path.Combine(_root, "owned-before-replacement"); + string ownedDirectory = Path.Combine(stateRoot, "owned"); + string ownedFile = Path.Combine(stateRoot, "delete-me.txt"); + Directory.CreateDirectory(ownedDirectory); + Directory.CreateDirectory(externalRoot); + File.WriteAllText(Path.Combine(ownedDirectory, "owned.txt"), "owned"); + File.WriteAllText(ownedFile, "owned"); + File.WriteAllText(Path.Combine(externalRoot, "must-survive.txt"), "outside"); + bool replaced = false; + + var cleaner = new InstallationStateCleaner( + [stateRoot, dataRoot], + beforeDeleteEntry: entry => + { + if (!replaced && string.Equals(entry, ownedDirectory, StringComparison.OrdinalIgnoreCase)) + { + replaced = true; + Directory.Move(ownedDirectory, movedOwnedDirectory); + Directory.CreateSymbolicLink(ownedDirectory, externalRoot); + } + }); + + cleaner.Clear(); + + Assert.True(replaced); + Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); + Assert.True(File.Exists(Path.Combine(movedOwnedDirectory, "owned.txt"))); + Assert.False(File.Exists(ownedFile)); + Assert.False(Directory.Exists(ownedDirectory)); + } + + [Fact] + public void ClearRejectsAReparsePointAncestor() + { + string external = Path.Combine(_root, "external"); + string redirectedParent = Path.Combine(_root, "redirected"); + string stateRoot = Path.Combine(redirectedParent, "state"); + Directory.CreateDirectory(external); + Directory.CreateSymbolicLink(redirectedParent, external); + Directory.CreateDirectory(stateRoot); + File.WriteAllText(Path.Combine(stateRoot, "must-survive.txt"), "outside"); + + Assert.Throws(() => new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + Path.Combine(_root, "data"))); + Assert.True(File.Exists(Path.Combine(stateRoot, "must-survive.txt"))); + } + + [Fact] + public void ClearUnlinksAnOwnedReparsePointWithoutFollowingIt() + { + string stateRoot = Path.Combine(_root, "state"); + string externalRoot = Path.Combine(_root, "external"); + Directory.CreateDirectory(stateRoot); + Directory.CreateDirectory(externalRoot); + File.WriteAllText(Path.Combine(externalRoot, "must-survive.txt"), "outside"); + string link = Path.Combine(stateRoot, "link"); + Directory.CreateSymbolicLink(link, externalRoot); + + new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + Path.Combine(_root, "data")).Clear(); + + Assert.False(File.Exists(link)); + Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 38f43fcf..4c3bdd91 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -266,6 +266,9 @@ public async Task ApplicationExitCodeIsReturned() Assert.Equal(42, exitCode); } + // The host cannot know what the agent account's PATH contains, so it names + // the directory and lets the guest prepend it. Sending a host-built PATH + // would replace the guest's own. [Fact] public async Task IsolatedLaunchPrependsTheSelectedAgentNodeDirectory() {