From d206e4525f963b0eff4a2c36c5e1ed6741427f6f Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 15 Sep 2026 11:01:53 -0700 Subject: [PATCH 01/17] Add fresh setup reset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 11 +- .../Gateway/GatewayConfigurationStore.cs | 12 + .../Gateway/GatewayRuntime.cs | 8 +- src/OpenClaw.Launcher/HostDiagnosticLog.cs | 3 + src/OpenClaw.Launcher/HostStartup.cs | 5 +- src/OpenClaw.Launcher/Program.cs | 475 ++++++------ .../Session/InstallationLifecycle.cs | 111 +++ .../Session/InstallationStateCleaner.cs | 120 +++ .../Session/SessionRuntime.cs | 56 ++ .../Session/TeardownOrchestrator.cs | 8 + .../ClawCtlCommandLineTests.cs | 26 + .../HostDiagnosticLogTests.cs | 14 + .../ProgramStartupTests.cs | 6 + tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 700 ++++++++++++++---- .../Session/InstallationStateCleanerTests.cs | 53 ++ 15 files changed, 1236 insertions(+), 372 deletions(-) create mode 100644 src/OpenClaw.Launcher/Session/InstallationLifecycle.cs create mode 100644 src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 1a383a78..04063830 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 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,16 @@ 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); + setup.Options.Add(fresh); setup.SetAction((parsed, cancellationToken) => - handlers.Setup(new SetupOptions(parsed.GetValue(noIsolation)), cancellationToken)); + handlers.Setup( + new SetupOptions(parsed.GetValue(fresh), parsed.GetValue(noIsolation)), + cancellationToken)); Command status = new( StatusCommandName, "Show the isolated-session record and MXC-observed provision state without provisioning a replacement."); diff --git a/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs index 1dd34df1..c65962ee 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs @@ -140,6 +140,18 @@ public GatewayLaunchConfiguration Resolve( }; } + public GatewayLaunchConfiguration Resolve( + string workspacePath, + Func? readEnvironmentVariable = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + GatewayLaunchConfiguration configuration = Resolve(readEnvironmentVariable); + return configuration with + { + WorkingDirectory = configuration.WorkingDirectory ?? workspacePath, + }; + } + public GatewayConfigurationResult Read() { string text; diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index d6670747..a273db56 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -183,10 +183,7 @@ Task CreateRequestAsync(CancellationToken cancellationToken string applicationDirectory = options.PackagedApplicationDirectory ?? throw new SessionException( "The packaged OpenClaw application was not found, so the gateway cannot be started."); - SessionRecord sessionRecord = session.RequireSetup(); - GatewayLaunchConfiguration launch = ResolveLaunchConfiguration( - configuration, - sessionRecord, + GatewayLaunchConfiguration launch = configuration.Resolve( Environment.GetEnvironmentVariable); string archivePath = options.PackagedNodeArchivePath ?? throw new SessionException( @@ -236,6 +233,7 @@ internal static GatewayLaunchConfiguration ResolveLaunchConfiguration( string workspacePath = sessionRecord.WorkspacePath ?? throw new SessionException( "The isolated session has no shared workspace for the gateway."); - return configuration.Resolve(workspacePath, environmentVariable); + GatewayLaunchConfiguration resolved = configuration.Resolve(environmentVariable); + return resolved with { WorkingDirectory = resolved.WorkingDirectory ?? workspacePath }; } } 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..5560d9b8 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 - { - runtime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); - record = await runtime.StartForExecutionAsync(CancellationToken.None) - .ConfigureAwait(false); - } - catch (Session.SessionCapabilityUnavailableException) - when (mode == Session.SessionMode.Automatic) + log(routing.Reason); + if (routing.Routing == Session.SessionRouting.Session) { - 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,26 @@ 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) { + 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, + resolveNode, + cancellationToken), Status = async cancellationToken => { Session.SessionStatus status = await GetSessionRuntime() @@ -442,33 +326,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)) { @@ -500,10 +360,6 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .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); - } return result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted ? 0 : 1; }, @@ -515,10 +371,6 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .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); - } return result.Succeeded ? 0 : 1; } }); @@ -545,6 +397,185 @@ 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>? resolveNode, + CancellationToken cancellationToken) + { + string applicationDirectory = GetPackagedApplicationDirectory(options); + log("Confirmed the packaged OpenClaw application is present."); + ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); + + Session.SessionRoutingDecision routing = await lifecycle + .GetSessionRoutingDecisionAsync(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( + 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(); + 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 = await lifecycle.TeardownAsync( + options, runtime, log, lockAlreadyHeld: true, cancellationToken) + .ConfigureAwait(false); + if (!teardownResult.Succeeded) + { + 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; + } + + Session.IInstallationStateCleaner cleaner = lifecycle.CreateStateCleaner(runtime); + cleaner.Clear(); + log("Fresh setup cleared package-owned local state."); + 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; + } + } + + 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.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); + 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) + { + string directory = Path.Combine(Path.GetTempPath(), "OpenClawGatewayMSIX", "fresh-reset"); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, $"pre-reset-{Guid.NewGuid():N}.log"); + File.WriteAllLines(path, + [ + $"timestampUtc={DateTimeOffset.UtcNow:O}", + $"applicationId={runtime.ApplicationId}", + "report=pre-reset diagnostic metadata; credentials and local file contents are excluded" + ]); + 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..a3ea6bc3 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs @@ -0,0 +1,111 @@ +using OpenClaw.Launcher.Gateway; + +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 GetSessionRoutingDecisionAsync( + CancellationToken cancellationToken); + + 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 GetSessionRoutingDecisionAsync( + CancellationToken cancellationToken) + { + Mxc.MxcReadinessReport readiness = await Mxc.MxcReadiness + .ProbeAsync(cancellationToken).ConfigureAwait(false); + return SessionRoutingPolicy.Decide( + SessionMode.Automatic, + HostPaths.Create().PackageFamilyName, + readiness); + } + + 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..d7aa331c --- /dev/null +++ b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs @@ -0,0 +1,120 @@ +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 IInstallationFileSystem _fileSystem; + + public InstallationStateCleaner(HostPaths paths, string productLocalStateRoot) + : this([paths.StateRoot, productLocalStateRoot], paths.PackageFamilyName) + { + } + + internal InstallationStateCleaner( + IReadOnlyList trustedRoots, + string? packageFamilyName = "test", + IInstallationFileSystem? fileSystem = null) + { + ArgumentNullException.ThrowIfNull(trustedRoots); + _fileSystem = fileSystem ?? PhysicalInstallationFileSystem.Instance; + 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))]; + 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; + } + + foreach (string entry in _fileSystem.EnumerateFileSystemEntries(root)) + { + DeleteEntry(entry, _fileSystem); + } + } + } + + 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."); + } + + if (fileSystem.DirectoryExists(fullRoot) && + (fileSystem.GetAttributes(fullRoot) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException("The installation state root is a reparse point and cannot be cleared."); + } + + return fullRoot; + } + + private static void DeleteEntry(string path, IInstallationFileSystem fileSystem) + { + FileAttributes attributes = fileSystem.GetAttributes(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); + } + + fileSystem.DeleteDirectory(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..b7702450 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"); @@ -210,6 +215,57 @@ public void ValidateSavedOwnershipForHostFallback() RequireSetup(); } + /// + /// 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() + { + SessionStatus session = Coordinator.GetRecordedStatus(); + SetupStateResult setup = SetupState.Read(ApplicationId); + if (session.Availability == SessionAvailability.None && + setup.Fault == SetupStateFault.Missing) + { + return; + } + + if (session.Record is null) + { + throw new SessionException( + session.Detail ?? "The saved isolated-session record could not be used."); + } + + 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."); + } + } + /// /// Returns the Node.js executable extracted by the agent for the currently /// packaged runtime. 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.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index d5f1632d..be2db2d8 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -134,6 +134,32 @@ public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() 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), received); + } + [Fact] public async Task PowerShellHelpDescribesTheIsolatedAgentShell() { 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..da6557bd 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,16 +93,10 @@ 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)); + "could not complete", + output.ToString(), + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -129,6 +115,10 @@ await File.WriteAllTextAsync( new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownSucceeds = true, + }; int setupExitCode = await Program.RunControlAsync( options, @@ -137,12 +127,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 +173,116 @@ 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 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 +299,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 +344,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 +395,171 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() _ => { }, TextWriter.Null, error, - createSessionRuntime: _ => runtime); + installationLifecycle: new FailingFreshLifecycle(runtime)); Assert.Equal(1, exitCode); Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); Assert.DoesNotContain( _lastSessionBackend!.Calls, call => call.StartsWith("deprovision:", StringComparison.Ordinal)); + } [Fact] - public async Task SetupReportsMissingApplicationBeforeResolvingNode() + 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-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 FreshSetupProvisionFailureDoesNotClaimReady() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + ((FakeMxcSessionClient)runtime.Backend).AttachedBehavior = _ => + { + 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(1); + }; + 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.AttachedBehavior = async cancellationToken => + { + lifecycle.ProvisionStarted.TrySetResult(); + await lifecycle.AllowProvision.Task + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + WriteRuntimeInstallResult(backend, 0); + return 0; + }; + 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 +585,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 +790,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)!); @@ -549,35 +803,15 @@ private SessionRuntime CreateSessionRuntime() "S-1-5-21-0-0-0-1001", workspace) }; - backend.ExecuteBehavior = _ => + backend.AttachedBehavior = _ => { - 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" - })); - return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + WriteRuntimeInstallResult(backend, 0); + return Task.FromResult(0); }; _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 +837,57 @@ 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 IInstallationLifecycle Inner = + InstallationLifecycle.Production; + + public SessionRuntime CreateRuntime(Action log) => runtime; + + public Task GetSessionRoutingDecisionAsync( + CancellationToken cancellationToken) => + Task.FromResult(new SessionRoutingDecision( + SessionRouting.Session, + "Test session support is available.")); + + 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}\"}}")) @@ -659,4 +933,148 @@ private HostOptions CreateAgentOptions() "console.log('fixture');"); return new HostOptions(applicationDirectory, null, ["--version"]); } + + 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 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 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 GetSessionRoutingDecisionAsync( + CancellationToken cancellationToken) => + Task.FromResult(new SessionRoutingDecision( + SessionRouting.Session, + "Test session support is available.")); + + 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); + 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 void Clear() + { + if (Exception is not null) + { + throw Exception; + } + + Cleared = true; + } + } + + 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..102c7e2e --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs @@ -0,0 +1,53 @@ +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"))); + } +} From 1b14381fff929bbce43cceacb3265799d92de3be Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 15 Sep 2026 11:02:57 -0700 Subject: [PATCH 02/17] Add forced fresh setup recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 32 ++- src/OpenClaw.Launcher/Program.cs | 84 +++++- .../SmokeProgram.cs | 12 + .../ClawCtlCommandLineTests.cs | 31 ++- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 244 +++++++++++++++++- 5 files changed, 380 insertions(+), 23 deletions(-) diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 04063830..866a8bcc 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -16,7 +16,14 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStop { get; init; } } -internal sealed record SetupOptions(bool Fresh, bool NoIsolation = false); +<<<<<<< HEAD +internal sealed record SetupOptions( + bool Fresh, + bool Force = false, + bool NoIsolation = false); +======= +internal sealed record SetupOptions(bool Fresh, bool Force, bool NoIsolation); +>>>>>>> b881f6d (Add forced fresh setup recovery) // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the @@ -67,10 +74,25 @@ public static RootCommand Create(ClawCtlHandlers handlers) 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(fresh), parsed.GetValue(noIsolation)), + new SetupOptions( + parsed.GetValue(fresh), + parsed.GetValue(force), + parsed.GetValue(noIsolation)), cancellationToken)); Command status = new( StatusCommandName, @@ -86,11 +108,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/Program.cs b/src/OpenClaw.Launcher/Program.cs index 5560d9b8..ba641146 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -448,25 +448,60 @@ await output.WriteLineAsync( string reportPath = WriteFreshDiagnosticReport(runtime, log); await output.WriteLineAsync($"Pre-reset diagnostic report: {reportPath}") .ConfigureAwait(false); - Session.TeardownResult teardownResult = await lifecycle.TeardownAsync( - options, runtime, log, lockAlreadyHeld: true, cancellationToken) - .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: Fresh setup stopped because teardown is incomplete: {teardownResult.Message}") + $"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); } - - return 1; } 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); @@ -497,6 +532,14 @@ await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Mess .ConfigureAwait(false); return 1; } + catch (OperationCanceledException) + { + log("Fresh setup was cancelled before it completed."); + await output.WriteLineAsync( + "OpenClaw setup was cancelled; cleanup or setup may be incomplete. Rerun `clawctl setup --fresh` to retry.") + .ConfigureAwait(false); + return 1; + } } internal static async Task RunSetupCoreAsync( @@ -563,15 +606,40 @@ 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"); - File.WriteAllLines(path, + 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; } 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 be2db2d8..5110948f 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -132,6 +132,8 @@ public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() Normalize(ClawCtlCommandLine.SetupDescription), help, StringComparison.Ordinal); + Assert.Contains("--fresh", help, StringComparison.Ordinal); + Assert.Contains("Requires --fresh", help, StringComparison.Ordinal); } [Fact] @@ -157,7 +159,33 @@ public async Task SetupFreshPassesTheExplicitDestructiveAuthorization() int exitCode = await root.Parse("setup --fresh").InvokeAsync(); Assert.Equal(0, exitCode); - Assert.Equal(new SetupOptions(Fresh: true), received); + 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] @@ -217,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/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index da6557bd..3f43bab5 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -452,6 +452,9 @@ public async Task FreshSetupResetsInOrderBeforeProvisioningAndRecordsConsistentN 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( ((FakeMxcSessionClient)runtime.Backend).Calls, call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); Assert.Contains("OpenClaw isolated session is ready.", output.ToString(), StringComparison.Ordinal); @@ -480,12 +483,220 @@ public async Task FreshSetupCleanerFailurePreventsProvisionAndReady() 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 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).AttachedBehavior = _ => + ((FakeMxcSessionClient)runtime.Backend).ExecuteBehavior = _ => { string requestPath = Directory.GetFiles( ((FakeMxcSessionClient)runtime.Backend).Metadata!.EphemeralWorkspacePath, @@ -499,7 +710,7 @@ public async Task FreshSetupProvisionFailureDoesNotClaimReady() RequestId = request.RequestId, Error = "installer failed" })); - return Task.FromResult(1); + return Task.FromResult(new MxcExecutionResult(1, string.Empty, string.Empty)); }; var lifecycle = new FailingFreshLifecycle(runtime) { @@ -528,14 +739,12 @@ public async Task ConcurrentFreshSetupReportsBusyWithoutStartingAnotherReset() { TeardownSucceeds = true, }; - backend.AttachedBehavior = async cancellationToken => + backend.ExecuteBehavior = async _ => { lifecycle.ProvisionStarted.TrySetResult(); - await lifecycle.AllowProvision.Task - .WaitAsync(cancellationToken) - .ConfigureAwait(false); + await lifecycle.AllowProvision.Task.ConfigureAwait(false); WriteRuntimeInstallResult(backend, 0); - return 0; + return new MxcExecutionResult(0, string.Empty, string.Empty); }; var firstOutput = new StringWriter(); Task first = Program.RunControlAsync( @@ -803,10 +1012,10 @@ private SessionRuntime CreateSessionRuntime() "S-1-5-21-0-0-0-1001", workspace) }; - backend.AttachedBehavior = _ => + backend.ExecuteBehavior = _ => { WriteRuntimeInstallResult(backend, 0); - return Task.FromResult(0); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); }; _lastSessionBackend = backend; return SessionRuntime.Create( @@ -983,6 +1192,10 @@ public FailingFreshLifecycle(SessionRuntime runtime) public Exception? CleanerException { get; init; } + public Exception? TeardownException { get; init; } + + public TeardownResult? TeardownResult { get; init; } + public bool TeardownSucceeds { get; init; } public TaskCompletionSource ProvisionStarted { get; } = @@ -1021,6 +1234,16 @@ public Task TeardownAsync( 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.")); @@ -1060,6 +1283,8 @@ private sealed class RecordingCleaner : IInstallationStateCleaner public Exception? Exception { get; set; } + public Action? Cleanup { get; set; } + public void Clear() { if (Exception is not null) @@ -1068,6 +1293,7 @@ public void Clear() } Cleared = true; + Cleanup?.Invoke(); } } From 4c49a0b8e4440d7b8bff72393d047cf5772f644e Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 15 Sep 2026 19:25:11 -0700 Subject: [PATCH 03/17] Resolve lifecycle stack integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 4 ---- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 21 +++---------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 866a8bcc..08e972b0 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -16,14 +16,10 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStop { get; init; } } -<<<<<<< HEAD internal sealed record SetupOptions( bool Fresh, bool Force = false, bool NoIsolation = false); -======= -internal sealed record SetupOptions(bool Fresh, bool Force, bool NoIsolation); ->>>>>>> b881f6d (Add forced fresh setup recovery) // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 3f43bab5..b361354e 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -102,23 +102,10 @@ public async Task SetupReportsAnUnavailableIsolatedSessionWithoutResolvingHostNo [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, - }; + var lifecycle = new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }; int setupExitCode = await Program.RunControlAsync( options, @@ -126,7 +113,6 @@ await File.WriteAllTextAsync( _ => { }, TextWriter.Null, TextWriter.Null, - _ => Task.FromResult(hostNode), installationLifecycle: lifecycle); Assert.Equal(0, setupExitCode); @@ -402,7 +388,6 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() Assert.DoesNotContain( _lastSessionBackend!.Calls, call => call.StartsWith("deprovision:", StringComparison.Ordinal)); - } [Fact] From 6c7925deebaaeb98868f58d13ad3a8bcdbb89a4f Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 10:35:24 -0700 Subject: [PATCH 04/17] Gate setup on session support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 5 +-- src/OpenClaw.Launcher/Program.cs | 45 +++++-------------- .../Session/InstallationLifecycle.cs | 21 ++++++--- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 15 ++++--- 4 files changed, 35 insertions(+), 51 deletions(-) diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 08e972b0..0737bcb6 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -16,10 +16,7 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStop { get; init; } } -internal sealed record SetupOptions( - bool Fresh, - bool Force = false, - bool NoIsolation = false); +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 diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index ba641146..1c10aa6a 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -114,10 +114,8 @@ PlatformNotSupportedException or : await RunAgentAsync( options, WriteDiagnostic, - startup.ResolveNode ?? (_ => Task.FromResult( - (startup.InstallNodeRuntime ?? NodeRuntimeInstaller.EnsureInstalled)( - GetPackagedNodeArchivePath(options), - WriteDiagnostic))), + startup.ResolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( + GetPackagedNodeArchivePath(options)))), startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, startup.InstallationLifecycle is null ? null @@ -255,6 +253,7 @@ internal static async Task RunControlAsync( Func>? resolveNode = null, Session.IInstallationLifecycle? installationLifecycle = null) { + _ = resolveNode; Session.IInstallationLifecycle lifecycle = installationLifecycle ?? Session.InstallationLifecycle.Production; Session.SessionRuntime? sessionRuntime = null; @@ -271,7 +270,6 @@ Session.SessionRuntime GetSessionRuntime() => lifecycle, log, output, - resolveNode, cancellationToken), Status = async cancellationToken => { @@ -404,15 +402,14 @@ private static async Task RunSetupAsync( Session.IInstallationLifecycle lifecycle, Action log, TextWriter output, - Func>? resolveNode, CancellationToken cancellationToken) { string applicationDirectory = GetPackagedApplicationDirectory(options); log("Confirmed the packaged OpenClaw application is present."); ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); - Session.SessionRoutingDecision routing = await lifecycle - .GetSessionRoutingDecisionAsync(cancellationToken).ConfigureAwait(false); + Session.SessionRoutingDecision routing = await lifecycle.CheckSessionSupportAsync( + cancellationToken).ConfigureAwait(false); if (routing.Routing != Session.SessionRouting.Session) { await output.WriteLineAsync( @@ -425,10 +422,7 @@ await output.WriteLineAsync( Session.SessionRoutingPolicy.ReadMode( Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) { - NodeRuntime nodeRuntime = await (resolveNode ?? - (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( - GetPackagedNodeArchivePath(options), - log))))(cancellationToken).ConfigureAwait(false); + NodeRuntime nodeRuntime = lifecycle.PrepareHostRuntime(options, log); ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); await output.WriteLineAsync( "OpenClaw setup completed without isolated-session provisioning.") @@ -561,21 +555,10 @@ internal static async Task RunSetupCoreAsync( }); Session.SessionStartResult session = await runtime.Coordinator .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); - 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 (session.SupersededRecord is not null && + runtime.GatewayState.ClearForSupersededSession(session.SupersededRecord.SandboxId)) { - if (runtime.GatewayState.ClearForSupersededSession(supersededSandboxId)) - { - log("Removed the gateway record for the superseded session."); - } + log("Removed the gateway record for the superseded session."); } Session.SessionRecord record = session.Record; @@ -660,15 +643,7 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) string nodeDirectory = Path.GetDirectoryName(agentNodePath) ?? throw new Session.SessionException( "The agent's Node.js runtime has no parent directory."); - SessionToolInstallResult installedTools = await runtime.Executor.InstallToolsAsync( - record, - helperPath, - cancellationToken).ConfigureAwait(false); - Session.AgentTools tools = new( - Path.GetDirectoryName(installedTools.ShimPath) - ?? throw new Session.SessionException( - "The installed agent command shim has no parent directory."), - installedTools.ShimPath!); + Session.AgentTools tools = Session.AgentToolShim.Install(record.WorkspacePath!); Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); await output.WriteLineAsync( diff --git a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs index a3ea6bc3..6dfd5c79 100644 --- a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs +++ b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs @@ -1,4 +1,5 @@ using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; namespace OpenClaw.Launcher.Session; @@ -10,9 +11,11 @@ internal interface IInstallationLifecycle { SessionRuntime CreateRuntime(Action log); - Task GetSessionRoutingDecisionAsync( + Task CheckSessionSupportAsync( CancellationToken cancellationToken); + NodeRuntime PrepareHostRuntime(HostOptions options, Action log); + PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime); ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime); @@ -37,17 +40,23 @@ internal sealed class InstallationLifecycle : IInstallationLifecycle public SessionRuntime CreateRuntime(Action log) => SessionRuntime.Create(log); - public async Task GetSessionRoutingDecisionAsync( + public async Task CheckSessionSupportAsync( CancellationToken cancellationToken) { - Mxc.MxcReadinessReport readiness = await Mxc.MxcReadiness - .ProbeAsync(cancellationToken).ConfigureAwait(false); + 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 @@ -86,8 +95,8 @@ private static Task RunTeardownAsync( TeardownOrchestrator teardown = GatewayRuntime.CreateTeardownOrchestrator(options, runtime, log); return lockAlreadyHeld - ? teardown.RunUnderLockAsync(runtime.HelperPath, force: true, cancellationToken) - : teardown.RunAsync(runtime.HelperPath, force: true, cancellationToken); + ? teardown.RunUnderLockAsync(runtime.HelperPath, cancellationToken) + : teardown.RunAsync(runtime.HelperPath, cancellationToken); } public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) => diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index b361354e..5153d13d 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -94,7 +94,7 @@ public async Task SetupReportsAnUnavailableIsolatedSessionWithoutResolvingHostNo output.ToString(), StringComparison.Ordinal); Assert.Contains( - "could not complete", + "requires isolated-session support", output.ToString(), StringComparison.OrdinalIgnoreCase); } @@ -385,9 +385,6 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() Assert.Equal(1, exitCode); Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); - Assert.DoesNotContain( - _lastSessionBackend!.Calls, - call => call.StartsWith("deprovision:", StringComparison.Ordinal)); } [Fact] @@ -1193,11 +1190,17 @@ public FailingFreshLifecycle(SessionRuntime runtime) public SessionRuntime CreateRuntime(Action log) => _runtime; - public Task GetSessionRoutingDecisionAsync( + public Task CheckSessionSupportAsync( CancellationToken cancellationToken) => Task.FromResult(new SessionRoutingDecision( SessionRouting.Session, - "Test session support is available.")); + "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, From 146d293edea25fe362b2479a7630f3ab5774a20a Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 10:36:53 -0700 Subject: [PATCH 05/17] Keep ordinary setup cancellation guidance non-destructive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/Program.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 1c10aa6a..0aebad48 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -528,9 +528,12 @@ await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Mess } catch (OperationCanceledException) { - log("Fresh setup was cancelled before it completed."); + 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; cleanup or setup may be incomplete. Rerun `clawctl setup --fresh` to retry.") + $"OpenClaw setup was cancelled; setup may be incomplete. Rerun `{retryCommand}` to retry.") .ConfigureAwait(false); return 1; } From 465eeb11aaf32a831b331ec6a3af0deb497900ec Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 10:49:32 -0700 Subject: [PATCH 06/17] Document session-free setup requirements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 61c976bdc8bbc9580708a7a74357f6c6e2419af7 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 11:03:25 -0700 Subject: [PATCH 07/17] Reject redirected installation state ancestors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Session/InstallationStateCleaner.cs | 12 +++++++++--- .../Session/InstallationStateCleanerTests.cs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs index d7aa331c..ba65ab43 100644 --- a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs +++ b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs @@ -57,10 +57,16 @@ private static string ValidateRoot(string root, IInstallationFileSystem fileSyst throw new SessionException("The installation state root is unsafe to clear."); } - if (fileSystem.DirectoryExists(fullRoot) && - (fileSystem.GetAttributes(fullRoot) & FileAttributes.ReparsePoint) != 0) + for (DirectoryInfo? current = new DirectoryInfo(fullRoot); + current is not null; + current = current.Parent) { - throw new SessionException("The installation state root is a reparse point and cannot be cleared."); + 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; diff --git a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs index 102c7e2e..3aac65a7 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs @@ -50,4 +50,21 @@ public void ClearRejectsAReparsePointRoot() Path.Combine(_root, "data"))); Assert.True(File.Exists(Path.Combine(target, "must-survive.txt"))); } + + [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"))); + } } From 923fc4583bce5a2f315e1339fa4bc2928749db91 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 11:11:40 -0700 Subject: [PATCH 08/17] Install agent command tools inside the session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/Program.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 0aebad48..55b8b8a9 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -646,7 +646,15 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) string nodeDirectory = Path.GetDirectoryName(agentNodePath) ?? throw new Session.SessionException( "The agent's Node.js runtime has no parent directory."); - Session.AgentTools tools = Session.AgentToolShim.Install(record.WorkspacePath!); + SessionToolInstallResult installedTools = await runtime.Executor.InstallToolsAsync( + record, + helperPath, + cancellationToken).ConfigureAwait(false); + Session.AgentTools tools = new( + Path.GetDirectoryName(installedTools.ShimPath) + ?? throw new Session.SessionException( + "The installed agent command shim has no parent directory."), + installedTools.ShimPath!); Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); await output.WriteLineAsync( From 342e6a2e157928fa8ca165d96c813684680a8b12 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 11:17:59 -0700 Subject: [PATCH 09/17] Persist superseded session reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/Program.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 55b8b8a9..69fbd80d 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -558,10 +558,22 @@ internal static async Task RunSetupCoreAsync( }); Session.SessionStartResult session = await runtime.Coordinator .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); - if (session.SupersededRecord is not null && - runtime.GatewayState.ClearForSupersededSession(session.SupersededRecord.SandboxId)) + 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) { - log("Removed the gateway record for the superseded session."); + if (runtime.GatewayState.ClearForSupersededSession(supersededSandboxId)) + { + log($"Removed the gateway record for superseded session '{supersededSandboxId}'."); + } } Session.SessionRecord record = session.Record; From 92e8ab08f636122b80afd52e9ee90e357fb5ae2d Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 11:30:03 -0700 Subject: [PATCH 10/17] Detect missing scheduled tasks without localized text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs | 3 +++ 1 file changed, 3 insertions(+) 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() { From b73e2f90553a62db13cd966332bad130f3ddbcff Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 14:05:20 -0700 Subject: [PATCH 11/17] Fix session reset and gateway failure output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Gateway/GatewayControlOutput.cs | 126 ++++++++++++++++++ .../Gateway/GatewayRuntime.cs | 2 + src/OpenClaw.Launcher/Program.cs | 26 +++- .../Session/InstallationStateCleaner.cs | 12 +- .../Gateway/GatewayControlOutputTests.cs | 88 ++++++++++++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 29 ++++ .../Session/InstallationStateCleanerTests.cs | 35 +++++ 7 files changed, 310 insertions(+), 8 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs diff --git a/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs new file mode 100644 index 00000000..f5182eaa --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs @@ -0,0 +1,126 @@ +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, + HostPaths paths, + 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, paths, 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, + HostPaths paths, + CancellationToken cancellationToken) + { + try + { + using FileStream stream = TrustedPath.OpenRead(paths.StateRoot, paths.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 ({paths.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: {paths.LogPath}").ConfigureAwait(false); + } + } + + private static string Sanitize(string value) + { + StringBuilder builder = new(value.Length); + bool inEscapeSequence = false; + foreach (char character in value) + { + if (inEscapeSequence) + { + if (character is >= '@' and <= '~') + { + inEscapeSequence = false; + } + + continue; + } + + if (character == '\x1b') + { + inEscapeSequence = 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 a273db56..51680af1 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -32,6 +32,8 @@ private GatewayRuntime( public string HelperPath { get; } + internal HostPaths Paths => _paths; + private SessionRuntime Session => _session; private static bool FileExists(string path) => File.Exists(path); diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 69fbd80d..d15ab1f3 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -251,7 +251,8 @@ internal static async Task RunControlAsync( TextWriter output, TextWriter error, Func>? resolveNode = null, - Session.IInstallationLifecycle? installationLifecycle = null) + Session.IInstallationLifecycle? installationLifecycle = null, + Func? readEnvironmentVariable = null) { _ = resolveNode; Session.IInstallationLifecycle lifecycle = @@ -270,6 +271,7 @@ Session.SessionRuntime GetSessionRuntime() => lifecycle, log, output, + readEnvironmentVariable ?? Environment.GetEnvironmentVariable, cancellationToken), Status = async cancellationToken => { @@ -352,12 +354,12 @@ await error.WriteLineAsync( }, 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); + await Gateway.GatewayControlOutput.WriteStatusAsync( + output, result, runtime.Paths, cancellationToken).ConfigureAwait(false); return result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted ? 0 : 1; }, @@ -368,7 +370,8 @@ await error.WriteLineAsync( .Controller .StopAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(result.Message).ConfigureAwait(false); + await Gateway.GatewayControlOutput.WriteStopAsync(output, result) + .ConfigureAwait(false); return result.Succeeded ? 0 : 1; } }); @@ -402,6 +405,7 @@ private static async Task RunSetupAsync( Session.IInstallationLifecycle lifecycle, Action log, TextWriter output, + Func readEnvironmentVariable, CancellationToken cancellationToken) { string applicationDirectory = GetPackagedApplicationDirectory(options); @@ -420,8 +424,16 @@ await output.WriteLineAsync( if (setupOptions.NoIsolation || Session.SessionRoutingPolicy.ReadMode( - Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) + 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( diff --git a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs index ba65ab43..fbff2be9 100644 --- a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs +++ b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs @@ -5,6 +5,7 @@ internal sealed class InstallationStateCleaner : IInstallationStateCleaner { private readonly string[] _roots; private readonly IInstallationFileSystem _fileSystem; + private readonly Action? _beforeTraversal; public InstallationStateCleaner(HostPaths paths, string productLocalStateRoot) : this([paths.StateRoot, productLocalStateRoot], paths.PackageFamilyName) @@ -14,10 +15,12 @@ public InstallationStateCleaner(HostPaths paths, string productLocalStateRoot) internal InstallationStateCleaner( IReadOnlyList trustedRoots, string? packageFamilyName = "test", - IInstallationFileSystem? fileSystem = null) + IInstallationFileSystem? fileSystem = null, + Action? beforeTraversal = null) { ArgumentNullException.ThrowIfNull(trustedRoots); _fileSystem = fileSystem ?? PhysicalInstallationFileSystem.Instance; + _beforeTraversal = beforeTraversal; if (packageFamilyName is null) { throw new SessionException( @@ -41,6 +44,13 @@ public void Clear() continue; } + _beforeTraversal?.Invoke(root); + if (!_fileSystem.DirectoryExists(root) || + (_fileSystem.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + { + continue; + } + foreach (string entry in _fileSystem.EnumerateFileSystemEntries(root)) { DeleteEntry(entry, _fileSystem); diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs new file mode 100644 index 00000000..7456fe73 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs @@ -0,0 +1,88 @@ +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() + { + HostPaths paths = HostPaths.ForRoot(_root, "OpenClaw.Gateway_test"); + Directory.CreateDirectory(Path.GetDirectoryName(paths.LogPath)!); + string[] lines = + [ + .. Enumerable.Range(1, 12).Select(index => + index == 12 ? "\u001b[31mfinal\u0001 line\u001b[0m" : $"line {index}") + ]; + await File.WriteAllLinesAsync(paths.LogPath, lines).ConfigureAwait(true); + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStatusAsync( + output, + new GatewayStatusReport( + GatewayState.Stopped, + null, + "The gateway is not running.", + "the application exited with code 78"), + paths, + 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 ({paths.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('\u001b', rendered); + Assert.DoesNotContain('\u0001', rendered); + } + + [Fact] + public async Task StatusKeepsFailureOutputWhenTheGuestLogIsUnavailable() + { + HostPaths paths = HostPaths.ForRoot(_root, "OpenClaw.Gateway_test"); + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStatusAsync( + output, + new GatewayStatusReport( + GatewayState.Unhealthy, + null, + "The gateway is not serving.", + "Inspect diagnostics before retrying."), + paths, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal( + $"The gateway is not serving.{Environment.NewLine}Inspect diagnostics before retrying.{Environment.NewLine}" + + $"Gateway log unavailable: {paths.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/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 5153d13d..4c9c0949 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -387,6 +387,35 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() 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() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs index 3aac65a7..fb8dc9db 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs @@ -51,6 +51,41 @@ public void ClearRejectsAReparsePointRoot() Assert.True(File.Exists(Path.Combine(target, "must-survive.txt"))); } + [Fact] + public void ClearSkipsAReplacedRootAndContinuesClearingOtherOwnedState() + { + 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); + } + }); + + cleaner.Clear(); + + Assert.True(replaced); + Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); + Assert.Empty(Directory.EnumerateFileSystemEntries(dataRoot)); + Assert.True(File.Exists(Path.Combine(movedStateRoot, "owned.txt"))); + } + [Fact] public void ClearRejectsAReparsePointAncestor() { From 122dbe837f44adf6e9f99c0dd2daf902bf668a00 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 14:42:29 -0700 Subject: [PATCH 12/17] Harden installation state cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Session/InstallationStateCleaner.cs | 76 ++++++++++++++++--- .../Session/InstallationStateCleanerTests.cs | 62 ++++++++++++++- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs index fbff2be9..220c466e 100644 --- a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs +++ b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs @@ -4,8 +4,10 @@ namespace OpenClaw.Launcher.Session; 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) @@ -16,11 +18,13 @@ internal InstallationStateCleaner( IReadOnlyList trustedRoots, string? packageFamilyName = "test", IInstallationFileSystem? fileSystem = null, - Action? beforeTraversal = 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( @@ -28,6 +32,12 @@ internal InstallationStateCleaner( } _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)) { @@ -45,6 +55,38 @@ public void Clear() } _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) { @@ -53,7 +95,7 @@ public void Clear() foreach (string entry in _fileSystem.EnumerateFileSystemEntries(root)) { - DeleteEntry(entry, _fileSystem); + DeleteEntry(entry); } } } @@ -82,18 +124,29 @@ private static string ValidateRoot(string root, IInstallationFileSystem fileSyst return fullRoot; } - private static void DeleteEntry(string path, IInstallationFileSystem fileSystem) + private void DeleteEntry(string path) { - FileAttributes attributes = fileSystem.GetAttributes(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); + _fileSystem.DeleteDirectory(path); } else { - fileSystem.DeleteFile(path); + _fileSystem.DeleteFile(path); } return; @@ -101,17 +154,20 @@ private static void DeleteEntry(string path, IInstallationFileSystem fileSystem) if ((attributes & FileAttributes.Directory) == 0) { - fileSystem.DeleteFile(path); + _fileSystem.DeleteFile(path); return; } - foreach (string child in fileSystem.EnumerateFileSystemEntries(path)) + foreach (string child in _fileSystem.EnumerateFileSystemEntries(path)) { - DeleteEntry(child, fileSystem); + DeleteEntry(child); } - fileSystem.DeleteDirectory(path); + _fileSystem.DeleteDirectory(path); } + + private static bool EntryExists(string path) => + File.Exists(path) || Directory.Exists(path); } internal interface IInstallationFileSystem diff --git a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs index fb8dc9db..9ca38b49 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs @@ -52,7 +52,7 @@ public void ClearRejectsAReparsePointRoot() } [Fact] - public void ClearSkipsAReplacedRootAndContinuesClearingOtherOwnedState() + public void ClearRejectsAReplacedRootWithoutTouchingTheReplacement() { string stateRoot = Path.Combine(_root, "state"); string dataRoot = Path.Combine(_root, "data"); @@ -78,14 +78,51 @@ public void ClearSkipsAReplacedRootAndContinuesClearingOtherOwnedState() } }); - cleaner.Clear(); + Assert.Throws(() => cleaner.Clear()); Assert.True(replaced); Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); - Assert.Empty(Directory.EnumerateFileSystemEntries(dataRoot)); + 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() { @@ -102,4 +139,23 @@ public void ClearRejectsAReparsePointAncestor() 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"))); + } } From e9c9e1f3f4e32502a6f5355eaa07a6b24c78ac85 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 15:21:46 -0700 Subject: [PATCH 13/17] Keep session fixtures isolated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 4c9c0949..0667b858 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1069,17 +1069,20 @@ await File.WriteAllTextAsync( private sealed class StubbedRecoveryLifecycle(SessionRuntime runtime) : IInstallationLifecycle { - private static readonly IInstallationLifecycle Inner = + private static readonly InstallationLifecycle Inner = InstallationLifecycle.Production; public SessionRuntime CreateRuntime(Action log) => runtime; - public Task GetSessionRoutingDecisionAsync( + 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); From f3a3621bc02b4f18f2a96326e9340a61bc3823f6 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 15:33:42 -0700 Subject: [PATCH 14/17] Resolve session lifecycle integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Gateway/GatewayConfigurationStore.cs | 12 ------------ src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs | 12 ++++++------ .../Session/InstallationLifecycle.cs | 4 ++-- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs index c65962ee..1dd34df1 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs @@ -140,18 +140,6 @@ public GatewayLaunchConfiguration Resolve( }; } - public GatewayLaunchConfiguration Resolve( - string workspacePath, - Func? readEnvironmentVariable = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); - GatewayLaunchConfiguration configuration = Resolve(readEnvironmentVariable); - return configuration with - { - WorkingDirectory = configuration.WorkingDirectory ?? workspacePath, - }; - } - public GatewayConfigurationResult Read() { string text; diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index 51680af1..409250d0 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -185,7 +185,10 @@ Task CreateRequestAsync(CancellationToken cancellationToken string applicationDirectory = options.PackagedApplicationDirectory ?? throw new SessionException( "The packaged OpenClaw application was not found, so the gateway cannot be started."); - GatewayLaunchConfiguration launch = configuration.Resolve( + SessionRecord sessionRecord = session.RequireSetup(); + GatewayLaunchConfiguration launch = ResolveLaunchConfiguration( + configuration, + sessionRecord, Environment.GetEnvironmentVariable); string archivePath = options.PackagedNodeArchivePath ?? throw new SessionException( @@ -196,9 +199,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 }); } @@ -235,7 +236,6 @@ internal static GatewayLaunchConfiguration ResolveLaunchConfiguration( string workspacePath = sessionRecord.WorkspacePath ?? throw new SessionException( "The isolated session has no shared workspace for the gateway."); - GatewayLaunchConfiguration resolved = configuration.Resolve(environmentVariable); - return resolved with { WorkingDirectory = resolved.WorkingDirectory ?? workspacePath }; + return configuration.Resolve(workspacePath, environmentVariable); } } diff --git a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs index 6dfd5c79..eb0aac2d 100644 --- a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs +++ b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs @@ -95,8 +95,8 @@ private static Task RunTeardownAsync( TeardownOrchestrator teardown = GatewayRuntime.CreateTeardownOrchestrator(options, runtime, log); return lockAlreadyHeld - ? teardown.RunUnderLockAsync(runtime.HelperPath, cancellationToken) - : teardown.RunAsync(runtime.HelperPath, cancellationToken); + ? teardown.RunUnderLockAsync(runtime.HelperPath, force: true, cancellationToken) + : teardown.RunAsync(runtime.HelperPath, force: true, cancellationToken); } public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) => From 54d58eed09ea6a8b84adfad3544ccc0ec977b0c1 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 15:41:47 -0700 Subject: [PATCH 15/17] Preserve host runtime startup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/Program.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index d15ab1f3..d56afdda 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -114,8 +114,10 @@ PlatformNotSupportedException or : await RunAgentAsync( options, WriteDiagnostic, - startup.ResolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( - GetPackagedNodeArchivePath(options)))), + startup.ResolveNode ?? (_ => Task.FromResult( + (startup.InstallNodeRuntime ?? NodeRuntimeInstaller.EnsureInstalled)( + GetPackagedNodeArchivePath(options), + WriteDiagnostic))), startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, startup.InstallationLifecycle is null ? null From 2ce972b7e5498c279973c3a6e0fa7ed06ffed39f Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 16:27:38 -0700 Subject: [PATCH 16/17] Read the gateway log from the session workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Gateway/GatewayControlOutput.cs | 23 ++++++++++++++----- .../Gateway/GatewayRuntime.cs | 3 +++ src/OpenClaw.Launcher/Program.cs | 5 +++- .../Gateway/GatewayControlOutputTests.cs | 22 ++++++++++-------- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs index f5182eaa..c13f6f68 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs @@ -12,14 +12,18 @@ internal static class GatewayControlOutput public static async Task WriteStatusAsync( TextWriter output, GatewayStatusReport result, - HostPaths paths, + 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, paths, cancellationToken).ConfigureAwait(false); + await WriteLogTailAsync( + output, + workspacePath, + result.Record?.LogPath, + cancellationToken).ConfigureAwait(false); } } @@ -41,12 +45,19 @@ private static async Task WriteDetailAsync(TextWriter output, string? detail) private static async Task WriteLogTailAsync( TextWriter output, - HostPaths paths, + string? workspacePath, + string? logPath, CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(workspacePath) || + string.IsNullOrWhiteSpace(logPath)) + { + return; + } + try { - using FileStream stream = TrustedPath.OpenRead(paths.StateRoot, paths.LogPath); + using FileStream stream = TrustedPath.OpenRead(workspacePath, logPath); if (stream.Length == 0) { return; @@ -81,7 +92,7 @@ .. lines.TakeLast(LogTailLineCount) return; } - await output.WriteLineAsync($"Gateway log tail ({paths.LogPath}):").ConfigureAwait(false); + await output.WriteLineAsync($"Gateway log tail ({logPath}):").ConfigureAwait(false); foreach (string line in tail) { await output.WriteLineAsync(line).ConfigureAwait(false); @@ -91,7 +102,7 @@ .. lines.TakeLast(LogTailLineCount) 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: {paths.LogPath}").ConfigureAwait(false); + await output.WriteLineAsync($"Gateway log unavailable: {logPath}").ConfigureAwait(false); } } diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index 409250d0..0f58094f 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -34,6 +34,9 @@ private GatewayRuntime( 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); diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index d56afdda..b0f87568 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -361,7 +361,10 @@ await error.WriteLineAsync( .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); await Gateway.GatewayControlOutput.WriteStatusAsync( - output, result, runtime.Paths, cancellationToken).ConfigureAwait(false); + output, + result, + runtime.GetRecordedWorkspacePath(), + cancellationToken).ConfigureAwait(false); return result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted ? 0 : 1; }, diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs index 7456fe73..e116182d 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs @@ -17,30 +17,31 @@ public void Dispose() [Fact] public async Task StatusIncludesDetailAndSanitizedBoundedLogTailForAStoppedGateway() { - HostPaths paths = HostPaths.ForRoot(_root, "OpenClaw.Gateway_test"); - Directory.CreateDirectory(Path.GetDirectoryName(paths.LogPath)!); + 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(paths.LogPath, lines).ConfigureAwait(true); + await File.WriteAllLinesAsync(logPath, lines).ConfigureAwait(true); using var output = new StringWriter(); await GatewayControlOutput.WriteStatusAsync( output, new GatewayStatusReport( GatewayState.Stopped, - null, + new GatewayRecord { LogPath = logPath }, "The gateway is not running.", "the application exited with code 78"), - paths, + 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 ({paths.LogPath}):", 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); @@ -51,22 +52,23 @@ await GatewayControlOutput.WriteStatusAsync( [Fact] public async Task StatusKeepsFailureOutputWhenTheGuestLogIsUnavailable() { - HostPaths paths = HostPaths.ForRoot(_root, "OpenClaw.Gateway_test"); + 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, - null, + new GatewayRecord { LogPath = logPath }, "The gateway is not serving.", "Inspect diagnostics before retrying."), - paths, + workspace, CancellationToken.None).ConfigureAwait(true); Assert.Equal( $"The gateway is not serving.{Environment.NewLine}Inspect diagnostics before retrying.{Environment.NewLine}" + - $"Gateway log unavailable: {paths.LogPath}{Environment.NewLine}", + $"Gateway log unavailable: {logPath}{Environment.NewLine}", output.ToString()); } From d550ecaae7f0ec009e457431a13174143b0d6244 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 18:44:48 -0700 Subject: [PATCH 17/17] Update fresh reset launch test seams Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Gateway/GatewayControlOutput.cs | 16 +++-- .../Session/SessionRuntime.cs | 16 +---- .../Gateway/GatewayControlOutputTests.cs | 2 + tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 64 +++++++++++++++---- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs index c13f6f68..60b3cf9d 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs @@ -109,14 +109,22 @@ .. lines.TakeLast(LogTailLineCount) private static string Sanitize(string value) { StringBuilder builder = new(value.Length); - bool inEscapeSequence = false; + bool afterEscape = false; + bool inControlSequence = false; foreach (char character in value) { - if (inEscapeSequence) + if (afterEscape) + { + inControlSequence = character == '['; + afterEscape = false; + continue; + } + + if (inControlSequence) { if (character is >= '@' and <= '~') { - inEscapeSequence = false; + inControlSequence = false; } continue; @@ -124,7 +132,7 @@ private static string Sanitize(string value) if (character == '\x1b') { - inEscapeSequence = true; + afterEscape = true; } else if (!char.IsControl(character)) { diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index b7702450..295697d4 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -202,19 +202,6 @@ session.Detail is null return session.Record; } - public void ValidateSavedOwnershipForHostFallback() - { - SetupStateResult setup = SetupState.Read(ApplicationId); - SessionStatus session = Coordinator.GetRecordedStatus(); - if (setup.Fault == SetupStateFault.Missing && - session.Availability == SessionAvailability.None) - { - return; - } - - RequireSetup(); - } - /// /// Validates local ownership before automatic host fallback. /// @@ -238,7 +225,8 @@ public void ValidateSavedOwnershipForHostFallback() if (session.Record is null) { throw new SessionException( - session.Detail ?? "The saved isolated-session record could not be used."); + $"{session.Detail ?? "The saved isolated-session record could not be used."} " + + "Run `clawctl setup` to repair it."); } if (setup.Record is null) diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs index e116182d..a30b3bd0 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs @@ -45,6 +45,8 @@ await GatewayControlOutput.WriteStatusAsync( 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); } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 0667b858..58ae4219 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -242,6 +242,43 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( 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() { @@ -847,7 +884,7 @@ public async Task AutomaticAgentLaunchUsesHostOnlyWhenReadinessReportsIsolationU new Version(24, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); }, - (_, _, _, _, _) => + (_, _, _, _, _, _) => { launchedHost = true; return Task.FromResult(17); @@ -887,7 +924,7 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( new Version(24, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); }, - (_, _, _, _, _) => Task.FromResult(0), + (_, _, _, _, _, _) => Task.FromResult(0), probeReadiness: _ => Task.FromResult(new MxcReadinessReport( "runtime", null, @@ -922,7 +959,7 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( new Version(24, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); }, - (_, _, _, _, _) => + (_, _, _, _, _, _) => { launchedHost = true; return Task.FromResult(0); @@ -1147,16 +1184,6 @@ private Task RunAgentWithDirectLaunchProbeAsync( getPackageFamilyName: () => "OpenClaw.Gateway_test"); } - private HostOptions CreateAgentOptions() - { - string applicationDirectory = Path.Combine(_testDirectory, "agent-app"); - Directory.CreateDirectory(applicationDirectory); - File.WriteAllText( - Path.Combine(applicationDirectory, "openclaw.mjs"), - "console.log('fixture');"); - return new HostOptions(applicationDirectory, null, ["--version"]); - } - private static void WriteRuntimeInstallResult(FakeMxcSessionClient backend, int exitCode) { string requestPath = Directory.GetFiles(backend.Metadata!.EphemeralWorkspacePath, "runtime-*.json") @@ -1175,6 +1202,16 @@ private static void WriteRuntimeInstallResult(FakeMxcSessionClient backend, int })); } + private HostOptions CreateAgentOptions() + { + string applicationDirectory = Path.Combine(_testDirectory, "agent-app"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllText( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + return new HostOptions(applicationDirectory, null, ["--version"]); + } + private async Task CreateApplicationAsync() { string applicationDirectory = Path.Combine(_testDirectory, Guid.NewGuid().ToString("N"), "app"); @@ -1315,6 +1352,7 @@ public void Clear() Cleared = true; Cleanup?.Invoke(); } + } private sealed class RecordingLockHandle : ISessionLockHandle