From 730347cc8b062cdcdcff538f7fd61821bf625dd0 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 10:32:32 -0700 Subject: [PATCH 1/7] feat: add agent config readiness probe Classify the isolated agent's default OpenClaw config without starting Node or the OpenClaw runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cc6aa7d-1710-48a9-804d-3e5f496d8a34 --- docs/mxc-compatibility-evidence.md | 11 ++ src/OpenClaw.SessionHost/AgentProfile.cs | 44 +++++ src/OpenClaw.SessionHost/Program.cs | 14 +- src/OpenClaw.SessionHost/SessionCollector.cs | 37 +--- .../SessionConfigReadinessChecker.cs | 159 ++++++++++++++++ .../SessionConfigReadinessProtocol.cs | 179 ++++++++++++++++++ .../SessionConfigReadinessCheckerTests.cs | 156 +++++++++++++++ .../SessionConfigReadinessProtocolTests.cs | 135 +++++++++++++ .../Session/SessionHostExecutableTests.cs | 24 ++- .../Session/SessionHostProgramTests.cs | 24 +++ 10 files changed, 742 insertions(+), 41 deletions(-) create mode 100644 src/OpenClaw.SessionHost/AgentProfile.cs create mode 100644 src/OpenClaw.SessionHost/SessionConfigReadinessChecker.cs create mode 100644 src/OpenClaw.SessionProtocol/SessionConfigReadinessProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessCheckerTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessProtocolTests.cs diff --git a/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md index 398d83e8..d9fdd9d3 100644 --- a/docs/mxc-compatibility-evidence.md +++ b/docs/mxc-compatibility-evidence.md @@ -69,6 +69,17 @@ shared session workspace. It does not stage the application tree. The helper has distinct modes for launching a request, inspecting processes/listeners, installing the agent runtime, and collecting requested diagnostics. +The helper also exposes an internal `--check-config ` mode for a +future host-side caller. It resolves the agent account's default +`.openclaw\openclaw.json` and classifies the file as `Absent`, `NotReady`, or +`StartupEligible` without starting Node.js or OpenClaw. This is deliberately a +file-only heuristic for the default OpenClaw-generated config: +`StartupEligible` means only that the file can be parsed and contains +`gateway.mode` set exactly to `local`. It does not resolve alternate profiles, +includes, environment substitution, secrets, plugins, bind/auth policy, ports, +or any other runtime dependency, and therefore does not claim that a gateway +will start or remain healthy. + Opening the agent shell with `clawctl pwsh` installs an ASCII `openclaw.cmd` shim in the shared workspace: `RunPowerShellAsync` calls `InstallToolsAsync`. Setup alone does not guarantee that shim exists. The shim reads its Node.js and diff --git a/src/OpenClaw.SessionHost/AgentProfile.cs b/src/OpenClaw.SessionHost/AgentProfile.cs new file mode 100644 index 00000000..c7c76e20 --- /dev/null +++ b/src/OpenClaw.SessionHost/AgentProfile.cs @@ -0,0 +1,44 @@ +using System.Runtime.InteropServices; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// Resolves paths owned by the isolated session's agent account. +internal static class AgentProfile +{ + private static readonly Guid ProfileFolderId = + new("5E6C858F-0E22-4760-9AFE-EA3317B67173"); + + public static string GetPath() + { + int result = SHGetKnownFolderPath( + ProfileFolderId, + flags: 0, + token: IntPtr.Zero, + out IntPtr path); + if (result < 0) + { + throw new SessionLaunchException( + $"Windows could not resolve the user profile directory " + + $"(HRESULT 0x{result:X8})."); + } + + try + { + return Marshal.PtrToStringUni(path) + ?? throw new SessionLaunchException( + "Windows returned an empty user profile directory."); + } + finally + { + Marshal.FreeCoTaskMem(path); + } + } + + [DllImport("shell32.dll")] + private static extern int SHGetKnownFolderPath( + in Guid folderId, + uint flags, + IntPtr token, + out IntPtr path); +} diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs index 930c8b26..b85b224a 100644 --- a/src/OpenClaw.SessionHost/Program.cs +++ b/src/OpenClaw.SessionHost/Program.cs @@ -21,7 +21,8 @@ internal static int Run( ISessionProcessLauncher launcher, TextWriter errorOutput, Func readFile, - Action writeResult) + Action writeResult, + Func? checkConfig = null) { if (!TryGetMode(args, out string? mode, out string? requestPath)) { @@ -29,7 +30,8 @@ internal static int Run( // is the one failure that can only surface on stderr. errorOutput.WriteLine( "openclaw-session-host: usage: openclaw-session-host " + - "--request|--supervise|--inspect|--stop|--collect|--install-runtime|--install-tools "); + "--request|--supervise|--inspect|--stop|--collect|--install-runtime|" + + "--install-tools|--check-config "); return SessionLaunchProtocol.HelperFailureExitCode; } @@ -45,6 +47,12 @@ internal static int Run( return SessionRuntimeInstaller.Run(requestPath, readFile, File.WriteAllText); case "--install-tools": return SessionToolInstaller.Run(requestPath, readFile, File.WriteAllText); + case "--check-config": + return checkConfig?.Invoke(requestPath) ?? + SessionConfigReadinessChecker.Run( + requestPath, + readFile, + File.WriteAllText); } if (args[0] == "--collect") @@ -125,7 +133,7 @@ private static bool TryGetMode( } if (args[0] is not ("--request" or "--supervise" or "--inspect" or "--stop" or - "--collect" or "--install-runtime" or "--install-tools")) + "--collect" or "--install-runtime" or "--install-tools" or "--check-config")) { return false; } diff --git a/src/OpenClaw.SessionHost/SessionCollector.cs b/src/OpenClaw.SessionHost/SessionCollector.cs index 9b797d22..453ca8a4 100644 --- a/src/OpenClaw.SessionHost/SessionCollector.cs +++ b/src/OpenClaw.SessionHost/SessionCollector.cs @@ -23,9 +23,6 @@ namespace OpenClaw.SessionHost; /// internal static class SessionCollector { - private static readonly Guid ProfileFolderId = - new("5E6C858F-0E22-4760-9AFE-EA3317B67173"); - public static int Run( string requestPath, Func readFile, @@ -36,7 +33,7 @@ public static int Run( { string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); string? requestId = null; - string profile = profileRoot ?? GetProfilePath(); + string profile = profileRoot ?? AgentProfile.GetPath(); try { @@ -235,32 +232,6 @@ internal static string ResolveInProfile(string profile, string relativePath) "resolves outside the agent profile"); } - private static string GetProfilePath() - { - int result = SHGetKnownFolderPath( - ProfileFolderId, - flags: 0, - token: IntPtr.Zero, - out IntPtr path); - if (result < 0) - { - throw new SessionLaunchException( - $"Windows could not resolve the user profile directory " + - $"(HRESULT 0x{result:X8})."); - } - - try - { - return Marshal.PtrToStringUni(path) - ?? throw new SessionLaunchException( - "Windows returned an empty user profile directory."); - } - finally - { - Marshal.FreeCoTaskMem(path); - } - } - private static string ResolveInWorkspace(string workspace, string destination) { string root = Path.GetFullPath(workspace) @@ -820,10 +791,4 @@ private static extern uint GetFinalPathNameByHandle( uint pathLength, uint flags); - [DllImport("shell32.dll")] - private static extern int SHGetKnownFolderPath( - in Guid folderId, - uint flags, - IntPtr token, - out IntPtr path); } diff --git a/src/OpenClaw.SessionHost/SessionConfigReadinessChecker.cs b/src/OpenClaw.SessionHost/SessionConfigReadinessChecker.cs new file mode 100644 index 00000000..c25b9360 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionConfigReadinessChecker.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// Implements the guest-side file-only OpenClaw config check. +internal static class SessionConfigReadinessChecker +{ + private static readonly JsonDocumentOptions DocumentOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }; + + public static int Run( + string requestPath, + Func readFile, + Action writeFile, + string? profileRoot = null, + Func? fileExists = null) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + + try + { + SessionConfigReadinessRequest request = + SessionConfigReadinessProtocol.ReadRequest(readFile(requestPath)); + requestId = request.RequestId!; + string profile = profileRoot ?? AgentProfile.GetPath(); + string configPath = Path.Combine(profile, ".openclaw", "openclaw.json"); + SessionConfigReadinessResult result = Classify( + requestId, + configPath, + readFile, + fileExists ?? File.Exists); + writeFile(resultPath, SessionConfigReadinessProtocol.SerializeResult(result)); + return 0; + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException or ArgumentException) + { + try + { + writeFile( + resultPath, + SessionConfigReadinessProtocol.SerializeResult( + new SessionConfigReadinessResult + { + RequestId = requestId, + Error = exception.Message + })); + } + catch (Exception writeException) when ( + writeException is IOException or UnauthorizedAccessException) + { + // The helper exit code remains the only available signal. + } + + return SessionLaunchProtocol.HelperFailureExitCode; + } + } + + internal static SessionConfigReadinessResult Classify( + string requestId, + string configPath, + Func readFile, + Func fileExists) + { + if (!fileExists(configPath)) + { + return Result( + requestId, + SessionConfigReadinessState.Absent, + SessionConfigReadinessReason.ConfigFileMissing); + } + + string json; + try + { + json = readFile(configPath); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return Result( + requestId, + SessionConfigReadinessState.Absent, + SessionConfigReadinessReason.ConfigFileMissing); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return Result( + requestId, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.ConfigFileUnreadable); + } + + try + { + using JsonDocument document = JsonDocument.Parse(json, DocumentOptions); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return Result( + requestId, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.ConfigFileInvalid); + } + + if (!document.RootElement.TryGetProperty("gateway", out JsonElement gateway) || + gateway.ValueKind != JsonValueKind.Object) + { + return Result( + requestId, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.GatewayMissing); + } + + if (!gateway.TryGetProperty("mode", out JsonElement mode) || + mode.ValueKind != JsonValueKind.String) + { + return Result( + requestId, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.GatewayModeMissing); + } + + return string.Equals(mode.GetString(), "local", StringComparison.Ordinal) + ? Result( + requestId, + SessionConfigReadinessState.StartupEligible, + SessionConfigReadinessReason.GatewayModeLocal) + : Result( + requestId, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.GatewayModeNotLocal); + } + catch (JsonException) + { + return Result( + requestId, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.ConfigFileInvalid); + } + } + + private static SessionConfigReadinessResult Result( + string requestId, + SessionConfigReadinessState state, + SessionConfigReadinessReason reason) => + new() + { + RequestId = requestId, + State = state, + Reason = reason + }; +} diff --git a/src/OpenClaw.SessionProtocol/SessionConfigReadinessProtocol.cs b/src/OpenClaw.SessionProtocol/SessionConfigReadinessProtocol.cs new file mode 100644 index 00000000..e51d8343 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionConfigReadinessProtocol.cs @@ -0,0 +1,179 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// Asks the guest to classify its default OpenClaw config file. +public sealed record SessionConfigReadinessRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } +} + +/// The file-only readiness state of the guest's default config. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionConfigReadinessState +{ + Absent, + NotReady, + StartupEligible +} + +/// A stable explanation for a successful readiness classification. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionConfigReadinessReason +{ + ConfigFileMissing, + ConfigFileUnreadable, + ConfigFileInvalid, + GatewayMissing, + GatewayModeMissing, + GatewayModeNotLocal, + GatewayModeLocal +} + +/// The guest's file-only OpenClaw config classification. +public sealed record SessionConfigReadinessResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("state")] + public SessionConfigReadinessState? State { get; init; } + + [JsonPropertyName("reason")] + public SessionConfigReadinessReason? Reason { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(SessionConfigReadinessRequest))] +[JsonSerializable(typeof(SessionConfigReadinessResult))] +internal sealed partial class SessionConfigReadinessJsonContext : JsonSerializerContext; + +/// Serializes and validates config-readiness control messages. +public static class SessionConfigReadinessProtocol +{ + public static string SerializeRequest(SessionConfigReadinessRequest request) => + JsonSerializer.Serialize( + request, + SessionConfigReadinessJsonContext.Default.SessionConfigReadinessRequest); + + public static string SerializeResult(SessionConfigReadinessResult result) => + JsonSerializer.Serialize( + result, + SessionConfigReadinessJsonContext.Default.SessionConfigReadinessResult); + + public static SessionConfigReadinessRequest ReadRequest(string json) + { + SessionConfigReadinessRequest request = Deserialize( + json, + SessionConfigReadinessJsonContext.Default.SessionConfigReadinessRequest, + "config readiness request"); + RequireCurrentSchema(request.SchemaVersion, "config readiness request", "helper"); + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new SessionLaunchException( + "The config readiness request has no request id."); + } + + return request; + } + + public static SessionConfigReadinessResult ReadResult( + string json, + string? expectedRequestId = null) + { + SessionConfigReadinessResult result = Deserialize( + json, + SessionConfigReadinessJsonContext.Default.SessionConfigReadinessResult, + "config readiness result"); + RequireCurrentSchema(result.SchemaVersion, "config readiness result", "launcher"); + if (string.IsNullOrWhiteSpace(result.RequestId)) + { + throw new SessionLaunchException( + "The config readiness result has no request id."); + } + + if (expectedRequestId is not null && + !string.Equals(result.RequestId, expectedRequestId, StringComparison.Ordinal)) + { + throw new SessionLaunchException( + "The config readiness result does not match the request."); + } + + bool hasError = !string.IsNullOrWhiteSpace(result.Error); + bool hasState = result.State is not null; + bool hasReason = result.Reason is not null; + if ((hasError && (hasState || hasReason)) || + (!hasError && (!hasState || !hasReason))) + { + throw new SessionLaunchException( + "The config readiness result must contain either an error or a classification."); + } + + if (!hasError && !IsCompatible(result.State!.Value, result.Reason!.Value)) + { + throw new SessionLaunchException( + "The config readiness result contains an incompatible state and reason."); + } + + return result; + } + + private static bool IsCompatible( + SessionConfigReadinessState state, + SessionConfigReadinessReason reason) => + state switch + { + SessionConfigReadinessState.Absent => + reason == SessionConfigReadinessReason.ConfigFileMissing, + SessionConfigReadinessState.StartupEligible => + reason == SessionConfigReadinessReason.GatewayModeLocal, + SessionConfigReadinessState.NotReady => + reason is not SessionConfigReadinessReason.ConfigFileMissing and + not SessionConfigReadinessReason.GatewayModeLocal, + _ => false + }; + + private static T Deserialize( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + string description) + { + try + { + return JsonSerializer.Deserialize(json, typeInfo) + ?? throw new SessionLaunchException( + $"The {description} is empty."); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The {description} is invalid: {exception.Message}", + exception); + } + } + + private static void RequireCurrentSchema( + int version, + string description, + string reader) + { + if (version != SessionLaunchProtocol.CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"The {reader} cannot read {description} schema version {version}; " + + $"expected {SessionLaunchProtocol.CurrentSchemaVersion}."); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessCheckerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessCheckerTests.cs new file mode 100644 index 00000000..a3034a47 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessCheckerTests.cs @@ -0,0 +1,156 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionConfigReadinessCheckerTests : IDisposable +{ + private readonly string _profile = TestDirectory.Create(); + + private string ConfigPath => + Path.Combine(_profile, ".openclaw", "openclaw.json"); + + public void Dispose() + { + Directory.Delete(_profile, recursive: true); + GC.SuppressFinalize(this); + } + + private void WriteConfig(string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); + File.WriteAllText(ConfigPath, content); + } + + private static void AssertResult( + SessionConfigReadinessResult result, + SessionConfigReadinessState state, + SessionConfigReadinessReason reason) + { + Assert.Equal(state, result.State); + Assert.Equal(reason, result.Reason); + Assert.Null(result.Error); + } + + [Fact] + public void ADeletedOrNeverCreatedConfigIsAbsent() + { + SessionConfigReadinessResult missing = SessionConfigReadinessChecker.Classify( + "r1", + ConfigPath, + File.ReadAllText, + File.Exists); + SessionConfigReadinessResult raced = SessionConfigReadinessChecker.Classify( + "r2", + ConfigPath, + _ => throw new FileNotFoundException(), + _ => true); + + AssertResult( + missing, + SessionConfigReadinessState.Absent, + SessionConfigReadinessReason.ConfigFileMissing); + AssertResult( + raced, + SessionConfigReadinessState.Absent, + SessionConfigReadinessReason.ConfigFileMissing); + } + + [Fact] + public void AnUnreadableConfigIsNotReady() + { + SessionConfigReadinessResult result = SessionConfigReadinessChecker.Classify( + "r1", + ConfigPath, + _ => throw new UnauthorizedAccessException(), + _ => true); + + AssertResult( + result, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.ConfigFileUnreadable); + } + + [Theory] + [InlineData("")] + [InlineData("null")] + [InlineData("{ invalid")] + public void AnInvalidConfigIsNotReady(string content) + { + WriteConfig(content); + + SessionConfigReadinessResult result = SessionConfigReadinessChecker.Classify( + "r1", + ConfigPath, + File.ReadAllText, + File.Exists); + + AssertResult( + result, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.ConfigFileInvalid); + } + + [Theory] + [InlineData("{}", SessionConfigReadinessReason.GatewayMissing)] + [InlineData("""{"gateway":{}}""", SessionConfigReadinessReason.GatewayModeMissing)] + [InlineData("""{"gateway":{"mode":42}}""", SessionConfigReadinessReason.GatewayModeMissing)] + [InlineData("""{"gateway":{"mode":"remote"}}""", SessionConfigReadinessReason.GatewayModeNotLocal)] + [InlineData("""{"gateway":{"mode":"LOCAL"}}""", SessionConfigReadinessReason.GatewayModeNotLocal)] + public void AConfigWithoutExactLocalModeIsNotReady( + string content, + SessionConfigReadinessReason reason) + { + WriteConfig(content); + + SessionConfigReadinessResult result = SessionConfigReadinessChecker.Classify( + "r1", + ConfigPath, + File.ReadAllText, + File.Exists); + + AssertResult(result, SessionConfigReadinessState.NotReady, reason); + } + + [Fact] + public void AGeneratedConfigWithLocalModeIsStartupEligible() + { + WriteConfig( + """ + { + // Generated configuration can contain comments. + "gateway": { + "mode": "local", + }, + } + """); + + SessionConfigReadinessResult result = SessionConfigReadinessChecker.Classify( + "r1", + ConfigPath, + File.ReadAllText, + File.Exists); + + AssertResult( + result, + SessionConfigReadinessState.StartupEligible, + SessionConfigReadinessReason.GatewayModeLocal); + } + + [Fact] + public void RunWritesAProtocolFailureForAMalformedRequest() + { + string requestPath = Path.Combine(_profile, "request.json"); + string? resultText = null; + + int exitCode = SessionConfigReadinessChecker.Run( + requestPath, + _ => "{ invalid", + (_, value) => resultText = value, + _profile); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.NotNull(resultText); + Assert.Contains("\"error\":", resultText, StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessProtocolTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessProtocolTests.cs new file mode 100644 index 00000000..0151e336 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionConfigReadinessProtocolTests.cs @@ -0,0 +1,135 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionConfigReadinessProtocolTests +{ + private static SessionConfigReadinessRequest ValidRequest() => new() + { + RequestId = "readiness-1" + }; + + private static SessionConfigReadinessResult ValidResult() => new() + { + RequestId = "readiness-1", + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + }; + + [Fact] + public void AValidRequestAndResultRoundTrip() + { + SessionConfigReadinessRequest request = + SessionConfigReadinessProtocol.ReadRequest( + SessionConfigReadinessProtocol.SerializeRequest(ValidRequest())); + SessionConfigReadinessResult result = + SessionConfigReadinessProtocol.ReadResult( + SessionConfigReadinessProtocol.SerializeResult(ValidResult()), + request.RequestId); + + Assert.Equal("readiness-1", result.RequestId); + Assert.Equal(SessionConfigReadinessState.StartupEligible, result.State); + Assert.Equal(SessionConfigReadinessReason.GatewayModeLocal, result.Reason); + } + + [Fact] + public void ARequestWithoutAnIdIsRejected() + { + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadRequest( + SessionConfigReadinessProtocol.SerializeRequest( + ValidRequest() with { RequestId = null }))); + } + + [Fact] + public void AStaleSchemaVersionIsRejected() + { + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadRequest( + SessionConfigReadinessProtocol.SerializeRequest( + ValidRequest() with + { + SchemaVersion = SessionLaunchProtocol.CurrentSchemaVersion + 1 + }))); + } + + [Fact] + public void MalformedJsonIsRejected() + { + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadRequest("{ not-json")); + } + + [Fact] + public void AResultWithoutAnIdIsRejected() + { + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadResult( + SessionConfigReadinessProtocol.SerializeResult( + ValidResult() with { RequestId = null }))); + } + + [Fact] + public void AResultForAnotherRequestIsRejected() + { + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadResult( + SessionConfigReadinessProtocol.SerializeResult(ValidResult()), + "another-request")); + } + + [Fact] + public void AnInvalidEnumValueIsRejected() + { + SessionConfigReadinessResult result = ValidResult() with + { + State = (SessionConfigReadinessState)42 + }; + + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadResult( + SessionConfigReadinessProtocol.SerializeResult(result))); + } + + [Fact] + public void AnIncompatibleStateAndReasonAreRejected() + { + SessionConfigReadinessResult result = ValidResult() with + { + State = SessionConfigReadinessState.Absent + }; + + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadResult( + SessionConfigReadinessProtocol.SerializeResult(result))); + } + + [Fact] + public void AFailureCannotAlsoClaimAClassification() + { + SessionConfigReadinessResult result = ValidResult() with + { + Error = "profile lookup failed" + }; + + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadResult( + SessionConfigReadinessProtocol.SerializeResult(result))); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void APartialClassificationIsRejected(bool retainState) + { + SessionConfigReadinessResult result = ValidResult() with + { + State = retainState ? ValidResult().State : null, + Reason = retainState ? null : ValidResult().Reason + }; + + Assert.Throws( + () => SessionConfigReadinessProtocol.ReadResult( + SessionConfigReadinessProtocol.SerializeResult(result))); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs index b5f91a0f..73a475cd 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs @@ -43,7 +43,10 @@ private string CreateHostileDirectory() return path; } - private static int RunHelper(string requestPath, string workingDirectory) + private static int RunHelper( + string requestPath, + string workingDirectory, + string mode = "--request") { ProcessStartInfo startInfo = new() { @@ -52,7 +55,7 @@ private static int RunHelper(string requestPath, string workingDirectory) RedirectStandardError = true, WorkingDirectory = workingDirectory }; - startInfo.ArgumentList.Add("--request"); + startInfo.ArgumentList.Add(mode); startInfo.ArgumentList.Add(requestPath); using Process process = Process.Start(startInfo)!; @@ -67,6 +70,23 @@ private static SessionLaunchResult ReadResult(string requestPath) => SessionLaunchProtocol.ReadResult( File.ReadAllText(SessionLaunchProtocol.ResultPathFor(requestPath))); + [Fact] + public void ThePublishedHelperDispatchesConfigReadinessWithoutStartingAChild() + { + string requestPath = Path.Combine(_testDirectory, "readiness.json"); + File.WriteAllText(requestPath, "{ not a request"); + + int exitCode = RunHelper( + requestPath, + AppContext.BaseDirectory, + "--check-config"); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + string result = File.ReadAllText( + SessionLaunchProtocol.ResultPathFor(requestPath)); + Assert.Contains("\"error\":", result, StringComparison.Ordinal); + } + [Fact] public void TheHelperStartsARealProcessAndDistinguishesItsFailureFromTheChildExitCode() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs index 45f88f9b..7b00fb7b 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs @@ -100,6 +100,30 @@ public void ToolInstallModeReachesTheGuestInstallerThroughTheHelperEntrypoint() Assert.True(File.Exists(result.ShimPath)); } + [Fact] + public void ConfigReadinessModeUsesItsDedicatedCheckerWithoutLaunchingAProcess() + { + var launcher = new RecordingLauncher(0); + string? deliveredPath = null; + + int exitCode = SessionHostProgram.Run( + ["--check-config", @"C:\shared\readiness.json"], + launcher, + new StringWriter(), + _ => throw new InvalidOperationException("The checker owns its request."), + (_, _) => throw new InvalidOperationException("The checker owns its result."), + path => + { + deliveredPath = path; + return 17; + }); + + Assert.Equal(17, exitCode); + Assert.Equal(@"C:\shared\readiness.json", deliveredPath); + Assert.Null(launcher.Request); + Assert.Null(launcher.DetachedRequest); + } + [Fact] public void TheArgumentVectorReachesTheLauncherUnchanged() { From dd51543b370e61847c9a62f5d8bce46db31dc1ab Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 11:14:32 -0700 Subject: [PATCH 2/7] feat: suggest starting a ready gateway After successful interactive OpenClaw calls, check agent config readiness and managed gateway liveness before suggesting clawctl gateway-service start. Suppress later checks for the current Windows logon after a manual start or an observed running gateway. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cc6aa7d-1710-48a9-804d-3e5f496d8a34 --- README.md | 9 + docs/mxc-compatibility-evidence.md | 23 +- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 6 +- .../Gateway/AgentGatewayGuidance.cs | 119 +++++++++++ .../Gateway/GatewayGuidanceStateStore.cs | 99 +++++++++ .../Gateway/GatewayLauncherScript.cs | 2 +- .../Gateway/WindowsLogonSession.cs | 87 ++++++++ src/OpenClaw.Launcher/HostPaths.cs | 3 + src/OpenClaw.Launcher/Program.cs | 55 ++++- .../Session/SessionExecutor.cs | 60 ++++++ .../SmokeProgram.cs | 12 ++ .../ClawCtlCommandLineTests.cs | 42 +++- .../ClawCtlHelpTests.cs | 12 +- .../ClawCtlParserDefaultsTests.cs | 2 +- .../Gateway/AgentGatewayGuidanceTests.cs | 201 ++++++++++++++++++ .../Gateway/GatewayGuidanceStateStoreTests.cs | 51 +++++ .../Gateway/GatewayPersistenceManagerTests.cs | 5 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 119 ++++++++++- .../Session/HostPathsTests.cs | 4 + .../Session/SessionExecutorTests.cs | 36 ++++ 20 files changed, 926 insertions(+), 21 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayGuidanceStateStore.cs create mode 100644 src/OpenClaw.Launcher/Gateway/WindowsLogonSession.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/GatewayGuidanceStateStoreTests.cs diff --git a/README.md b/README.md index 0a5ba023..7a868219 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,15 @@ reserve, reject, or rewrite upstream command arguments. OpenClaw inherits the terminal's working directory; the launcher does not make the read-only application directory the workspace. +After each successful interactive `openclaw` invocation, the launcher performs +a file-only readiness check inside the isolated session. If the default +`.openclaw\openclaw.json` has `gateway.mode` set to `local` but the managed +gateway has never started or has stopped, it suggests +`clawctl gateway-service start` on standard error. It does not suggest a second +start when gateway status is starting, unhealthy, or unknown. The check stops +for the current Windows logon after that start command is invoked or a running +gateway is observed; a new Windows logon enables it again. + ### `clawctl` `clawctl` owns setup and the isolated-session operations: diff --git a/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md index d9fdd9d3..c39e26c1 100644 --- a/docs/mxc-compatibility-evidence.md +++ b/docs/mxc-compatibility-evidence.md @@ -69,8 +69,8 @@ shared session workspace. It does not stage the application tree. The helper has distinct modes for launching a request, inspecting processes/listeners, installing the agent runtime, and collecting requested diagnostics. -The helper also exposes an internal `--check-config ` mode for a -future host-side caller. It resolves the agent account's default +The helper also exposes an internal `--check-config ` mode used by +the launcher after packaged `openclaw` calls. It resolves the agent account's default `.openclaw\openclaw.json` and classifies the file as `Absent`, `NotReady`, or `StartupEligible` without starting Node.js or OpenClaw. This is deliberately a file-only heuristic for the default OpenClaw-generated config: @@ -80,6 +80,10 @@ includes, environment substitution, secrets, plugins, bind/auth policy, ports, or any other runtime dependency, and therefore does not claim that a gateway will start or remain healthy. +On the local win-x64 NativeAOT publish, ten direct helper invocations took +53.5-108.9 ms (59.15 ms median), including process startup and file inspection +but excluding the MXC round trip. No invocation started Node.js or OpenClaw. + Opening the agent shell with `clawctl pwsh` installs an ASCII `openclaw.cmd` shim in the shared workspace: `RunPowerShellAsync` calls `InstallToolsAsync`. Setup alone does not guarantee that shim exists. The shim reads its Node.js and @@ -115,6 +119,21 @@ owned by that process or one of its descendants. A missing record is process is `Unhealthy`; and failed inspection is `Unknown` to avoid starting a second gateway beside one that could still be healthy. +After an `openclaw` child exits, the launcher uses the helper's file-only +readiness result before checking the managed gateway record and liveness. A +successful interactive call gets a start suggestion only for `NotStarted` or +`Stopped`; unsuccessful or redirected calls, and `Starting`, `Unhealthy`, or +`Unknown` gateway states, remain silent. The advisory path cannot change the +OpenClaw exit code. + +The suggestion remains eligible until a manual +`clawctl gateway-service start` invocation or an observed `Running` state. +Acknowledgement is package-local and keyed to the Windows token authentication +ID, so it suppresses later checks only for the current Windows logon. The +sign-in recovery command carries a hidden provenance marker and does not count +as a manual acknowledgement; a later OpenClaw invocation that observes its +running gateway does. + ## Diagnostics and safe collection `clawctl collect-logs [--output ]` creates a ZIP at the supplied path or diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 8554a83a..f98b28cd 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -11,7 +11,7 @@ internal sealed record ClawCtlHandlers public required Func> CollectLogs { get; init; } public required Func> Teardown { get; init; } public required Func> PowerShell { get; init; } - public required Func> GatewayStart { get; init; } + public required Func> GatewayStart { get; init; } public required Func> GatewayStatus { get; init; } public required Func> GatewayStop { get; init; } } @@ -163,11 +163,13 @@ public static RootCommand Create( "gateway-service", "Manage the background OpenClaw gateway inside the isolated session."); Command gatewayStart = new("start", "Start the gateway if needed."); + Option recovery = new("--recovery") { Hidden = true }; + gatewayStart.Options.Add(recovery); gatewayStart.SetAction((parsed, token) => { outputOptions.Json = parsed.GetValue(json); outputOptions.NoColor = parsed.GetValue(noColor); - return handlers.GatewayStart(token); + return handlers.GatewayStart(parsed.GetValue(recovery), token); }); Command gatewayStatus = new("status", "Show whether the gateway is running."); gatewayStatus.SetAction((parsed, token) => diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs new file mode 100644 index 00000000..4939dfb9 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -0,0 +1,119 @@ +using System.ComponentModel; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +internal sealed class AgentGatewayGuidance +{ + internal const string Hint = + "Hint: The OpenClaw gateway is not running. " + + "Run 'clawctl gateway-service start' to start it."; + internal static readonly TimeSpan AdvisoryTimeout = TimeSpan.FromSeconds(5); + + private readonly ISessionLock _lifecycleLock; + private readonly Func> _checkReadiness; + private readonly Func> _getGatewayStatus; + private readonly GatewayGuidanceStateStore _state; + private readonly Func _getLogonSessionId; + private readonly Action _log; + private readonly TimeProvider _clock; + + public AgentGatewayGuidance( + ISessionLock lifecycleLock, + Func> checkReadiness, + Func> getGatewayStatus, + GatewayGuidanceStateStore state, + Func getLogonSessionId, + Action log, + TimeProvider? clock = null) + { + _lifecycleLock = lifecycleLock; + _checkReadiness = checkReadiness; + _getGatewayStatus = getGatewayStatus; + _state = state; + _getLogonSessionId = getLogonSessionId; + _log = log; + _clock = clock ?? TimeProvider.System; + } + + public async Task EvaluateAsync( + int openClawExitCode, + bool interactive, + TextWriter error) + { + try + { + string logonSessionId = _getLogonSessionId(); + if (_state.IsAcknowledged(logonSessionId)) + { + _log("Gateway guidance is already acknowledged for this Windows logon."); + return; + } + + using var readinessTimeout = new CancellationTokenSource(AdvisoryTimeout); + SessionConfigReadinessResult readiness = + await _checkReadiness(readinessTimeout.Token).ConfigureAwait(false); + _log($"Agent config readiness is {readiness.State} ({readiness.Reason})."); + if (readiness.State != SessionConfigReadinessState.StartupEligible) + { + return; + } + + using var statusTimeout = new CancellationTokenSource(AdvisoryTimeout); + GatewayStatusReport status = + await _getGatewayStatus(statusTimeout.Token).ConfigureAwait(false); + _log($"Post-OpenClaw gateway state is {status.State}."); + if (status.State == GatewayState.Running) + { + Acknowledge( + logonSessionId, + GatewayGuidanceAcknowledgement.GatewayObservedRunning); + return; + } + + if (status.State is GatewayState.NotStarted or GatewayState.Stopped && + interactive && + openClawExitCode == 0) + { + await error.WriteLineAsync(Hint).ConfigureAwait(false); + } + } + catch (Exception exception) when ( + exception is SessionException or SessionLaunchException or + OperationCanceledException or + IOException or UnauthorizedAccessException or InvalidOperationException or + ObjectDisposedException or Win32Exception) + { + _log($"Gateway guidance was skipped: {exception.GetType().Name}: {exception.Message}"); + } + } + + public void AcknowledgeManualStart() + { + try + { + Acknowledge( + _getLogonSessionId(), + GatewayGuidanceAcknowledgement.ManualStartInvoked); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or + InvalidOperationException or Win32Exception) + { + _log( + $"Gateway guidance acknowledgement failed: " + + $"{exception.GetType().Name}: {exception.Message}"); + } + } + + private void Acknowledge( + string logonSessionId, + GatewayGuidanceAcknowledgement acknowledgement) + { + using ISessionLockHandle handle = + _lifecycleLock.TryAcquire(AdvisoryTimeout) + ?? throw new SessionBusyException(AdvisoryTimeout); + _state.Write(logonSessionId, acknowledgement, _clock.GetUtcNow()); + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayGuidanceStateStore.cs b/src/OpenClaw.Launcher/Gateway/GatewayGuidanceStateStore.cs new file mode 100644 index 00000000..34dca2e6 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayGuidanceStateStore.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Gateway; + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum GatewayGuidanceAcknowledgement +{ + ManualStartInvoked, + GatewayObservedRunning +} + +internal sealed record GatewayGuidanceRecord +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("logonSessionId")] + public string LogonSessionId { get; init; } = string.Empty; + + [JsonPropertyName("acknowledgement")] + public GatewayGuidanceAcknowledgement Acknowledgement { get; init; } + + [JsonPropertyName("acknowledgedUtc")] + public DateTimeOffset AcknowledgedUtc { get; init; } +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(GatewayGuidanceRecord))] +internal sealed partial class GatewayGuidanceJsonContext : JsonSerializerContext; + +internal sealed class GatewayGuidanceStateStore +{ + public const int CurrentSchemaVersion = 1; + + private readonly string _filePath; + + public GatewayGuidanceStateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = Path.GetFullPath(filePath); + } + + public bool IsAcknowledged(string logonSessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(logonSessionId); + + try + { + GatewayGuidanceRecord? record = JsonSerializer.Deserialize( + File.ReadAllText(_filePath), + GatewayGuidanceJsonContext.Default.GatewayGuidanceRecord); + return record is not null && + record.SchemaVersion == CurrentSchemaVersion && + Enum.IsDefined(record.Acknowledgement) && + string.Equals( + record.LogonSessionId, + logonSessionId, + StringComparison.Ordinal); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or + IOException or UnauthorizedAccessException or JsonException) + { + return false; + } + } + + public void Write( + string logonSessionId, + GatewayGuidanceAcknowledgement acknowledgement, + DateTimeOffset acknowledgedUtc) + { + ArgumentException.ThrowIfNullOrWhiteSpace(logonSessionId); + if (!Enum.IsDefined(acknowledgement)) + { + throw new ArgumentOutOfRangeException(nameof(acknowledgement)); + } + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string text = JsonSerializer.Serialize( + new GatewayGuidanceRecord + { + SchemaVersion = CurrentSchemaVersion, + LogonSessionId = logonSessionId, + Acknowledgement = acknowledgement, + AcknowledgedUtc = acknowledgedUtc + }, + GatewayGuidanceJsonContext.Default.GatewayGuidanceRecord); + string temporaryPath = _filePath + ".tmp"; + File.WriteAllText(temporaryPath, text); + File.Move(temporaryPath, _filePath, overwrite: true); + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs index 979b9c26..f48eff8f 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs @@ -15,7 +15,7 @@ internal static class GatewayLauncherScript public const string ControlApplicationId = "Control"; - public const string ControlArguments = "gateway-service start"; + public const string ControlArguments = "gateway-service start --recovery"; public static string Create(string workingDirectory, string activationScriptPath) { diff --git a/src/OpenClaw.Launcher/Gateway/WindowsLogonSession.cs b/src/OpenClaw.Launcher/Gateway/WindowsLogonSession.cs new file mode 100644 index 00000000..aeadfe36 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/WindowsLogonSession.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace OpenClaw.Launcher.Gateway; + +internal static class WindowsLogonSession +{ + private const uint TokenQuery = 0x0008; + private const int TokenStatisticsClass = 10; + + public static string GetCurrentId() + { + if (!OpenProcessToken( + System.Diagnostics.Process.GetCurrentProcess().SafeHandle, + TokenQuery, + out SafeAccessTokenHandle token)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + using (token) + { + int size = Marshal.SizeOf(); + nint buffer = Marshal.AllocHGlobal(size); + try + { + if (!GetTokenInformation( + token, + TokenStatisticsClass, + buffer, + size, + out _)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + TokenStatistics statistics = + Marshal.PtrToStructure(buffer); + return $"{statistics.AuthenticationId.HighPart:X8}:" + + $"{statistics.AuthenticationId.LowPart:X8}"; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct Luid + { + public uint LowPart; + public int HighPart; + } + + [StructLayout(LayoutKind.Sequential)] + private struct TokenStatistics + { + public Luid TokenId; + public Luid AuthenticationId; + public long ExpirationTime; + public uint TokenType; + public uint ImpersonationLevel; + public uint DynamicCharged; + public uint DynamicAvailable; + public uint GroupCount; + public uint PrivilegeCount; + public Luid ModifiedId; + } + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenProcessToken( + SafeProcessHandle processHandle, + uint desiredAccess, + out SafeAccessTokenHandle tokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetTokenInformation( + SafeAccessTokenHandle tokenHandle, + int tokenInformationClass, + nint tokenInformation, + int tokenInformationLength, + out int returnLength); +} diff --git a/src/OpenClaw.Launcher/HostPaths.cs b/src/OpenClaw.Launcher/HostPaths.cs index 57a98614..44d8cea6 100644 --- a/src/OpenClaw.Launcher/HostPaths.cs +++ b/src/OpenClaw.Launcher/HostPaths.cs @@ -152,6 +152,9 @@ private HostPaths(string stateRoot, string? packageFamilyName) /// public string GatewayStatePath => Path.Combine(StateRoot, "gateway.json"); + public string GatewayGuidanceStatePath => + Path.Combine(StateRoot, "gateway-guidance.json"); + /// /// The gateway's launch configuration, kept separate from its recorded /// process so that stopping the gateway never discards the user's port. diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 4031e520..ad3de6a8 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -149,7 +149,8 @@ PlatformNotSupportedException or startup.InstallationLifecycle is null ? null : startup.InstallationLifecycle.CreateRuntime, - readEnvironmentVariable: startup.ReadEnvironmentVariable) + readEnvironmentVariable: startup.ReadEnvironmentVariable, + error: error) .ConfigureAwait(false); } catch (Exception exception) @@ -278,7 +279,10 @@ internal static async Task RunAgentAsync( Func>? probeReadiness = null, Func? getPackageFamilyName = null, Func? readEnvironmentVariable = null, - Func? isInteractive = null) + Func? isInteractive = null, + TextWriter? error = null, + Func? getLogonSessionId = null, + TimeProvider? clock = null) { string applicationDirectory = GetPackagedApplicationDirectory(options); log("Using the OpenClaw application directly from the package."); @@ -297,7 +301,7 @@ await runtime.StartForExecutionAsync(CancellationToken.None) .ConfigureAwait(false); string agentNodePath = runtime.RequireAgentNodePath( GetPackagedNodeArchivePath(options)); - return await runtime.Executor.ExecuteAsync( + int exitCode = await runtime.Executor.ExecuteAsync( record, new Session.SessionExecutionRequest( runtime.RequireStagedHelper(record), @@ -311,6 +315,28 @@ await runtime.StartForExecutionAsync(CancellationToken.None) readEnvironmentVariable ?? Environment.GetEnvironmentVariable) }, CancellationToken.None).ConfigureAwait(false); + Gateway.GatewayController gateway = Gateway.GatewayRuntime + .Create(options, runtime.Paths, runtime, log, clock) + .Controller; + var guidance = new Gateway.AgentGatewayGuidance( + runtime.LifecycleLock, + cancellationToken => runtime.Executor.CheckConfigReadinessAsync( + record, + runtime.RequireStagedHelper(record), + cancellationToken), + cancellationToken => gateway.GetStatusAsync( + runtime.HelperPath, + cancellationToken), + new Gateway.GatewayGuidanceStateStore( + runtime.Paths.GatewayGuidanceStatePath), + getLogonSessionId ?? Gateway.WindowsLogonSession.GetCurrentId, + log, + clock); + await guidance.EvaluateAsync( + exitCode, + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), + error ?? Console.Error).ConfigureAwait(false); + return exitCode; } // output and error are required parameters (not Console defaults) so tests @@ -324,7 +350,9 @@ internal static async Task RunControlAsync( TextWriter error, Session.IInstallationLifecycle? installationLifecycle = null, Func? readEnvironmentVariable = null, - ClawCtlOutputOptions? controlOutputOptions = null) + ClawCtlOutputOptions? controlOutputOptions = null, + Func? getLogonSessionId = null, + TimeProvider? clock = null) { Session.IInstallationLifecycle lifecycle = installationLifecycle ?? Session.InstallationLifecycle.Production; @@ -478,12 +506,27 @@ await Gateway.GatewayRuntime.Create( options, GetSessionRuntime(), cancellationToken), - GatewayStart = async cancellationToken => + GatewayStart = async (recovery, cancellationToken) => { Session.SessionRuntime runtime = GetSessionRuntime(); Gateway.GatewayController controller = Gateway.GatewayRuntime - .Create(options, runtime.Paths, runtime, log) + .Create(options, runtime.Paths, runtime, log, clock) .Controller; + if (!recovery) + { + new Gateway.AgentGatewayGuidance( + runtime.LifecycleLock, + _ => throw new InvalidOperationException( + "Manual acknowledgement must not check config readiness."), + _ => throw new InvalidOperationException( + "Manual acknowledgement must not inspect the gateway."), + new Gateway.GatewayGuidanceStateStore( + runtime.Paths.GatewayGuidanceStatePath), + getLogonSessionId ?? Gateway.WindowsLogonSession.GetCurrentId, + log, + clock) + .AcknowledgeManualStart(); + } // Narration is human guidance, so it is off whenever the // caller asked for a document: stdout carries exactly one diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index e6658d5a..1be30afd 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -262,6 +262,66 @@ await operation.WriteTextNewAsync( } } + public async Task CheckConfigReadinessAsync( + SessionRecord record, + string helperPath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(helperPath); + + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("config-readiness", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionConfigReadinessProtocol.SerializeRequest( + new SessionConfigReadinessRequest { RequestId = requestId }), + cancellationToken).ConfigureAwait(false); + + _log("Checking agent-side OpenClaw config readiness."); + MxcExecutionResult execution = await _backend.ExecuteAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + BuildGuestCommandLine(helperPath, requestPath, "--check-config")), + null, + cancellationToken).ConfigureAwait(false); + + string resultText; + try + { + resultText = await operation.ReadTextAsync(resultPath, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or IOException) + { + throw new SessionException( + "The isolated session did not report config readiness " + + DescribeMissingResult(execution)); + } + + SessionConfigReadinessResult result = + SessionConfigReadinessProtocol.ReadResult(resultText, requestId); + if (result.Error is { Length: > 0 } error) + { + throw new SessionException( + $"The isolated session could not check config readiness: {error}"); + } + + return result; + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + /// /// Installs the packaged Node.js runtime into the agent's profile. /// diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 199bcfc0..99905d98 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -48,6 +48,7 @@ private static async Task Main() ("version JSON survives NativeAOT", VersionJsonIsStructuredAsync), ("Spectre renders clawctl output under NativeAOT", SpectreOutputRenders), ("gateway narration survives NativeAOT", GatewayNarrationRenders), + ("Windows logon identity survives NativeAOT", WindowsLogonIdentityWorks), ("missing application reports diagnostics", MissingApplicationReportsAsync), ("openclaw never parses its arguments", AgentNeverParsesItsArgumentsAsync) ]; @@ -66,6 +67,7 @@ private static async Task Main() await runAsync().ConfigureAwait(false); WriteLine($" ok {name}"); } + catch (Exception exception) { failures++; @@ -84,6 +86,16 @@ private static async Task Main() return 0; } + private static Task WindowsLogonIdentityWorks() + { + string id = WindowsLogonSession.GetCurrentId(); + Assert( + id.Length == 17 && id[8] == ':' && + id.Where(character => character != ':').All(Uri.IsHexDigit), + $"Unexpected Windows logon identity '{id}'."); + return Task.CompletedTask; + } + // The management entrypoint is selected from the native command line. This // is the check the JIT suite cannot make, because there the first argument // is the test host rather than clawctl. diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index dce64be8..9d4ac0bc 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -86,7 +86,7 @@ public void OnlyTheOwnedCommandsAreExposed() CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), GatewayStop = _ => Task.FromResult(0) }); @@ -114,7 +114,7 @@ public async Task GatewayServiceStartInvokesOnlyTheStartHandler() CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => + GatewayStart = (_, _) => { starts++; return Task.FromResult(0); @@ -129,6 +129,36 @@ public async Task GatewayServiceStartInvokesOnlyTheStartHandler() Assert.Equal(1, starts); } + [Theory] + [InlineData("gateway-service start", false)] + [InlineData("gateway-service start --recovery", true)] + public async Task GatewayServiceStartReportsRecoveryProvenance( + string commandLine, + bool expectedRecovery) + { + bool? recovery = null; + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (_, _) => Task.FromResult(0), + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = (value, _) => + { + recovery = value; + return Task.FromResult(0); + }, + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }); + + int exitCode = await root.Parse(commandLine).InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(expectedRecovery, recovery); + } + [Theory] [InlineData("setup --json")] [InlineData("--json status")] @@ -148,7 +178,7 @@ public async Task JsonIsAvailableToEveryNonInteractiveCommand(string commandLine CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), GatewayStop = _ => Task.FromResult(0) }, outputOptions); @@ -192,7 +222,7 @@ public async Task NoColorIsAvailableToEveryCommand(string commandLine) CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), GatewayStop = _ => Task.FromResult(0) }, outputOptions); @@ -236,7 +266,7 @@ public async Task SetupFreshPassesTheExplicitDestructiveAuthorization() CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), GatewayStop = _ => Task.FromResult(0) }); @@ -262,7 +292,7 @@ public async Task SetupFreshForcePassesTheExplicitRecoveryOverride() CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), GatewayStop = _ => Task.FromResult(0) }); diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs index 0baf097b..b9b2bc63 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs @@ -13,7 +13,7 @@ public sealed class ClawCtlHelpTests CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), GatewayStop = _ => Task.FromResult(0) }; @@ -50,6 +50,16 @@ public void HiddenCommandsAreNotDescribed() Assert.DoesNotContain(model.Commands, c => c.Term == "secret"); } + [Fact] + public void GatewayRecoveryMarkerIsHiddenFromHelp() + { + ClawCtlHelpModel model = Describe("gateway-service start"); + + Assert.DoesNotContain( + model.Options, + option => option.Term.Contains("--recovery", StringComparison.Ordinal)); + } + // RootCommand names itself after the entry assembly, so an unguarded // renderer reports the test host here and the scenario driver in the // NativeAOT suite. Usage must always name the shipped command. diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 0ff7212a..1d367cbb 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -73,7 +73,7 @@ public void CompletionSuggestsCommandsWithoutResolvingNode() CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), - GatewayStart = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), GatewayStatus = _ => Task.FromResult(0), GatewayStop = _ => Task.FromResult(0) }); diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs new file mode 100644 index 00000000..89849d5c --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs @@ -0,0 +1,201 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Tests.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class AgentGatewayGuidanceTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + private readonly List _log = []; + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + private AgentGatewayGuidance Create( + SessionConfigReadinessState readinessState, + GatewayState gatewayState, + out GatewayGuidanceStateStore store, + Action? readinessCalled = null, + Action? statusCalled = null) + { + store = new GatewayGuidanceStateStore( + Path.Combine(_root, "gateway-guidance.json")); + return new AgentGatewayGuidance( + new AlwaysFreeLock(), + _ => + { + readinessCalled?.Invoke(); + return Task.FromResult(new SessionConfigReadinessResult + { + RequestId = "r1", + State = readinessState, + Reason = readinessState switch + { + SessionConfigReadinessState.Absent => + SessionConfigReadinessReason.ConfigFileMissing, + SessionConfigReadinessState.StartupEligible => + SessionConfigReadinessReason.GatewayModeLocal, + _ => SessionConfigReadinessReason.GatewayModeMissing + } + }); + }, + _ => + { + statusCalled?.Invoke(); + return Task.FromResult(new GatewayStatusReport( + gatewayState, + null, + gatewayState.ToString())); + }, + store, + () => "logon-a", + _log.Add, + new FixedTimeProvider( + new DateTimeOffset(2026, 9, 18, 18, 0, 0, TimeSpan.Zero))); + } + + [Theory] + [InlineData(SessionConfigReadinessState.Absent)] + [InlineData(SessionConfigReadinessState.NotReady)] + public async Task ConfigThatIsNotEligibleNeverChecksOrHints( + SessionConfigReadinessState readiness) + { + int statusCalls = 0; + AgentGatewayGuidance guidance = Create( + readiness, + GatewayState.NotStarted, + out _, + statusCalled: () => statusCalls++); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Equal(0, statusCalls); + Assert.Equal(string.Empty, error.ToString()); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public async Task EligibleStoppedGatewayHintsAfterEverySuccessfulInteractiveCall( + int stateValue) + { + GatewayState state = (GatewayState)stateValue; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + state, + out _); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Equal( + 2, + error.ToString().Split(AgentGatewayGuidance.Hint).Length - 1); + } + + [Theory] + [InlineData(false, 0)] + [InlineData(true, 1)] + [InlineData(true, 130)] + public async Task HintRequiresInteractiveSuccessfulOpenClaw( + bool interactive, + int exitCode) + { + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _); + var error = new StringWriter(); + + await guidance.EvaluateAsync(exitCode, interactive, error); + + Assert.Equal(string.Empty, error.ToString()); + } + + [Theory] + [InlineData(5)] + [InlineData(3)] + [InlineData(4)] + public async Task UncertainGatewayNeverHintsOrAcknowledges(int stateValue) + { + GatewayState state = (GatewayState)stateValue; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + state, + out GatewayGuidanceStateStore store); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Equal(string.Empty, error.ToString()); + Assert.False(store.IsAcknowledged("logon-a")); + } + + [Fact] + public async Task ObservedRunningAcknowledgesAndSkipsEveryLaterProbe() + { + int readinessCalls = 0; + int statusCalls = 0; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.Running, + out GatewayGuidanceStateStore store, + () => readinessCalls++, + () => statusCalls++); + + await guidance.EvaluateAsync(1, interactive: false, TextWriter.Null); + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.True(store.IsAcknowledged("logon-a")); + Assert.Equal(1, readinessCalls); + Assert.Equal(1, statusCalls); + } + + [Fact] + public async Task ManualStartAcknowledgementSkipsEveryProbe() + { + int readinessCalls = 0; + int statusCalls = 0; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore store, + () => readinessCalls++, + () => statusCalls++); + + guidance.AcknowledgeManualStart(); + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.True(store.IsAcknowledged("logon-a")); + Assert.Equal(0, readinessCalls); + Assert.Equal(0, statusCalls); + } + + [Fact] + public async Task AdvisoryFailureIsLoggedWithoutOutput() + { + var store = new GatewayGuidanceStateStore( + Path.Combine(_root, "gateway-guidance.json")); + var guidance = new AgentGatewayGuidance( + new AlwaysFreeLock(), + _ => throw new SessionLaunchException("bad helper result"), + _ => throw new InvalidOperationException("must not inspect"), + store, + () => "logon-a", + _log.Add); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Equal(string.Empty, error.ToString()); + Assert.Contains( + _log, + line => line.Contains("bad helper result", StringComparison.Ordinal)); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayGuidanceStateStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayGuidanceStateStoreTests.cs new file mode 100644 index 00000000..3d573565 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayGuidanceStateStoreTests.cs @@ -0,0 +1,51 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class GatewayGuidanceStateStoreTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + private string StatePath => Path.Combine(_root, "gateway-guidance.json"); + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + [Fact] + public void MissingMalformedAndOtherLogonStateDoNotAcknowledge() + { + var store = new GatewayGuidanceStateStore(StatePath); + Assert.False(store.IsAcknowledged("logon-a")); + + File.WriteAllText(StatePath, "{ invalid"); + Assert.False(store.IsAcknowledged("logon-a")); + + store.Write( + "logon-b", + GatewayGuidanceAcknowledgement.ManualStartInvoked, + DateTimeOffset.UtcNow); + Assert.False(store.IsAcknowledged("logon-a")); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void CurrentLogonAcknowledgementRoundTripsAtomically( + int acknowledgementValue) + { + GatewayGuidanceAcknowledgement acknowledgement = + (GatewayGuidanceAcknowledgement)acknowledgementValue; + var store = new GatewayGuidanceStateStore(StatePath); + + store.Write( + "logon-a", + acknowledgement, + new DateTimeOffset(2026, 9, 18, 18, 0, 0, TimeSpan.Zero)); + + Assert.True(store.IsAcknowledged("logon-a")); + Assert.False(File.Exists(StatePath + ".tmp")); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs index bbecf7b2..8c7f465a 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs @@ -110,7 +110,10 @@ public async Task TheGeneratedLauncherActivatesTheOwningPackageControlApplicatio Assert.Contains("chcp 65001", launcher, StringComparison.OrdinalIgnoreCase); Assert.Contains("OpenClaw.Gateway_test!Control", activation, StringComparison.Ordinal); Assert.Contains("ActivateApplication", activation, StringComparison.Ordinal); - Assert.Contains("gateway-service start", activation, StringComparison.Ordinal); + Assert.Contains( + "gateway-service start --recovery", + activation, + StringComparison.Ordinal); Assert.DoesNotContain("WindowsApps", launcher + activation, StringComparison.OrdinalIgnoreCase); } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index c47c42b4..daaadcab 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -48,6 +48,26 @@ public async Task AgentForwardsEveryArgumentIntoTheSession() })); return Task.FromResult(0); }; + _lastSessionBackend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "config-readiness-*.json").Single(); + SessionConfigReadinessRequest request = + SessionConfigReadinessProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionConfigReadinessProtocol.SerializeResult( + new SessionConfigReadinessResult + { + RequestId = request.RequestId, + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + var error = new StringWriter(); int exitCode = await Program.RunAgentAsync( new HostOptions( @@ -58,10 +78,107 @@ public async Task AgentForwardsEveryArgumentIntoTheSession() _ => runtime, probeReadiness: SupportedHost, getPackageFamilyName: () => runtime.Paths.PackageFamilyName, - readEnvironmentVariable: _ => null); + readEnvironmentVariable: _ => null, + isInteractive: () => true, + error: error, + getLogonSessionId: () => "logon-a"); Assert.Equal(arguments, forwarded); Assert.Equal(7, exitCode); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public async Task SuccessfulInteractiveAgentHintsWhenEligibleGatewayWasNeverStarted() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions setupOptions = CreateSetupOptions(applicationDirectory); + SessionRuntime runtime = CreateSessionRuntime(); + int setupExitCode = await Program.RunControlAsync( + setupOptions, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }); + Assert.Equal(0, setupExitCode); + + _lastSessionBackend!.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "launch-*.json").Single(); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + })); + return Task.FromResult(0); + }; + _lastSessionBackend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "config-readiness-*.json").Single(); + SessionConfigReadinessRequest request = + SessionConfigReadinessProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionConfigReadinessProtocol.SerializeResult( + new SessionConfigReadinessResult + { + RequestId = request.RequestId, + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + var error = new StringWriter(); + + int exitCode = await Program.RunAgentAsync( + new HostOptions( + applicationDirectory, + setupOptions.PackagedNodeArchivePath, + ["status"]), + _ => { }, + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: _ => null, + isInteractive: () => true, + error: error, + getLogonSessionId: () => "logon-a"); + + Assert.Equal(0, exitCode); + Assert.Contains(AgentGatewayGuidance.Hint, error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ManualGatewayStartAcknowledgesBeforeAStartFailure() + { + SessionRuntime runtime = await SetUpSessionAsync().ConfigureAwait(true); + ((FakeMxcSessionClient)runtime.Backend).ExecuteFailure = + new SessionException("gateway start failed"); + + await Assert.ThrowsAsync( + () => Program.RunControlAsync( + CreateSetupOptions(Path.Combine(_testDirectory, "app")), + ["gateway-service", "start"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime), + getLogonSessionId: () => "logon-manual")); + + var store = new GatewayGuidanceStateStore( + runtime.Paths.GatewayGuidanceStatePath); + Assert.True(store.IsAcknowledged("logon-manual")); } [Fact] diff --git a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs index 3823f555..e2fcd066 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs @@ -44,6 +44,10 @@ public void DerivedPathsSitUnderTheStateRoot() Assert.StartsWith(paths.StateRoot, paths.LogPath, StringComparison.Ordinal); Assert.StartsWith(paths.StateRoot, paths.SessionStatePath, StringComparison.Ordinal); + Assert.StartsWith( + paths.StateRoot, + paths.GatewayGuidanceStatePath, + StringComparison.Ordinal); Assert.NotEqual(paths.LogPath, paths.SessionStatePath); } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 92d34612..b7413f2d 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -529,6 +529,42 @@ public async Task ConcurrentInvocationsUseDistinctRequestFiles() Assert.Equal(2, seen.Distinct(StringComparer.Ordinal).Count()); } + [Fact] + public async Task ConfigReadinessUsesTheDedicatedHelperModeAndCleansUp() + { + _backend.ExecuteBehavior = request => + { + string requestPath = Directory.GetFiles( + Workspace, + "config-readiness-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + SessionConfigReadinessRequest readiness = + SessionConfigReadinessProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionConfigReadinessProtocol.SerializeResult( + new SessionConfigReadinessResult + { + RequestId = readiness.RequestId, + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + + SessionConfigReadinessResult result = + await Create(createRequestId: () => "readiness1") + .CheckConfigReadinessAsync( + Record(), + @"C:\Package\session-host\openclaw-session-host.exe", + CancellationToken.None); + + Assert.Equal(SessionConfigReadinessState.StartupEligible, result.State); + Assert.Contains("--check-config", _backend.ExecutedCommandLines.Single(), StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + [Fact] public async Task HelperFailureIsDistinguishedFromAnApplicationExit() { From 8f5cf8e3fce696dfb0e46bfef14d60b0af81f0fe Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 11:37:10 -0700 Subject: [PATCH 3/7] style: add crab-branded gateway hint Render the post-OpenClaw gateway suggestion with Spectre.Console, using warning and accent colors while preserving plain and redirected output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cc6aa7d-1710-48a9-804d-3e5f496d8a34 --- src/OpenClaw.Launcher/ClawCtlConsole.cs | 21 ++++++++++ .../Gateway/AgentGatewayGuidance.cs | 9 ++-- src/OpenClaw.Launcher/Program.cs | 33 ++++++++++++--- .../SmokeProgram.cs | 16 ++++++++ .../ClawCtlConsoleTests.cs | 41 +++++++++++++++++++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 12 +++++- 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index ca8e877f..5972d7ef 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -77,6 +77,27 @@ internal static void WriteResult( } } + internal static void WriteGatewayHint( + TextWriter output, + bool useColor = false, + bool? useUnicode = null) + { + ArgumentNullException.ThrowIfNull(output); + + bool unicode = useUnicode ?? SupportsUnicode(output); + var paragraph = new Paragraph(); + if (unicode) + { + paragraph.Append($"{IdentityMark} ", WarningStyle); + } + + paragraph.Append("Hint:", WarningStyle); + paragraph.Append(" The OpenClaw gateway is not running. Run "); + paragraph.Append("clawctl gateway-service start", AccentStyle); + paragraph.Append(" to start it."); + Render(output, paragraph, useColor, unicode); + } + internal static void WriteVersion(TextWriter output, bool useColor = false) { ArgumentNullException.ThrowIfNull(output); diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs index 4939dfb9..9926acdb 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -8,7 +8,7 @@ internal sealed class AgentGatewayGuidance { internal const string Hint = "Hint: The OpenClaw gateway is not running. " + - "Run 'clawctl gateway-service start' to start it."; + "Run clawctl gateway-service start to start it."; internal static readonly TimeSpan AdvisoryTimeout = TimeSpan.FromSeconds(5); private readonly ISessionLock _lifecycleLock; @@ -18,6 +18,7 @@ internal sealed class AgentGatewayGuidance private readonly Func _getLogonSessionId; private readonly Action _log; private readonly TimeProvider _clock; + private readonly Action _writeHint; public AgentGatewayGuidance( ISessionLock lifecycleLock, @@ -26,7 +27,8 @@ public AgentGatewayGuidance( GatewayGuidanceStateStore state, Func getLogonSessionId, Action log, - TimeProvider? clock = null) + TimeProvider? clock = null, + Action? writeHint = null) { _lifecycleLock = lifecycleLock; _checkReadiness = checkReadiness; @@ -35,6 +37,7 @@ public AgentGatewayGuidance( _getLogonSessionId = getLogonSessionId; _log = log; _clock = clock ?? TimeProvider.System; + _writeHint = writeHint ?? (writer => writer.WriteLine(Hint)); } public async Task EvaluateAsync( @@ -76,7 +79,7 @@ public async Task EvaluateAsync( interactive && openClawExitCode == 0) { - await error.WriteLineAsync(Hint).ConfigureAwait(false); + _writeHint(error); } } catch (Exception exception) when ( diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index ad3de6a8..e1af6517 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -301,6 +301,11 @@ await runtime.StartForExecutionAsync(CancellationToken.None) .ConfigureAwait(false); string agentNodePath = runtime.RequireAgentNodePath( GetPackagedNodeArchivePath(options)); + bool interactive = + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(); + TextWriter errorWriter = error ?? Console.Error; + Func environmentReader = + readEnvironmentVariable ?? Environment.GetEnvironmentVariable; int exitCode = await runtime.Executor.ExecuteAsync( record, new Session.SessionExecutionRequest( @@ -311,8 +316,8 @@ await runtime.StartForExecutionAsync(CancellationToken.None) record.WorkspacePath!) { AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( - (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), - readEnvironmentVariable ?? Environment.GetEnvironmentVariable) + interactive, + environmentReader) }, CancellationToken.None).ConfigureAwait(false); Gateway.GatewayController gateway = Gateway.GatewayRuntime @@ -331,11 +336,29 @@ await runtime.StartForExecutionAsync(CancellationToken.None) runtime.Paths.GatewayGuidanceStatePath), getLogonSessionId ?? Gateway.WindowsLogonSession.GetCurrentId, log, - clock); + clock, + target => + { + IDisposable? restore = null; + bool useColor = ClawCtlColorPolicy.PrepareOutput( + noColor: false, + json: false, + ReferenceEquals(target, Console.Error), + interactive, + environmentReader, + () => WindowsHostConsole.Instance.TryEnableVirtualTerminalProcessing( + target, + log, + out restore)); + using (restore) + { + ClawCtlConsole.WriteGatewayHint(target, useColor); + } + }); await guidance.EvaluateAsync( exitCode, - (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), - error ?? Console.Error).ConfigureAwait(false); + interactive, + errorWriter).ConfigureAwait(false); return exitCode; } diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 99905d98..b4c63556 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -353,6 +353,22 @@ private static Task SpectreOutputRenders() Assert( failure.ToString().Contains("no package identity.", StringComparison.Ordinal), "The note callout did not render its message."); + + using var hint = new StringWriter(); + ClawCtlConsole.WriteGatewayHint( + hint, + useColor: true, + useUnicode: true); + string visibleHint = StripAnsi(hint.ToString()); + Assert( + visibleHint.Contains("\U0001f980 Hint:", StringComparison.Ordinal) && + visibleHint.Contains( + "Run clawctl gateway-service start", + StringComparison.Ordinal) && + !visibleHint.Contains( + "'clawctl gateway-service start'", + StringComparison.Ordinal), + "The colored gateway hint lost its branding or command formatting."); return Task.CompletedTask; } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 1c802229..638bc310 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -16,6 +16,47 @@ public void HeadingUsesCrabIdentityWhenUnicodeIsAvailable( Assert.Equal(expected, ClawCtlConsole.FormatHeading("status", useUnicode)); } + [Theory] + [InlineData(true, "\U0001f980 Hint:")] + [InlineData(false, "Hint:")] + public void GatewayHintUsesAvailableCrabBranding( + bool useUnicode, + string expectedPrefix) + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteGatewayHint(output, useUnicode: useUnicode); + + string rendered = output.ToString(); + Assert.StartsWith(expectedPrefix, rendered, StringComparison.Ordinal); + Assert.Contains( + "Run clawctl gateway-service start to start it.", + rendered, + StringComparison.Ordinal); + Assert.DoesNotContain( + "'clawctl gateway-service start'", + rendered, + StringComparison.Ordinal); + } + + [Fact] + public void GatewayHintColorChangesOnlyTerminalFormatting() + { + using var plain = new StringWriter(); + using var colored = new StringWriter(); + + ClawCtlConsole.WriteGatewayHint(plain, useUnicode: true); + ClawCtlConsole.WriteGatewayHint( + colored, + useColor: true, + useUnicode: true); + + Assert.Contains("\u001b[", colored.ToString(), StringComparison.Ordinal); + Assert.Equal( + plain.ToString(), + Regex.Replace(colored.ToString(), "\u001b\\[[0-9;]*m", string.Empty)); + } + [Fact] public void WriteSetupResultShowsReadyStateAndNextAction() { diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index daaadcab..a7a806b6 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -150,13 +150,21 @@ public async Task SuccessfulInteractiveAgentHintsWhenEligibleGatewayWasNeverStar _ => runtime, probeReadiness: SupportedHost, getPackageFamilyName: () => runtime.Paths.PackageFamilyName, - readEnvironmentVariable: _ => null, + readEnvironmentVariable: name => + name == "FORCE_COLOR" ? "1" : null, isInteractive: () => true, error: error, getLogonSessionId: () => "logon-a"); Assert.Equal(0, exitCode); - Assert.Contains(AgentGatewayGuidance.Hint, error.ToString(), StringComparison.Ordinal); + Assert.Contains("\u001b[", error.ToString(), StringComparison.Ordinal); + Assert.Contains( + AgentGatewayGuidance.Hint, + System.Text.RegularExpressions.Regex.Replace( + error.ToString(), + "\u001b\\[[0-9;]*m", + string.Empty), + StringComparison.Ordinal); } [Fact] From f9f27ec07567155f23c4ab12b9d0c84601e622a3 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 13:04:08 -0700 Subject: [PATCH 4/7] feat: report gateway config readiness Add file-only config readiness to both clawctl status surfaces when the gateway is not running, gate start guidance on startup eligibility, and preserve the additive state in JSON. Fix post-OpenClaw crab and color rendering through app-alias stderr proxies while retaining native VT setup and plain redirected output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cc6aa7d-1710-48a9-804d-3e5f496d8a34 --- README.md | 14 +- docs/clawctl-output-style.md | 12 ++ docs/mxc-compatibility-evidence.md | 16 ++ src/OpenClaw.Launcher/ClawCtlColorPolicy.cs | 24 +++ src/OpenClaw.Launcher/ClawCtlConsole.cs | 58 ++++- src/OpenClaw.Launcher/ClawCtlJson.cs | 57 ++++- src/OpenClaw.Launcher/ClawCtlResults.cs | 9 +- src/OpenClaw.Launcher/Program.cs | 67 +++++- .../Session/AgentConfigReadinessStatus.cs | 77 +++++++ .../SmokeProgram.cs | 21 ++ .../ClawCtlColorPolicyTests.cs | 51 +++++ .../ClawCtlReadinessOutputTests.cs | 131 ++++++++++++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 202 +++++++++++++++++- 13 files changed, 715 insertions(+), 24 deletions(-) create mode 100644 src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs create mode 100644 tests/OpenClaw.Launcher.Tests/ClawCtlReadinessOutputTests.cs diff --git a/README.md b/README.md index 7a868219..55b545ce 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,9 @@ gateway has never started or has stopped, it suggests `clawctl gateway-service start` on standard error. It does not suggest a second start when gateway status is starting, unhealthy, or unknown. The check stops for the current Windows logon after that start command is invoked or a running -gateway is observed; a new Windows logon enables it again. +gateway is observed; a new Windows logon enables it again. On an interactive +terminal the hint uses the crab identity and the same warning/accent palette as +`clawctl`; redirected and explicitly color-disabled output remains plain. ### `clawctl` @@ -90,12 +92,12 @@ gateway is observed; a new Windows logon enables it again. |---|---| | `clawctl setup` | Confirm packaged `app\openclaw.mjs` exists, provision or reuse the owned isolated session, and install the bundled Node.js runtime in the agent profile. It also configures gateway sign-in recovery without starting a gateway. On a machine that cannot host a session it fails with the Windows requirement described under [Requirements](#requirements). | | `clawctl setup --fresh [--force]` | Remove this installation's owned session and package-local state, then run setup again. Without `--force`, incomplete external cleanup stops before local state is erased. `--force` is valid only with `--fresh`; it preserves an explicit warning when cleanup of owned external resources cannot be confirmed, but still stops if bounded local deletion fails. | -| `clawctl status` | Report the recorded isolated session, installed Node.js runtime, gateway, and sign-in recovery state without provisioning or replacing the session. It asks the backend to start the recorded provision as its status probe, so it is not a passive diagnostic. Use `clawctl gateway-service status` to inspect the gateway alone. | +| `clawctl status` | Report the recorded isolated session, installed Node.js runtime, gateway, sign-in recovery, and file-only config readiness when the gateway is not running. It asks the backend to start the recorded provision as its status probe, so it is not a passive diagnostic, but it does not provision a replacement or start the gateway. Use `clawctl gateway-service status` to inspect the gateway alone. | | `clawctl teardown --force` | Confirm deletion, then stop and deprovision the owned session and remove its data and setup state. The MSIX remains installed. | | `clawctl pwsh` | Open an interactive PowerShell session inside the agent session. | | `clawctl collect-logs [--output ]` | Create a redacted host-and-agent diagnostics ZIP. | | `clawctl gateway-service start` | Start the OpenClaw gateway in the isolated session and wait for it to listen. Requires setup. | -| `clawctl gateway-service status` | Inspect the gateway without starting it. | +| `clawctl gateway-service status` | Inspect the gateway without starting it. When the gateway is not running, it may start/probe only the already-recorded isolated session to report file-only config readiness; it never provisions a replacement or starts the gateway. | | `clawctl gateway-service stop` | Stop the gateway while retaining the session and its data. | | `clawctl --version` | Print the packaged launcher version. | @@ -113,6 +115,12 @@ on standard output. Human diagnostics remain on standard error, and command exit codes do not change. `clawctl pwsh --json` is rejected because the command hands the terminal to an interactive shell. +When the gateway is not confirmed running, status JSON includes an optional +`gateway.readiness` object with `state`, stable `reason`, and failure `detail` +where applicable. The readiness states are `absent`, `not-ready`, +`startup-eligible`, `unavailable`, and `unknown`. A running gateway omits this +object and incurs no config-readiness probe. + Interactive terminals use color for headings and status marks. `--no-color`, the `NO_COLOR` environment variable, redirected output, and CI disable color; `FORCE_COLOR` enables it for redirected output or CI unless color was diff --git a/docs/clawctl-output-style.md b/docs/clawctl-output-style.md index e75a2397..6e4ddf79 100644 --- a/docs/clawctl-output-style.md +++ b/docs/clawctl-output-style.md @@ -21,6 +21,10 @@ should describe that environment in terms an operator can act on, while presents onboarding as mandatory nor launches it automatically. - End neutral states with the command that moves the user forward, such as `Run: clawctl setup`. +- When a gateway is not running, show the file-only agent config readiness + immediately after the gateway row. Suggest `clawctl gateway-service start` + only when readiness is `startup eligible`; absent, incomplete, unavailable, + and unknown config states need diagnosis or configuration instead. - Keep a successful `clawctl pwsh` launch silent so the user reaches the shell prompt directly. Its help text explains which commands are available inside the session. @@ -32,6 +36,14 @@ other non-actionable implementation details out of human output. Preserve them in structured output and diagnostics when they are useful for automation or support. +The post-OpenClaw gateway hint is a package-owned status surface even though it +is written after upstream output. On an interactive invocation it uses the crab +mark, warning-colored `Hint:`, and an accent-colored unquoted command. Resolve +foreground eligibility separately from the selected stderr handle: an +app-execution-alias proxy can carry ANSI without supporting console-mode +changes, while a native console handle still requires VT setup. Whole-command +redirection, CI, and `NO_COLOR` remain plain; `FORCE_COLOR` remains authoritative. + ## Failures and diagnostics Expected refusals should state the condition and the next usable action without diff --git a/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md index c39e26c1..2c859d27 100644 --- a/docs/mxc-compatibility-evidence.md +++ b/docs/mxc-compatibility-evidence.md @@ -119,6 +119,15 @@ owned by that process or one of its descendants. A missing record is process is `Unhealthy`; and failed inspection is `Unknown` to avoid starting a second gateway beside one that could still be healthy. +Both `clawctl status` and `clawctl gateway-service status` add the helper's +file-only config readiness whenever the managed gateway is not confirmed +running. The gateway-only command first observes gateway state, then starts +only an already-recorded session when necessary to reach the helper; it never +provisions a replacement or starts the gateway. Human output reports +`not configured`, `not ready`, `startup eligible`, `unavailable`, or `unknown`. +Structured output carries the same state under `gateway.readiness` with the +stable reason. A running gateway omits readiness and avoids the helper call. + After an `openclaw` child exits, the launcher uses the helper's file-only readiness result before checking the managed gateway record and liveness. A successful interactive call gets a start suggestion only for `NotStarted` or @@ -134,6 +143,13 @@ sign-in recovery command carries a hidden provenance marker and does not count as a manual acknowledgement; a later OpenClaw invocation that observes its running gateway does. +Agent entrypoint startup captures and restores console state and initializes +UTF-8 just as the control entrypoint does. Postflight rendering treats +foreground interactivity and stderr's native console capability separately: +the app-alias stderr proxy can receive ANSI and the crab glyph without +supporting `GetConsoleMode`, while a native console still requires successful +VT enablement. + ## Diagnostics and safe collection `clawctl collect-logs [--output ]` creates a ZIP at the supplied path or diff --git a/src/OpenClaw.Launcher/ClawCtlColorPolicy.cs b/src/OpenClaw.Launcher/ClawCtlColorPolicy.cs index 176b7888..1ff7331b 100644 --- a/src/OpenClaw.Launcher/ClawCtlColorPolicy.cs +++ b/src/OpenClaw.Launcher/ClawCtlColorPolicy.cs @@ -25,6 +25,30 @@ internal static bool PrepareOutput( enableVirtualTerminalProcessing()); } + internal static bool PrepareForegroundOutput( + bool noColor, + bool json, + bool outputIsProcessConsoleWriter, + bool invocationIsInteractive, + bool selectedStreamIsInteractive, + Func readEnvironmentVariable, + Func enableVirtualTerminalProcessing) + { + ArgumentNullException.ThrowIfNull(enableVirtualTerminalProcessing); + + bool useColor = ShouldUseColor( + noColor, + json, + outputIsProcessConsoleWriter, + invocationIsInteractive, + readEnvironmentVariable); + + return useColor && + (!outputIsProcessConsoleWriter || + !selectedStreamIsInteractive || + enableVirtualTerminalProcessing()); + } + internal static bool ShouldUseColor( bool noColor, bool json, diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 5972d7ef..a0d70e44 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -1,4 +1,5 @@ using System.Globalization; +using OpenClaw.SessionProtocol; using Spectre.Console; using Spectre.Console.Rendering; @@ -412,6 +413,8 @@ Session.SessionAvailability.BackendError or view.Detail(result.Gateway.Detail); } + WriteReadiness(view, result.Readiness); + view.Row("Recovery", DescribeRecovery(view, result.Recovery)); if (!string.IsNullOrWhiteSpace(result.Recovery.Detail)) { @@ -535,6 +538,8 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result) view.Detail(explanation); } + WriteReadiness(view, result.Readiness); + // Reaching the Control UI needs the shared token, and the command that // reveals it belongs to OpenClaw rather than to this package. if (result.State == Gateway.GatewayState.Running && result.Port is not null) @@ -544,13 +549,64 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result) } if (result.State == Gateway.GatewayState.NotStarted && - result.Action == "status") + result.Action == "status" && + result.Readiness?.State == + Session.AgentConfigReadinessState.StartupEligible) { view.Blank(); view.Command("Run", "clawctl gateway-service start"); } } + private static void WriteReadiness( + ResultView view, + Session.AgentConfigReadinessStatus? readiness) + { + if (readiness is null) + { + return; + } + + view.Row("Readiness", readiness.State switch + { + Session.AgentConfigReadinessState.Absent => + Status(view, StatusKind.Neutral, "not configured"), + Session.AgentConfigReadinessState.NotReady => + Status(view, StatusKind.Warning, "not ready"), + Session.AgentConfigReadinessState.StartupEligible => + Status(view, StatusKind.Success, "startup eligible"), + Session.AgentConfigReadinessState.Unavailable => + Status(view, StatusKind.Neutral, "unavailable"), + _ => Status( + view, + readiness.ProbeFailed ? StatusKind.Failure : StatusKind.Warning, + "unknown") + }); + + string? detail = readiness.Reason switch + { + SessionConfigReadinessReason.ConfigFileMissing => + "the default config file is missing", + SessionConfigReadinessReason.ConfigFileUnreadable => + "the default config file could not be read", + SessionConfigReadinessReason.ConfigFileInvalid => + "the default config file is invalid", + SessionConfigReadinessReason.GatewayMissing => + "the config has no gateway object", + SessionConfigReadinessReason.GatewayModeMissing => + "gateway.mode is missing", + SessionConfigReadinessReason.GatewayModeNotLocal => + "gateway.mode is not local", + SessionConfigReadinessReason.GatewayModeLocal => + "gateway.mode is local", + _ => readiness.Detail + }; + if (!string.IsNullOrWhiteSpace(detail)) + { + view.Detail(detail); + } + } + private static Paragraph DescribeSession( ResultView view, Session.SessionAvailability availability) => diff --git a/src/OpenClaw.Launcher/ClawCtlJson.cs b/src/OpenClaw.Launcher/ClawCtlJson.cs index 6ed01c9a..93df5736 100644 --- a/src/OpenClaw.Launcher/ClawCtlJson.cs +++ b/src/OpenClaw.Launcher/ClawCtlJson.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher; @@ -37,7 +38,13 @@ internal sealed record ClawCtlJsonRuntime(string NodeVersion); internal sealed record ClawCtlJsonGateway( string State, int? Port = null, - string? Url = null); + string? Url = null, + ClawCtlJsonReadiness? Readiness = null); + +internal sealed record ClawCtlJsonReadiness( + string State, + string? Reason = null, + string? Detail = null); internal sealed record ClawCtlJsonRecovery(string State); @@ -163,7 +170,8 @@ private static ClawCtlJsonDocument FromStatus(StatusCommandResult result) => result.NodeVersion), Gateway: new ClawCtlJsonGateway( DescribeGateway(result.Gateway.State), - GatewayAddress.ResolvePort(result.Gateway.Record)), + GatewayAddress.ResolvePort(result.Gateway.Record), + Readiness: FromReadiness(result.Readiness)), Recovery: new ClawCtlJsonRecovery(DescribeRecovery(result.Recovery.State)), Error: result.ExitCode == 0 ? null @@ -207,11 +215,19 @@ private static ClawCtlJsonDocument FromGateway(GatewayCommandResult result) => Gateway: new ClawCtlJsonGateway( DescribeGateway(result.State), result.Port, - result.Url)) + result.Url, + FromReadiness(result.Readiness))) : new ClawCtlJsonDocument( false, SchemaVersion, result.Command, + Gateway: result.Readiness is null + ? null + : new ClawCtlJsonGateway( + DescribeGateway(result.State), + result.Port, + result.Url, + FromReadiness(result.Readiness)), Error: new ClawCtlJsonError( "cli_error", NormalizeMessage(result.Detail ?? result.Message))); @@ -247,6 +263,41 @@ private static string DescribeGateway(GatewayState state) => _ => "unknown" }; + private static ClawCtlJsonReadiness? FromReadiness( + AgentConfigReadinessStatus? readiness) => + readiness is null + ? null + : new ClawCtlJsonReadiness( + readiness.State switch + { + AgentConfigReadinessState.Absent => "absent", + AgentConfigReadinessState.NotReady => "not-ready", + AgentConfigReadinessState.StartupEligible => "startup-eligible", + AgentConfigReadinessState.Unavailable => "unavailable", + _ => "unknown" + }, + readiness.Reason is null + ? null + : readiness.Reason.Value switch + { + SessionConfigReadinessReason.ConfigFileMissing => + "config-file-missing", + SessionConfigReadinessReason.ConfigFileUnreadable => + "config-file-unreadable", + SessionConfigReadinessReason.ConfigFileInvalid => + "config-file-invalid", + SessionConfigReadinessReason.GatewayMissing => + "gateway-missing", + SessionConfigReadinessReason.GatewayModeMissing => + "gateway-mode-missing", + SessionConfigReadinessReason.GatewayModeNotLocal => + "gateway-mode-not-local", + SessionConfigReadinessReason.GatewayModeLocal => + "gateway-mode-local", + _ => "unknown" + }, + NormalizeOptionalMessage(readiness.Detail)); + private static string DescribeRecovery(GatewayPersistenceState state) => state switch { diff --git a/src/OpenClaw.Launcher/ClawCtlResults.cs b/src/OpenClaw.Launcher/ClawCtlResults.cs index 1f13ecbe..ba970296 100644 --- a/src/OpenClaw.Launcher/ClawCtlResults.cs +++ b/src/OpenClaw.Launcher/ClawCtlResults.cs @@ -40,7 +40,8 @@ internal sealed record StatusCommandResult( SessionStatus Session, GatewayStatusReport Gateway, GatewayPersistenceStatus Recovery, - string? NodeVersion) : IClawCtlResult + string? NodeVersion, + AgentConfigReadinessStatus? Readiness = null) : IClawCtlResult { public string Command => "status"; @@ -51,7 +52,8 @@ SessionAvailability.BackendError or SessionAvailability.Unusable || Gateway.State is GatewayState.Unhealthy or GatewayState.Unknown || Recovery.State is GatewayPersistenceState.ActionRequired or - GatewayPersistenceState.Unknown + GatewayPersistenceState.Unknown || + Readiness?.ProbeFailed == true ? 1 : 0; } @@ -79,7 +81,8 @@ internal sealed record GatewayCommandResult( string? Detail, int ExitCode, int? Port = null, - string? Url = null) : IClawCtlResult + string? Url = null, + AgentConfigReadinessStatus? Readiness = null) : IClawCtlResult { public string Command => $"gateway-service {Action}"; } diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index e1af6517..4ec8fb34 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -123,8 +123,7 @@ PlatformNotSupportedException or try { WriteDiagnostic($"Host started through the {commandName} entrypoint."); - if (startup.Entrypoint == HostEntrypoint.Control && - ReferenceEquals(startup.Output, Console.Out) && + if (ReferenceEquals(startup.Output, Console.Out) && WindowsHostConsole.Instance.IsInteractive) { consoleRestore = WindowsHostConsole.Instance.Capture(WriteDiagnostic); @@ -282,7 +281,10 @@ internal static async Task RunAgentAsync( Func? isInteractive = null, TextWriter? error = null, Func? getLogonSessionId = null, - TimeProvider? clock = null) + TimeProvider? clock = null, + Func? errorIsProcessConsoleWriter = null, + Func? errorIsInteractive = null, + Func? supportsUnicode = null) { string applicationDirectory = GetPackagedApplicationDirectory(options); log("Using the OpenClaw application directly from the package."); @@ -339,20 +341,36 @@ await runtime.StartForExecutionAsync(CancellationToken.None) clock, target => { + bool selectedStreamIsInteractive = + errorIsInteractive?.Invoke() ?? + WindowsHostConsole.Instance.IsInteractiveOutput(target); + bool processConsoleWriter = + errorIsProcessConsoleWriter?.Invoke() ?? + ReferenceEquals(target, Console.Error); IDisposable? restore = null; - bool useColor = ClawCtlColorPolicy.PrepareOutput( + bool useColor = ClawCtlColorPolicy.PrepareForegroundOutput( noColor: false, json: false, - ReferenceEquals(target, Console.Error), + processConsoleWriter, interactive, + selectedStreamIsInteractive, environmentReader, () => WindowsHostConsole.Instance.TryEnableVirtualTerminalProcessing( target, log, out restore)); + bool useUnicode = supportsUnicode?.Invoke() ?? + (interactive && Console.OutputEncoding.CodePage == 65001); + log( + $"Gateway hint capabilities: interactive={interactive}, " + + $"stderrConsole={selectedStreamIsInteractive}, " + + $"color={useColor}, unicode={useUnicode}."); using (restore) { - ClawCtlConsole.WriteGatewayHint(target, useColor); + ClawCtlConsole.WriteGatewayHint( + target, + useColor, + useUnicode); } }); await guidance.EvaluateAsync( @@ -481,13 +499,21 @@ async Task RunSetupCommandAsync( Gateway.GatewayPersistenceStatus recovery = await lifecycle .GetRecoveryStatusAsync(log, cancellationToken) .ConfigureAwait(false); + Session.AgentConfigReadinessStatus? configReadiness = + gateway.State == Gateway.GatewayState.Running + ? null + : await Session.AgentConfigReadinessProbe.CheckAsync( + runtime, + status, + cancellationToken).ConfigureAwait(false); Session.SetupStateResult setup = runtime.SetupState.Read(runtime.ApplicationId); return WriteResult(new StatusCommandResult( status, gateway, recovery, - setup.Record?.AgentNodeVersion)); + setup.Record?.AgentNodeVersion, + configReadiness)); }, CollectLogs = async (requestedPath, cancellationToken) => { @@ -579,10 +605,28 @@ await Gateway.GatewayRuntime.Create( }, GatewayStatus = async cancellationToken => { - Gateway.GatewayRuntime runtime = Gateway.GatewayRuntime.Create(options, log); + Session.SessionRuntime sessionRuntime = GetSessionRuntime(); + Gateway.GatewayRuntime runtime = Gateway.GatewayRuntime.Create( + options, + sessionRuntime.Paths, + sessionRuntime, + log); Gateway.GatewayStatusReport result = await runtime.Controller - .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) + .GetStatusAsync(sessionRuntime.HelperPath, cancellationToken) .ConfigureAwait(false); + Session.AgentConfigReadinessStatus? configReadiness = null; + if (result.State != Gateway.GatewayState.Running) + { + Session.SessionStatus session = await sessionRuntime.Coordinator + .ProbeRecordedStatusAsync(cancellationToken) + .ConfigureAwait(false); + configReadiness = + await Session.AgentConfigReadinessProbe.CheckAsync( + sessionRuntime, + session, + cancellationToken).ConfigureAwait(false); + } + int? port = result.Record?.ObservedPorts is { Count: 1 } ? result.Record.ObservedPorts[0] : null; @@ -593,9 +637,10 @@ await Gateway.GatewayRuntime.Create( result.Detail, result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted - ? 0 + ? configReadiness?.ProbeFailed == true ? 1 : 0 : 1, - port)); + port, + Readiness: configReadiness)); }, GatewayStop = async cancellationToken => { diff --git a/src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs b/src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs new file mode 100644 index 00000000..82625435 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs @@ -0,0 +1,77 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Session; + +internal enum AgentConfigReadinessState +{ + Absent, + NotReady, + StartupEligible, + Unavailable, + Unknown +} + +internal sealed record AgentConfigReadinessStatus( + AgentConfigReadinessState State, + SessionConfigReadinessReason? Reason = null, + string? Detail = null, + bool ProbeFailed = false); + +internal static class AgentConfigReadinessProbe +{ + public static async Task CheckAsync( + SessionRuntime runtime, + SessionStatus session, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(session); + + if (session.Availability == SessionAvailability.None) + { + return new AgentConfigReadinessStatus( + AgentConfigReadinessState.Unavailable, + Detail: session.Detail); + } + + if (session.Availability != SessionAvailability.Running || + session.Record is null) + { + return new AgentConfigReadinessStatus( + AgentConfigReadinessState.Unknown, + Detail: session.Detail, + ProbeFailed: true); + } + + try + { + SessionConfigReadinessResult result = + await runtime.Executor.CheckConfigReadinessAsync( + session.Record, + runtime.RequireStagedHelper(session.Record), + cancellationToken).ConfigureAwait(false); + return new AgentConfigReadinessStatus( + result.State switch + { + SessionConfigReadinessState.Absent => + AgentConfigReadinessState.Absent, + SessionConfigReadinessState.NotReady => + AgentConfigReadinessState.NotReady, + SessionConfigReadinessState.StartupEligible => + AgentConfigReadinessState.StartupEligible, + _ => AgentConfigReadinessState.Unknown + }, + result.Reason); + } + catch (Exception exception) when ( + exception is SessionException or + IOException or UnauthorizedAccessException or + InvalidOperationException or ObjectDisposedException) + { + return new AgentConfigReadinessStatus( + AgentConfigReadinessState.Unknown, + Detail: exception.Message, + ProbeFailed: true); + } + } +} diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index b4c63556..de697291 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -369,6 +369,27 @@ private static Task SpectreOutputRenders() "'clawctl gateway-service start'", StringComparison.Ordinal), "The colored gateway hint lost its branding or command formatting."); + + using var readinessJson = new StringWriter(); + ClawCtlJson.WriteResult( + readinessJson, + new GatewayCommandResult( + "status", + GatewayState.NotStarted, + "No gateway has been started.", + null, + 0, + Readiness: new AgentConfigReadinessStatus( + AgentConfigReadinessState.StartupEligible))); + using JsonDocument readinessDocument = + JsonDocument.Parse(readinessJson.ToString()); + Assert( + readinessDocument.RootElement + .GetProperty("gateway") + .GetProperty("readiness") + .GetProperty("state") + .GetString() == "startup-eligible", + "The readiness JSON projection did not survive NativeAOT."); return Task.CompletedTask; } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs index 0cc8bc8f..967eb4e9 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs @@ -102,4 +102,55 @@ public void InteractiveColorRequiresVirtualTerminalSetup() Assert.False(enabled); Assert.True(enableCalled); } + + [Fact] + public void InteractiveForegroundProxyUsesColorWithoutVirtualTerminalSetup() + { + bool enableCalled = false; + + bool enabled = ClawCtlColorPolicy.PrepareForegroundOutput( + noColor: false, + json: false, + outputIsProcessConsoleWriter: true, + invocationIsInteractive: true, + selectedStreamIsInteractive: false, + _ => null, + () => + { + enableCalled = true; + return false; + }); + + Assert.True(enabled); + Assert.False(enableCalled); + } + + [Theory] + [InlineData(false, null, null)] + [InlineData(true, "1", null)] + [InlineData(true, null, "true")] + public void NoninteractiveAndExplicitPlainForegroundOutputStayPlain( + bool invocationIsInteractive, + string? noColor, + string? continuousIntegration) + { + string? ReadEnvironment(string name) => name switch + { + "NO_COLOR" => noColor, + "CI" => continuousIntegration, + _ => null + }; + + bool enabled = ClawCtlColorPolicy.PrepareForegroundOutput( + noColor: false, + json: false, + outputIsProcessConsoleWriter: true, + invocationIsInteractive, + selectedStreamIsInteractive: false, + ReadEnvironment, + () => throw new InvalidOperationException( + "A proxy stream must not require console mode changes.")); + + Assert.False(enabled); + } } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlReadinessOutputTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlReadinessOutputTests.cs new file mode 100644 index 00000000..d544134c --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlReadinessOutputTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests; + +public sealed class ClawCtlReadinessOutputTests +{ + [Theory] + [InlineData(0, "not configured")] + [InlineData(1, "not ready")] + [InlineData(2, "startup eligible")] + [InlineData(3, "unavailable")] + [InlineData(4, "unknown")] + public void HumanGatewayStatusDescribesEveryReadinessState( + int stateValue, + string expected) + { + var readiness = new AgentConfigReadinessStatus( + (AgentConfigReadinessState)stateValue); + var result = new GatewayCommandResult( + "status", + GatewayState.NotStarted, + "No gateway has been started.", + null, + 0, + Readiness: readiness); + using var output = new StringWriter(); + + ClawCtlConsole.WriteResult(output, result); + + Assert.Contains("Readiness:", output.ToString(), StringComparison.Ordinal); + Assert.Contains(expected, output.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData(0, "config-file-missing")] + [InlineData(1, "config-file-unreadable")] + [InlineData(2, "config-file-invalid")] + [InlineData(3, "gateway-missing")] + [InlineData(4, "gateway-mode-missing")] + [InlineData(5, "gateway-mode-not-local")] + [InlineData(6, "gateway-mode-local")] + public void JsonUsesStableReadinessReasons( + int reasonValue, + string expected) + { + var result = new GatewayCommandResult( + "status", + GatewayState.NotStarted, + "No gateway has been started.", + null, + 0, + Readiness: new AgentConfigReadinessStatus( + AgentConfigReadinessState.NotReady, + (SessionConfigReadinessReason)reasonValue)); + using var output = new StringWriter(); + + ClawCtlJson.WriteResult(output, result); + + using JsonDocument document = JsonDocument.Parse(output.ToString()); + Assert.Equal( + expected, + document.RootElement + .GetProperty("gateway") + .GetProperty("readiness") + .GetProperty("reason") + .GetString()); + } + + [Fact] + public void RunningGatewayOmitsReadinessFromHumanAndJsonOutput() + { + var result = new GatewayCommandResult( + "status", + GatewayState.Running, + "The gateway is listening.", + null, + 0); + using var human = new StringWriter(); + using var json = new StringWriter(); + + ClawCtlConsole.WriteResult(human, result); + ClawCtlJson.WriteResult(json, result); + + Assert.DoesNotContain("Readiness:", human.ToString(), StringComparison.Ordinal); + using JsonDocument document = JsonDocument.Parse(json.ToString()); + Assert.False( + document.RootElement + .GetProperty("gateway") + .TryGetProperty("readiness", out _)); + } + + [Fact] + public void StartGuidanceRequiresStartupEligibleConfig() + { + using var eligible = new StringWriter(); + using var absent = new StringWriter(); + + ClawCtlConsole.WriteResult( + eligible, + new GatewayCommandResult( + "status", + GatewayState.NotStarted, + "No gateway has been started.", + null, + 0, + Readiness: new AgentConfigReadinessStatus( + AgentConfigReadinessState.StartupEligible))); + ClawCtlConsole.WriteResult( + absent, + new GatewayCommandResult( + "status", + GatewayState.NotStarted, + "No gateway has been started.", + null, + 0, + Readiness: new AgentConfigReadinessStatus( + AgentConfigReadinessState.Absent))); + + Assert.Contains( + "clawctl gateway-service start", + eligible.ToString(), + StringComparison.Ordinal); + Assert.DoesNotContain( + "clawctl gateway-service start", + absent.ToString(), + StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index a7a806b6..10d7d8c4 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -150,14 +150,17 @@ public async Task SuccessfulInteractiveAgentHintsWhenEligibleGatewayWasNeverStar _ => runtime, probeReadiness: SupportedHost, getPackageFamilyName: () => runtime.Paths.PackageFamilyName, - readEnvironmentVariable: name => - name == "FORCE_COLOR" ? "1" : null, + readEnvironmentVariable: _ => null, isInteractive: () => true, error: error, - getLogonSessionId: () => "logon-a"); + getLogonSessionId: () => "logon-a", + errorIsProcessConsoleWriter: () => true, + errorIsInteractive: () => false, + supportsUnicode: () => true); Assert.Equal(0, exitCode); Assert.Contains("\u001b[", error.ToString(), StringComparison.Ordinal); + Assert.Contains("\U0001f980", error.ToString(), StringComparison.Ordinal); Assert.Contains( AgentGatewayGuidance.Hint, System.Text.RegularExpressions.Regex.Replace( @@ -675,6 +678,10 @@ public async Task StatusReportsSessionRuntimeGatewayAndRecovery() setupOutput, TextWriter.Null, installationLifecycle: lifecycle); + ConfigureConfigReadiness( + (FakeMxcSessionClient)runtime.Backend, + SessionConfigReadinessState.StartupEligible, + SessionConfigReadinessReason.GatewayModeLocal); using var statusOutput = new StringWriter(); int statusExitCode = await Program.RunControlAsync( @@ -693,6 +700,8 @@ public async Task StatusReportsSessionRuntimeGatewayAndRecovery() Assert.Contains("Node.js 24.20.0", status, StringComparison.Ordinal); Assert.Contains("Gateway:", status, StringComparison.Ordinal); Assert.Contains("not started", status, StringComparison.Ordinal); + Assert.Contains("Readiness:", status, StringComparison.Ordinal); + Assert.Contains("startup eligible", status, StringComparison.Ordinal); Assert.Contains("Recovery:", status, StringComparison.Ordinal); Assert.Contains("configured", status, StringComparison.Ordinal); } @@ -711,6 +720,10 @@ public async Task StatusJsonReportsStructuredSessionGatewayAndRecovery() setupOutput, TextWriter.Null, installationLifecycle: lifecycle); + ConfigureConfigReadiness( + (FakeMxcSessionClient)runtime.Backend, + SessionConfigReadinessState.StartupEligible, + SessionConfigReadinessReason.GatewayModeLocal); using var statusOutput = new StringWriter(); using var statusError = new StringWriter(); @@ -732,9 +745,165 @@ public async Task StatusJsonReportsStructuredSessionGatewayAndRecovery() Assert.Equal("running", root.GetProperty("session").GetProperty("state").GetString()); Assert.Equal("24.20.0", root.GetProperty("session").GetProperty("nodeVersion").GetString()); Assert.Equal("not-started", root.GetProperty("gateway").GetProperty("state").GetString()); + JsonElement readiness = + root.GetProperty("gateway").GetProperty("readiness"); + Assert.Equal("startup-eligible", readiness.GetProperty("state").GetString()); + Assert.Equal("gateway-mode-local", readiness.GetProperty("reason").GetString()); Assert.Equal("configured", root.GetProperty("recovery").GetProperty("state").GetString()); } + [Fact] + public async Task GatewayStatusStartsOnlyTheRecordedSessionAndReportsReadiness() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime); + int setupExitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: lifecycle); + var backend = (FakeMxcSessionClient)runtime.Backend; + ConfigureConfigReadiness( + backend, + SessionConfigReadinessState.NotReady, + SessionConfigReadinessReason.GatewayModeMissing); + backend.Calls.Clear(); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["gateway-service", "status"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(0, setupExitCode); + Assert.Equal(0, exitCode); + Assert.Contains( + backend.Calls, + call => call.StartsWith("start:", StringComparison.Ordinal)); + Assert.Contains( + backend.Calls, + call => call.StartsWith("execute:", StringComparison.Ordinal)); + Assert.DoesNotContain( + backend.Calls, + call => call.StartsWith("provision", StringComparison.Ordinal)); + Assert.Contains("Readiness:", output.ToString(), StringComparison.Ordinal); + Assert.Contains("not ready", output.ToString(), StringComparison.Ordinal); + Assert.Contains("gateway.mode is missing", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain( + "clawctl gateway-service start", + output.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task GatewayStatusWithoutARecordedSessionReportsUnavailableWithoutProvisioning() + { + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime); + var backend = (FakeMxcSessionClient)runtime.Backend; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(Path.Combine(_testDirectory, "app")), + ["gateway-service", "status"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(0, exitCode); + Assert.Empty(backend.Calls); + Assert.Contains("Readiness:", output.ToString(), StringComparison.Ordinal); + Assert.Contains("unavailable", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task GatewayStatusJsonRetainsGatewayWhenReadinessProbeFails() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime); + int setupExitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: lifecycle); + ((FakeMxcSessionClient)runtime.Backend).ExecuteFailure = + new SessionException("readiness unavailable"); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["gateway-service", "status", "--json"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(0, setupExitCode); + Assert.Equal(1, exitCode); + using JsonDocument document = JsonDocument.Parse(output.ToString()); + JsonElement root = document.RootElement; + Assert.False(root.GetProperty("ok").GetBoolean()); + Assert.Equal( + "not-started", + root.GetProperty("gateway").GetProperty("state").GetString()); + JsonElement readiness = + root.GetProperty("gateway").GetProperty("readiness"); + Assert.Equal("unknown", readiness.GetProperty("state").GetString()); + Assert.Contains( + "readiness unavailable", + readiness.GetProperty("detail").GetString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task GatewayStatusReportsUnknownReadinessWhenRecordedSessionIsUnavailable() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime); + int setupExitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: lifecycle); + ((FakeMxcSessionClient)runtime.Backend).StartFailure = new MxcException( + MxcErrorCode.RuntimeUnavailable, + "MXC is unavailable"); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["gateway-service", "status", "--json"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(0, setupExitCode); + Assert.Equal(1, exitCode); + using JsonDocument document = JsonDocument.Parse(output.ToString()); + JsonElement readiness = document.RootElement + .GetProperty("gateway") + .GetProperty("readiness"); + Assert.Equal("unknown", readiness.GetProperty("state").GetString()); + Assert.Contains( + "MXC is unavailable", + readiness.GetProperty("detail").GetString(), + StringComparison.OrdinalIgnoreCase); + } + [Fact] public void StatusJsonFailureRetainsStateDetails() { @@ -1679,6 +1848,33 @@ private HostOptions CreateSetupOptions(string applicationDirectory) return new HostOptions(applicationDirectory, archivePath, []); } + private static void ConfigureConfigReadiness( + FakeMxcSessionClient backend, + SessionConfigReadinessState state, + SessionConfigReadinessReason reason) + { + backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles( + backend.Metadata!.EphemeralWorkspacePath, + "config-readiness-*.json").Single(); + SessionConfigReadinessRequest request = + SessionConfigReadinessProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionConfigReadinessProtocol.SerializeResult( + new SessionConfigReadinessResult + { + RequestId = request.RequestId, + State = state, + Reason = reason + })); + return Task.FromResult( + new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + } + private sealed class FailingFreshLifecycle : IInstallationLifecycle { private readonly SessionRuntime _runtime; From a2332537b418ac627fefc1f620c174b050f2999e Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 13:40:03 -0700 Subject: [PATCH 5/7] fix: preserve process stderr identity Mark production console writers explicitly so UTF-8 initialization cannot make the app-alias stderr proxy look redirected before rendering the crab-branded gateway hint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cc6aa7d-1710-48a9-804d-3e5f496d8a34 --- src/OpenClaw.Launcher/HostStartup.cs | 3 +++ src/OpenClaw.Launcher/Program.cs | 7 +++++-- tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index 9b621899..f4cee083 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -19,6 +19,8 @@ internal sealed class HostStartup public required TextWriter Error { get; init; } + public bool UsesProcessConsoleWriters { get; init; } + public Session.IInstallationLifecycle? InstallationLifecycle { get; init; } public Func? ReadEnvironmentVariable { get; init; } @@ -30,6 +32,7 @@ internal sealed class HostStartup BaseDirectory = AppContext.BaseDirectory, Output = Console.Out, Error = Console.Error, + UsesProcessConsoleWriters = true, InstallationLifecycle = Session.InstallationLifecycle.Production }; } diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 4ec8fb34..820f37c6 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -123,7 +123,7 @@ PlatformNotSupportedException or try { WriteDiagnostic($"Host started through the {commandName} entrypoint."); - if (ReferenceEquals(startup.Output, Console.Out) && + if (startup.UsesProcessConsoleWriters && WindowsHostConsole.Instance.IsInteractive) { consoleRestore = WindowsHostConsole.Instance.Capture(WriteDiagnostic); @@ -149,7 +149,9 @@ startup.InstallationLifecycle is null ? null : startup.InstallationLifecycle.CreateRuntime, readEnvironmentVariable: startup.ReadEnvironmentVariable, - error: error) + error: error, + errorIsProcessConsoleWriter: + startup.UsesProcessConsoleWriters ? () => true : null) .ConfigureAwait(false); } catch (Exception exception) @@ -363,6 +365,7 @@ await runtime.StartForExecutionAsync(CancellationToken.None) (interactive && Console.OutputEncoding.CodePage == 65001); log( $"Gateway hint capabilities: interactive={interactive}, " + + $"processStderr={processConsoleWriter}, " + $"stderrConsole={selectedStreamIsInteractive}, " + $"color={useColor}, unicode={useUnicode}."); using (restore) diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index 206aca1a..cf52ec0a 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -16,6 +16,12 @@ public void ProductionStartupProvidesTheLifecycleFactoryForFallbackValidation() Assert.NotNull(HostStartup.CreateProduction().InstallationLifecycle); } + [Fact] + public void ProductionStartupIdentifiesItsProcessConsoleWriters() + { + Assert.True(HostStartup.CreateProduction().UsesProcessConsoleWriters); + } + [Fact] public async Task StartupWritesRecordsToTheSuppliedDiagnosticLog() { From 6d11e42321205ce3f158050cf532977a38787c7a Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 14:13:03 -0700 Subject: [PATCH 6/7] fix: serialize gateway guidance acknowledgement Keep manual start acknowledgement advisory under lock contention and recheck acknowledgement under the lifecycle lock before emitting a postflight hint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cc6aa7d-1710-48a9-804d-3e5f496d8a34 --- .../Gateway/AgentGatewayGuidance.cs | 26 ++++- .../Gateway/AgentGatewayGuidanceTests.cs | 103 ++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs index 9926acdb..5b7aff3f 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -79,7 +79,7 @@ public async Task EvaluateAsync( interactive && openClawExitCode == 0) { - _writeHint(error); + WriteHintIfUnacknowledged(logonSessionId, error); } } catch (Exception exception) when ( @@ -101,7 +101,8 @@ public void AcknowledgeManualStart() GatewayGuidanceAcknowledgement.ManualStartInvoked); } catch (Exception exception) when ( - exception is IOException or UnauthorizedAccessException or + exception is SessionException or + IOException or UnauthorizedAccessException or InvalidOperationException or Win32Exception) { _log( @@ -110,6 +111,27 @@ exception is IOException or UnauthorizedAccessException or } } + private void WriteHintIfUnacknowledged( + string logonSessionId, + TextWriter error) + { + using ISessionLockHandle? handle = + _lifecycleLock.TryAcquire(AdvisoryTimeout); + if (handle is null) + { + _log("Gateway guidance hint was skipped because lifecycle state is busy."); + return; + } + + if (_state.IsAcknowledged(logonSessionId)) + { + _log("Gateway guidance was acknowledged while postflight checks were running."); + return; + } + + _writeHint(error); + } + private void Acknowledge( string logonSessionId, GatewayGuidanceAcknowledgement acknowledgement) diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs index 89849d5c..eea305e0 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs @@ -177,6 +177,109 @@ public async Task ManualStartAcknowledgementSkipsEveryProbe() Assert.Equal(0, statusCalls); } + [Fact] + public void BusyManualAcknowledgementDoesNotAbortGatewayStart() + { + var store = new GatewayGuidanceStateStore( + Path.Combine(_root, "gateway-guidance.json")); + var guidance = new AgentGatewayGuidance( + new NeverFreeLock(), + _ => throw new InvalidOperationException("not used"), + _ => throw new InvalidOperationException("not used"), + store, + () => "logon-a", + _log.Add); + + guidance.AcknowledgeManualStart(); + + Assert.False(store.IsAcknowledged("logon-a")); + Assert.Contains( + _log, + message => message.Contains( + "acknowledgement failed", + StringComparison.Ordinal)); + } + + [Fact] + public async Task AcknowledgementDuringPostflightSuppressesThePendingHint() + { + var store = new GatewayGuidanceStateStore( + Path.Combine(_root, "gateway-guidance.json")); + var readinessStarted = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseReadiness = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var guidance = new AgentGatewayGuidance( + new AlwaysFreeLock(), + async _ => + { + readinessStarted.SetResult(); + await releaseReadiness.Task.ConfigureAwait(false); + return new SessionConfigReadinessResult + { + RequestId = "r1", + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + }; + }, + _ => Task.FromResult(new GatewayStatusReport( + GatewayState.NotStarted, + null, + "not started")), + store, + () => "logon-a", + _log.Add); + var error = new StringWriter(); + + Task evaluation = guidance.EvaluateAsync(0, interactive: true, error); + await readinessStarted.Task.ConfigureAwait(true); + store.Write( + "logon-a", + GatewayGuidanceAcknowledgement.ManualStartInvoked, + DateTimeOffset.UtcNow); + releaseReadiness.SetResult(); + await evaluation.ConfigureAwait(true); + + Assert.Equal(string.Empty, error.ToString()); + Assert.Contains( + _log, + message => message.Contains( + "acknowledged while postflight", + StringComparison.Ordinal)); + } + + [Fact] + public async Task BusyFinalAcknowledgementCheckSkipsTheAdvisoryHint() + { + var store = new GatewayGuidanceStateStore( + Path.Combine(_root, "gateway-guidance.json")); + var guidance = new AgentGatewayGuidance( + new NeverFreeLock(), + _ => Task.FromResult(new SessionConfigReadinessResult + { + RequestId = "r1", + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + }), + _ => Task.FromResult(new GatewayStatusReport( + GatewayState.NotStarted, + null, + "not started")), + store, + () => "logon-a", + _log.Add); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Equal(string.Empty, error.ToString()); + Assert.Contains( + _log, + message => message.Contains( + "lifecycle state is busy", + StringComparison.Ordinal)); + } + [Fact] public async Task AdvisoryFailureIsLoggedWithoutOutput() { From 41041dbd3621e8113edb38197c8b8dd6a34d6243 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 14:29:56 -0700 Subject: [PATCH 7/7] fix: preserve readiness advisory compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cc6aa7d-1710-48a9-804d-3e5f496d8a34 --- .../Gateway/AgentGatewayGuidance.cs | 3 +- .../Gateway/GatewayLauncherScript.cs | 86 +++++++++- src/OpenClaw.Launcher/Program.cs | 11 +- .../Session/AgentConfigReadinessStatus.cs | 3 +- .../Gateway/AgentGatewayGuidanceTests.cs | 14 +- .../Gateway/GatewayPersistenceManagerTests.cs | 32 ++++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 150 +++++++++++++++++- 7 files changed, 285 insertions(+), 14 deletions(-) diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs index 5b7aff3f..3c1e3859 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using OpenClaw.Launcher.Mxc; using OpenClaw.Launcher.Session; using OpenClaw.SessionProtocol; @@ -83,7 +84,7 @@ public async Task EvaluateAsync( } } catch (Exception exception) when ( - exception is SessionException or SessionLaunchException or + exception is MxcException or SessionException or SessionLaunchException or OperationCanceledException or IOException or UnauthorizedAccessException or InvalidOperationException or ObjectDisposedException or Win32Exception) diff --git a/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs index f48eff8f..c8864dbf 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace OpenClaw.Launcher.Gateway; /// @@ -17,6 +19,8 @@ internal static class GatewayLauncherScript public const string ControlArguments = "gateway-service start --recovery"; + private const string LegacyControlArguments = "gateway-service start"; + public static string Create(string workingDirectory, string activationScriptPath) { ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); @@ -39,8 +43,88 @@ public static string Create(string workingDirectory, string activationScriptPath } public static string CreateActivationScript(string packageFamilyName) + { + return CreateActivationScript(packageFamilyName, ControlArguments); + } + + public static bool UpgradeLegacyActivationScript( + string activationScriptPath, + string packageFamilyName, + Action log) + { + ArgumentException.ThrowIfNullOrWhiteSpace(activationScriptPath); + ArgumentException.ThrowIfNullOrWhiteSpace(packageFamilyName); + ArgumentNullException.ThrowIfNull(log); + + string existing; + try + { + existing = File.ReadAllText(activationScriptPath); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + log( + "The retained gateway recovery script could not be inspected; " + + $"this invocation will be treated as recovery: {exception.Message}"); + return true; + } + + if (!string.Equals( + existing, + CreateActivationScript(packageFamilyName, LegacyControlArguments), + StringComparison.Ordinal)) + { + return false; + } + + string temporaryPath = + $"{activationScriptPath}.{Guid.NewGuid():N}.tmp"; + try + { + File.WriteAllText( + temporaryPath, + CreateActivationScript(packageFamilyName), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + File.Move(temporaryPath, activationScriptPath, overwrite: true); + log("Upgraded the retained gateway recovery script."); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + log( + "The retained gateway recovery script could not be upgraded; " + + $"this invocation will still be treated as recovery: {exception.Message}"); + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + log( + "The temporary gateway recovery script could not be removed: " + + exception.Message); + } + } + + return true; + } + + private static string CreateActivationScript( + string packageFamilyName, + string controlArguments) { ArgumentException.ThrowIfNullOrWhiteSpace(packageFamilyName); + ArgumentException.ThrowIfNullOrWhiteSpace(controlArguments); string applicationUserModelId = $"{packageFamilyName}!{ControlApplicationId}"; @@ -50,7 +134,7 @@ public static string CreateActivationScript(string packageFamilyName) "$ErrorActionPreference = 'Stop'", $"$packageFamilyName = '{QuotePowerShell(packageFamilyName)}'", $"$applicationId = '{ControlApplicationId}'", - $"$arguments = '{ControlArguments}'", + $"$arguments = '{controlArguments}'", $"$applicationUserModelId = '{QuotePowerShell(applicationUserModelId)}'", "$package = Get-AppxPackage -Name 'OpenClaw.Gateway' | " + "Where-Object { $_.PackageFamilyName -eq $packageFamilyName } | " + diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 820f37c6..6fffbb29 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -561,10 +561,19 @@ await Gateway.GatewayRuntime.Create( GatewayStart = async (recovery, cancellationToken) => { Session.SessionRuntime runtime = GetSessionRuntime(); + bool retainedRecoveryInvocation = + !recovery && + runtime.Paths.PackageFamilyName is string packageFamilyName && + Gateway.GatewayLauncherScript.UpgradeLegacyActivationScript( + Path.ChangeExtension( + runtime.Paths.GatewayLauncherPath, + ".ps1"), + packageFamilyName, + log); Gateway.GatewayController controller = Gateway.GatewayRuntime .Create(options, runtime.Paths, runtime, log, clock) .Controller; - if (!recovery) + if (!recovery && !retainedRecoveryInvocation) { new Gateway.AgentGatewayGuidance( runtime.LifecycleLock, diff --git a/src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs b/src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs index 82625435..0f33dca9 100644 --- a/src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs +++ b/src/OpenClaw.Launcher/Session/AgentConfigReadinessStatus.cs @@ -1,3 +1,4 @@ +using OpenClaw.Launcher.Mxc; using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher.Session; @@ -64,7 +65,7 @@ await runtime.Executor.CheckConfigReadinessAsync( result.Reason); } catch (Exception exception) when ( - exception is SessionException or + exception is MxcException or SessionException or SessionLaunchException or IOException or UnauthorizedAccessException or InvalidOperationException or ObjectDisposedException) { diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs index eea305e0..fa239b8a 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; using OpenClaw.Launcher.Tests.Session; using OpenClaw.SessionProtocol; @@ -280,14 +281,19 @@ public async Task BusyFinalAcknowledgementCheckSkipsTheAdvisoryHint() StringComparison.Ordinal)); } - [Fact] - public async Task AdvisoryFailureIsLoggedWithoutOutput() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AdvisoryFailureIsLoggedWithoutOutput(bool backendFailure) { var store = new GatewayGuidanceStateStore( Path.Combine(_root, "gateway-guidance.json")); + Exception failure = backendFailure + ? new MxcException(MxcErrorCode.BackendError, "backend rejected dispatch") + : new SessionLaunchException("bad helper result"); var guidance = new AgentGatewayGuidance( new AlwaysFreeLock(), - _ => throw new SessionLaunchException("bad helper result"), + _ => throw failure, _ => throw new InvalidOperationException("must not inspect"), store, () => "logon-a", @@ -299,6 +305,6 @@ public async Task AdvisoryFailureIsLoggedWithoutOutput() Assert.Equal(string.Empty, error.ToString()); Assert.Contains( _log, - line => line.Contains("bad helper result", StringComparison.Ordinal)); + line => line.Contains(failure.Message, StringComparison.Ordinal)); } } diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs index 8c7f465a..1b549281 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs @@ -117,6 +117,38 @@ public async Task TheGeneratedLauncherActivatesTheOwningPackageControlApplicatio Assert.DoesNotContain("WindowsApps", launcher + activation, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task LegacyActivationUpgradeDoesNotOverwriteModifiedContent() + { + await CreateManager().InstallAsync(CancellationToken.None); + string modified = + (await File.ReadAllTextAsync( + ActivationScriptPath, + CancellationToken.None)) + .Replace( + GatewayLauncherScript.ControlArguments, + "gateway-service start", + StringComparison.Ordinal) + + "# user edit"; + await File.WriteAllTextAsync( + ActivationScriptPath, + modified, + CancellationToken.None); + + bool legacyInvocation = + GatewayLauncherScript.UpgradeLegacyActivationScript( + ActivationScriptPath, + "OpenClaw.Gateway_test", + _ => { }); + + Assert.False(legacyInvocation); + Assert.Equal( + modified, + await File.ReadAllTextAsync( + ActivationScriptPath, + CancellationToken.None)); + } + [Fact] public async Task TheControlActivationScriptFailsClosedOnMissingOrMismatchedTargets() { diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 10d7d8c4..e9b75b48 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -170,6 +170,67 @@ public async Task SuccessfulInteractiveAgentHintsWhenEligibleGatewayWasNeverStar StringComparison.Ordinal); } + [Theory] + [InlineData(0)] + [InlineData(17)] + public async Task BackendAdvisoryFailurePreservesAgentExitCode(int childExitCode) + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions setupOptions = CreateSetupOptions(applicationDirectory); + SessionRuntime runtime = CreateSessionRuntime(); + int setupExitCode = await Program.RunControlAsync( + setupOptions, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }); + Assert.Equal(0, setupExitCode); + + FakeMxcSessionClient backend = _lastSessionBackend!; + backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles( + backend.Metadata!.EphemeralWorkspacePath, + "launch-*.json").Single(); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = childExitCode, + })); + backend.ExecuteFailure = + new MxcException(MxcErrorCode.BackendError, "readiness dispatch failed"); + return Task.FromResult(0); + }; + var log = new List(); + + int exitCode = await Program.RunAgentAsync( + new HostOptions( + applicationDirectory, + setupOptions.PackagedNodeArchivePath, + ["status"]), + log.Add, + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: _ => null, + isInteractive: () => true, + error: TextWriter.Null, + getLogonSessionId: () => "logon-advisory"); + + Assert.Equal(childExitCode, exitCode); + Assert.Contains( + log, + line => line.Contains( + "MxcException: readiness dispatch failed", + StringComparison.Ordinal)); + } + [Fact] public async Task ManualGatewayStartAcknowledgesBeforeAStartFailure() { @@ -192,6 +253,47 @@ await Assert.ThrowsAsync( Assert.True(store.IsAcknowledged("logon-manual")); } + [Fact] + public async Task RetainedRecoveryScriptUpgradesWithoutManualAcknowledgement() + { + SessionRuntime runtime = await SetUpSessionAsync().ConfigureAwait(true); + string activationScriptPath = + Path.ChangeExtension(runtime.Paths.GatewayLauncherPath, ".ps1"); + string legacyScript = + GatewayLauncherScript.CreateActivationScript( + runtime.Paths.PackageFamilyName!) + .Replace( + GatewayLauncherScript.ControlArguments, + "gateway-service start", + StringComparison.Ordinal); + await File.WriteAllTextAsync( + activationScriptPath, + legacyScript, + CancellationToken.None); + ((FakeMxcSessionClient)runtime.Backend).ExecuteFailure = + new SessionException("gateway recovery failed"); + + await Assert.ThrowsAsync( + () => Program.RunControlAsync( + CreateSetupOptions(Path.Combine(_testDirectory, "app")), + ["gateway-service", "start"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime), + getLogonSessionId: () => "logon-recovery")); + + var store = new GatewayGuidanceStateStore( + runtime.Paths.GatewayGuidanceStatePath); + Assert.False(store.IsAcknowledged("logon-recovery")); + Assert.Contains( + GatewayLauncherScript.ControlArguments, + await File.ReadAllTextAsync( + activationScriptPath, + CancellationToken.None), + StringComparison.Ordinal); + } + [Fact] public async Task AgentControlCExitsSilentlyWithPortableInterruptedCode() { @@ -823,8 +925,14 @@ public async Task GatewayStatusWithoutARecordedSessionReportsUnavailableWithoutP Assert.Contains("unavailable", output.ToString(), StringComparison.Ordinal); } - [Fact] - public async Task GatewayStatusJsonRetainsGatewayWhenReadinessProbeFails() + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StatusJsonRetainsGatewayWhenReadinessProbeFails( + bool gatewayOnly, + bool malformedResult) { string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); SessionRuntime runtime = CreateSessionRuntime(); @@ -836,13 +944,41 @@ public async Task GatewayStatusJsonRetainsGatewayWhenReadinessProbeFails() TextWriter.Null, TextWriter.Null, installationLifecycle: lifecycle); - ((FakeMxcSessionClient)runtime.Backend).ExecuteFailure = - new SessionException("readiness unavailable"); + var backend = (FakeMxcSessionClient)runtime.Backend; + if (malformedResult) + { + backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles( + backend.Metadata!.EphemeralWorkspacePath, + "config-readiness-*.json").Single(); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionConfigReadinessProtocol.SerializeResult( + new SessionConfigReadinessResult + { + RequestId = "different-request", + State = SessionConfigReadinessState.StartupEligible, + Reason = SessionConfigReadinessReason.GatewayModeLocal + })); + return Task.FromResult( + new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + } + else + { + backend.ExecuteFailure = + new MxcException( + MxcErrorCode.BackendError, + "readiness backend unavailable"); + } using var output = new StringWriter(); int exitCode = await Program.RunControlAsync( CreateSetupOptions(applicationDirectory), - ["gateway-service", "status", "--json"], + gatewayOnly + ? ["gateway-service", "status", "--json"] + : ["status", "--json"], _ => { }, output, TextWriter.Null, @@ -860,7 +996,9 @@ public async Task GatewayStatusJsonRetainsGatewayWhenReadinessProbeFails() root.GetProperty("gateway").GetProperty("readiness"); Assert.Equal("unknown", readiness.GetProperty("state").GetString()); Assert.Contains( - "readiness unavailable", + malformedResult + ? "does not match" + : "readiness backend unavailable", readiness.GetProperty("detail").GetString(), StringComparison.Ordinal); }