From 1b0eecfd912e06fbf50af33c7fbdd08a6e513afe Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Fri, 18 Sep 2026 18:41:59 -0700 Subject: [PATCH 1/4] feat: add clawctl open command Require completed setup and a running managed gateway before opening OpenClaw's verified one-time Control UI handoff in the default browser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff501e8-3075-418e-9816-146dedceca22 --- README.md | 20 +- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 12 + src/OpenClaw.Launcher/ClawCtlConsole.cs | 24 +- src/OpenClaw.Launcher/ClawCtlJson.cs | 13 ++ src/OpenClaw.Launcher/ClawCtlResults.cs | 8 + .../Gateway/ControlUiHandoff.cs | 39 ++++ src/OpenClaw.Launcher/Program.cs | 97 +++++++- .../Session/SessionExecutor.cs | 131 +++++++++++ .../ClawCtlCommandLineTests.cs | 31 +++ .../Gateway/ControlUiHandoffTests.cs | 34 +++ .../Gateway/GatewayAddressTests.cs | 27 ++- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 207 ++++++++++++++++++ .../Session/SessionExecutorTests.cs | 162 ++++++++++++++ 13 files changed, 781 insertions(+), 24 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs diff --git a/README.md b/README.md index 7315ab32..1548de91 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ terminal the hint uses the crab identity and the same warning/accent palette as | `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, 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 open` | Open the running managed gateway's Control UI in the default browser. Requires completed `clawctl setup` and an already-running gateway; it probes those prerequisites and fails rather than starting the gateway. Packaged OpenClaw resolves the endpoint, TLS, Control UI base path, and authenticated one-time browser handoff. Authenticated URLs and tokens are not printed. | | `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. | @@ -159,19 +160,18 @@ clawctl gateway-service start Gateway: ✓ listening Port: 18789 - - Token: openclaw gateway auth-token --show ``` OpenClaw owns the endpoint configuration, including TLS and a custom Control UI -base path. The Windows package therefore does not construct an HTTP URL that -might contradict that configuration. It reports no port when multiple -unclassified listeners remain. JSON follows the same rule: `gateway.port` is -present only when identified, and no URL is promised. -Reaching the Control UI needs the shared gateway token, which -`openclaw gateway auth-token --show` reveals. `--json` carries the identified -port but not that command: a script should run it rather than parse a -suggestion. +base path. `clawctl status` and `clawctl gateway-service start` therefore do +not construct an HTTP URL that might contradict that configuration. They report +no port when multiple unclassified listeners remain; JSON follows the same +rule, with `gateway.port` present only when identified and no URL promised. + +`clawctl open` uses packaged OpenClaw's verified, authenticated browser handoff +instead of constructing a URL from that port. It does not start or recover the +gateway: start it first with `clawctl gateway-service start` if the probe says +it is not running. The command does not print authenticated URLs or tokens. Help and version requests take precedence over the rest of the command line. `clawctl --version bogus` reports the build identity and exits `0` rather than diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index f98b28cd..5052242a 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -10,6 +10,7 @@ internal sealed record ClawCtlHandlers public required Func> Status { get; init; } public required Func> CollectLogs { get; init; } public required Func> Teardown { get; init; } + public Func> Open { get; init; } = _ => Task.FromResult(1); public required Func> PowerShell { get; init; } public required Func> GatewayStart { get; init; } public required Func> GatewayStatus { get; init; } @@ -34,6 +35,7 @@ internal static class ClawCtlCommandLine public const string SetupCommandName = "setup"; public const string StatusCommandName = "status"; public const string CollectLogsCommandName = "collect-logs"; + public const string OpenCommandName = "open"; // Response-file expansion is off. A leading `@` means nothing to clawctl, // so it is reported as an unrecognized argument instead of silently reading @@ -142,6 +144,15 @@ public static RootCommand Create( outputOptions.NoColor = parsed.GetValue(noColor); return handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken); }); + Command open = new( + OpenCommandName, + "Open the running gateway's Control UI in the default browser."); + open.SetAction((parsed, cancellationToken) => + { + outputOptions.Json = parsed.GetValue(json); + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.Open(cancellationToken); + }); Command powerShell = new( "pwsh", "Open PowerShell inside the isolated agent. `openclaw` and `node` " + @@ -195,6 +206,7 @@ public static RootCommand Create( status, collectLogs, teardown, + open, powerShell, gateway }; diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index a0d70e44..37785940 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -61,6 +61,9 @@ internal static void WriteResult( case TeardownCommandResult teardown: WriteTeardown(view, teardown); break; + case OpenCommandResult open: + WriteOpen(view, open); + break; case GatewayCommandResult gateway: WriteGateway(view, gateway); break; @@ -520,6 +523,7 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result) { view.Row("URL", new Text(result.Url)); } + else if (result.Port is not null) { view.Row( @@ -540,12 +544,12 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result) 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) + // The authenticated handoff is owned by OpenClaw and must not expose + // a reusable token at the console. + if (result.State == Gateway.GatewayState.Running) { view.Blank(); - view.Command("Token", "openclaw gateway auth-token --show"); + view.Command("Open Control UI", "clawctl open"); } if (result.State == Gateway.GatewayState.NotStarted && @@ -558,6 +562,18 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result) } } + private static void WriteOpen(ResultView view, OpenCommandResult result) + { + if (result.State is { } state) + { + view.Row("Gateway", state == Gateway.GatewayState.Running + ? Status(view, StatusKind.Success, "listening") + : Status(view, StatusKind.Failure, state.ToString())); + } + + view.Detail(result.Message); + } + private static void WriteReadiness( ResultView view, Session.AgentConfigReadinessStatus? readiness) diff --git a/src/OpenClaw.Launcher/ClawCtlJson.cs b/src/OpenClaw.Launcher/ClawCtlJson.cs index 93df5736..1813f7d7 100644 --- a/src/OpenClaw.Launcher/ClawCtlJson.cs +++ b/src/OpenClaw.Launcher/ClawCtlJson.cs @@ -79,6 +79,7 @@ internal static void WriteResult(TextWriter output, IClawCtlResult result) StatusCommandResult status => FromStatus(status), CollectLogsCommandResult logs => FromCollectLogs(logs), TeardownCommandResult teardown => FromTeardown(teardown), + OpenCommandResult open => FromOpen(open), GatewayCommandResult gateway => FromGateway(gateway), _ => throw new ArgumentOutOfRangeException( nameof(result), @@ -232,6 +233,18 @@ private static ClawCtlJsonDocument FromGateway(GatewayCommandResult result) => "cli_error", NormalizeMessage(result.Detail ?? result.Message))); + private static ClawCtlJsonDocument FromOpen(OpenCommandResult result) => + new( + result.ExitCode == 0, + SchemaVersion, + result.Command, + Gateway: result.State is { } state + ? new ClawCtlJsonGateway(DescribeGateway(state)) + : null, + Error: result.ExitCode == 0 + ? null + : new ClawCtlJsonError("cli_error", NormalizeMessage(result.Message))); + private static void Write(TextWriter output, ClawCtlJsonDocument document) => output.WriteLine(JsonSerializer.Serialize( document, diff --git a/src/OpenClaw.Launcher/ClawCtlResults.cs b/src/OpenClaw.Launcher/ClawCtlResults.cs index ba970296..e9be155e 100644 --- a/src/OpenClaw.Launcher/ClawCtlResults.cs +++ b/src/OpenClaw.Launcher/ClawCtlResults.cs @@ -74,6 +74,14 @@ internal sealed record TeardownCommandResult( public int ExitCode => Teardown.Succeeded ? 0 : 1; } +internal sealed record OpenCommandResult( + GatewayState? State, + string Message, + int ExitCode) : IClawCtlResult +{ + public string Command => "open"; +} + internal sealed record GatewayCommandResult( string Action, GatewayState State, diff --git a/src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs b/src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs new file mode 100644 index 00000000..e5029731 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Gateway; + +internal sealed record ControlUiHandoff(bool Ok, string? BrowserUrl); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(ControlUiHandoff))] +internal sealed partial class ControlUiHandoffJsonContext : JsonSerializerContext; + +internal static class ControlUiHandoffParser +{ + internal static bool TryParse(string output, out string? browserUrl) + { + browserUrl = null; + try + { + ControlUiHandoff? handoff = JsonSerializer.Deserialize( + output, + ControlUiHandoffJsonContext.Default.ControlUiHandoff); + if (handoff is null || !handoff.Ok || + string.IsNullOrWhiteSpace(handoff.BrowserUrl) || + !Uri.TryCreate(handoff.BrowserUrl, UriKind.Absolute, out Uri? candidate) || + (candidate.Scheme != Uri.UriSchemeHttp && candidate.Scheme != Uri.UriSchemeHttps) || + !candidate.IsLoopback) + { + return false; + } + + browserUrl = handoff.BrowserUrl; + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 73a379c4..768a14b2 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using OpenClaw.SessionProtocol; @@ -235,7 +236,7 @@ private static string ResolveClawCtlCommand(string[] args) return action is null ? argument : $"{argument} {action}"; } - if (argument is "setup" or "status" or "collect-logs" or "teardown" or "pwsh") + if (argument is "setup" or "status" or "collect-logs" or "teardown" or "open" or "pwsh") { return argument; } @@ -447,7 +448,8 @@ internal static async Task RunControlAsync( Func? readEnvironmentVariable = null, ClawCtlOutputOptions? controlOutputOptions = null, Func? getLogonSessionId = null, - TimeProvider? clock = null) + TimeProvider? clock = null, + Func? launchBrowserAsync = null) { Session.IInstallationLifecycle lifecycle = installationLifecycle ?? Session.InstallationLifecycle.Production; @@ -605,6 +607,84 @@ await Gateway.GatewayRuntime.Create( .ConfigureAwait(false); return WriteResult(new TeardownCommandResult(result)); }, + Open = async cancellationToken => + { + Session.SessionRuntime runtime = GetSessionRuntime(); + try + { + _ = runtime.RequireSetup(); + } + catch (Session.SessionException exception) + { + return WriteResult(new OpenCommandResult(null, exception.Message, 1)); + } + + Gateway.GatewayStatusReport gateway = await Gateway.GatewayRuntime + .Create(options, runtime.Paths, runtime, log) + .Controller + .GetStatusAsync(runtime.HelperPath, cancellationToken) + .ConfigureAwait(false); + if (gateway.State != Gateway.GatewayState.Running) + { + string message = gateway.State is Gateway.GatewayState.NotStarted or + Gateway.GatewayState.Stopped + ? $"{gateway.Message} Run `clawctl gateway-service start` before opening the Control UI." + : gateway.Message; + return WriteResult(new OpenCommandResult(gateway.State, message, 1)); + } + + Session.SessionRecord record = await runtime.StartForExecutionAsync(cancellationToken) + .ConfigureAwait(false); + string applicationDirectory = GetPackagedApplicationDirectory(options); + string nodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); + Session.SessionCommandCaptureResult capture = await runtime.Executor + .ExecuteCommandCaptureAsync( + record, + new Session.SessionCommandRequest( + runtime.RequireStagedHelper(record), + nodePath, + [Path.Combine(applicationDirectory, "openclaw.mjs"), "dashboard", "--json"], + record.WorkspacePath!) + { + PathPrefix = Path.GetDirectoryName(nodePath), + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build() + }, + "Resolving the Control UI handoff in the isolated session.", + "OpenClaw dashboard", + cancellationToken) + .ConfigureAwait(false); + if (!Gateway.ControlUiHandoffParser.TryParse(capture.StandardOutput, out string? browserUrl) || + browserUrl is null) + { + return WriteResult(new OpenCommandResult( + gateway.State, + "OpenClaw did not return a usable Control UI handoff.", + 1)); + } + + try + { + await (launchBrowserAsync ?? (url => LaunchBrowserAsync(url)))(browserUrl) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException or + NotSupportedException) + { + log($"Browser launch failed: {exception.GetType().Name}"); + return WriteResult(new OpenCommandResult( + gateway.State, + "The Control UI is ready, but the default browser could not be opened.", + 1)); + } + + return WriteResult(new OpenCommandResult( + gateway.State, + "Opened the Control UI in the default browser.", + 0)); + }, PowerShell = cancellationToken => RunPowerShellAsync( options, GetSessionRuntime(), @@ -749,6 +829,19 @@ result.State is Gateway.GatewayState.Running or .ConfigureAwait(false); } + internal static Task LaunchBrowserAsync( + string browserUrl, + Func? startProcess = null) + { + _ = (startProcess ?? Process.Start)(new ProcessStartInfo(browserUrl) + { + UseShellExecute = true + }) ?? throw new InvalidOperationException( + "Windows did not start a process for the default browser."); + + return Task.CompletedTask; + } + private static async Task RunSetupAsync( SetupOptions setupOptions, HostOptions options, diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index f2cc8207..6661fbd8 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -1,3 +1,4 @@ +using System.Globalization; using OpenClaw.Launcher.Mxc; using OpenClaw.SessionProtocol; @@ -74,6 +75,13 @@ internal sealed record SessionCommandRequest( public string? NativeRootPath { get; init; } } +/// +/// Captured output from a non-interactive command run in the isolated session. +/// +internal sealed record SessionCommandCaptureResult( + string StandardOutput, + string StandardError); + /// /// Runs OpenClaw inside the owned session. /// @@ -210,6 +218,62 @@ await operation.WriteTextNewAsync( } } + /// + /// Runs a non-interactive command inside the session and captures its output. + /// + /// + /// The captured streams can contain authenticated data. This method must not + /// log them. + /// + public async Task ExecuteCommandCaptureAsync( + SessionRecord record, + SessionCommandRequest request, + string startingMessage, + string subject, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(request); + + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("launch", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + var launchRequest = new SessionLaunchRequest + { + RequestId = requestId, + Executable = request.Executable, + Arguments = request.Arguments, + WorkingDirectory = request.WorkingDirectory, + Environment = MergeEnvironment( + _buildEnvironment(), request.AdditionalEnvironment), + PathPrefix = request.PathPrefix, + }; + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionLaunchProtocol.SerializeRequest(launchRequest), + cancellationToken).ConfigureAwait(false); + + _log(startingMessage); + MxcExecutionResult execution = await _backend.ExecuteAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest(BuildGuestCommandLine(request.HelperPath, requestPath)), + null, + cancellationToken).ConfigureAwait(false); + + operation.EnsureCurrent(); + return ReadCapturedOutcome(operation, resultPath, execution, requestId, subject); + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + /// /// Asks the guest to stage diagnostic files into the shared workspace. /// @@ -599,6 +663,73 @@ static void Verify(string value, string name) /// captures nothing, so a dispatch failure is indistinguishable from an /// application exit without this file. /// + private static SessionCommandCaptureResult ReadCapturedOutcome( + SessionWorkspaceOperation operation, + string resultPath, + MxcExecutionResult execution, + string requestId, + string subject) + { + string resultText; + try + { + resultText = operation.ReadTextAsync(resultPath, CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or IOException) + { + throw new SessionException( + "The isolated session did not report a launch result " + + $"(executor exit code {execution.ExitCode}). {subject} may not have started."); + } + + SessionLaunchResult result; + try + { + result = SessionLaunchProtocol.ReadResult(resultText); + } + catch (SessionLaunchException exception) + { + throw new SessionException( + $"The isolated session reported an unreadable launch result: {exception.Message}", + exception); + } + + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a launch result for a different request."); + } + + if (!result.Launched) + { + throw new SessionException( + $"{subject} could not be started inside the isolated session: " + + (result.Error ?? "no reason was reported.")); + } + + if (execution.ExitCode != 0) + { + throw new SessionException( + $"The isolated session backend failed to run {subject} " + + $"(executor exit code {execution.ExitCode})."); + } + + if (result.ExitCode is not 0) + { + string exitCode = result.ExitCode is int value + ? value.ToString(CultureInfo.InvariantCulture) + : "no exit code"; + throw new SessionException( + $"{subject} exited with code {exitCode}."); + } + + return new SessionCommandCaptureResult( + execution.StandardOutput, + execution.StandardError); + } + private static int ReadOutcome( SessionWorkspaceOperation operation, string resultPath, diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index 9d4ac0bc..cbcc7dd9 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -97,6 +97,7 @@ public void OnlyTheOwnedCommandsAreExposed() ClawCtlCommandLine.StatusCommandName, ClawCtlCommandLine.CollectLogsCommandName, "teardown", + ClawCtlCommandLine.OpenCommandName, "pwsh", "gateway-service" ], @@ -129,6 +130,36 @@ public async Task GatewayServiceStartInvokesOnlyTheStartHandler() Assert.Equal(1, starts); } + [Fact] + public async Task OpenInvokesItsHandlerAndInheritsOutputOptions() + { + int opens = 0; + var outputOptions = new ClawCtlOutputOptions(); + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (_, _) => Task.FromResult(0), + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + Open = _ => + { + opens++; + return Task.FromResult(0); + }, + PowerShell = _ => Task.FromResult(0), + GatewayStart = (_, _) => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }, outputOptions); + + int exitCode = await root.Parse("open --json --no-color").InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(1, opens); + Assert.True(outputOptions.Json); + Assert.True(outputOptions.NoColor); + } + [Theory] [InlineData("gateway-service start", false)] [InlineData("gateway-service start --recovery", true)] diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs new file mode 100644 index 00000000..e17e136f --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs @@ -0,0 +1,34 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class ControlUiHandoffTests +{ + [Fact] + public void AcceptsAbsoluteLoopbackHttpUrlWithoutRewritingIt() + { + const string url = "http://127.0.0.1:18789/control/#token=secret"; + + bool parsed = ControlUiHandoffParser.TryParse( + $$"""{"ok":true,"browserUrl":"{{url}}"}""", + out string? browserUrl); + + Assert.True(parsed); + Assert.NotNull(browserUrl); + Assert.Equal(url, browserUrl); + } + + [Theory] + [InlineData("""{"ok":false,"browserUrl":"http://127.0.0.1:18789/"}""")] + [InlineData("""{"ok":true}""")] + [InlineData("""{"ok":true,"browserUrl":"https://example.com/"}""")] + [InlineData("""{"ok":true,"browserUrl":"file:///C:/temp"}""")] + [InlineData("not-json")] + public void RejectsUnsafeOrInvalidHandoffs(string output) + { + bool parsed = ControlUiHandoffParser.TryParse(output, out string? browserUrl); + + Assert.False(parsed); + Assert.Null(browserUrl); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs index d361bb65..c4b2e4f3 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayAddressTests.cs @@ -54,7 +54,7 @@ public sealed class GatewayStartPresentationTests null); [Fact] - public void ARunningGatewayReportsItsPortAndHowToGetTheToken() + public void ARunningGatewayReportsItsPortAndHowToOpenTheControlUi() { using var output = new StringWriter(); @@ -64,25 +64,36 @@ public void ARunningGatewayReportsItsPortAndHowToGetTheToken() Assert.Contains("Port:", text, StringComparison.Ordinal); Assert.Contains("18789", text, StringComparison.Ordinal); Assert.DoesNotContain("http://", text, StringComparison.Ordinal); - Assert.Contains("openclaw gateway auth-token --show", text, StringComparison.Ordinal); - Assert.DoesNotContain("openclaw dashboard", text, StringComparison.Ordinal); + Assert.Contains("clawctl open", text, StringComparison.Ordinal); + Assert.DoesNotContain("auth-token", text, StringComparison.Ordinal); } [Fact] - public void RunningStatusDoesNotSuggestOpeningTheDashboard() + public void RunningStatusSuggestsOpeningTheControlUi() { using var output = new StringWriter(); ClawCtlConsole.WriteResult(output, Running() with { Action = "status" }); - Assert.DoesNotContain( - "openclaw dashboard", + Assert.Contains( + "clawctl open", output.ToString(), StringComparison.Ordinal); } - // The token command is guidance for a person. A script asked for a document - // and should run the command itself rather than parse a suggestion. + [Fact] + public void ARunningGatewayWithoutAResolvedPortStillSuggestsOpeningTheControlUi() + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteResult(output, Running() with { Port = null }); + + string text = output.ToString(); + Assert.DoesNotContain("Port:", text, StringComparison.Ordinal); + Assert.Contains("clawctl open", text, StringComparison.Ordinal); + } + + // A script asked for a document must not receive a browser URL or token. [Fact] public void TheJsonDocumentCarriesThePortButNoUnverifiedUrl() { diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 6ea6ee2f..d5161990 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1772,6 +1772,213 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( Assert.False(sessionRuntimeCreated); } + [Fact] + public async Task BrowserLaunchFailsWhenShellExecutionDoesNotStartAProcess() + { + await Assert.ThrowsAsync( + () => Program.LaunchBrowserAsync( + "http://127.0.0.1:18789/#token=secret", + _ => null)); + } + + [Fact] + public async Task OpenWithoutSetupFailsNormallyWithoutStartingWork() + { + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime); + var backend = (FakeMxcSessionClient)runtime.Backend; + bool browserLaunched = false; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, null, []), + ["open"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle, + launchBrowserAsync: _ => + { + browserLaunched = true; + return Task.CompletedTask; + }); + + string text = FlattenRenderedText(output.ToString()); + Assert.Equal(1, exitCode); + Assert.Contains("has not been set up", text, StringComparison.Ordinal); + Assert.Contains("clawctl setup", text, StringComparison.Ordinal); + Assert.DoesNotContain("This shouldn't happen", text, StringComparison.Ordinal); + Assert.Empty(backend.Calls); + Assert.False(browserLaunched); + } + + [Fact] + public async Task OpenWithIncompleteSetupFailsNormallyWithoutStartingWork() + { + SessionRuntime runtime = CreateSessionRuntime(); + runtime.SetupState.Write(new SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = SetupPhase.Preparing + }); + var lifecycle = new FailingFreshLifecycle(runtime); + var backend = (FakeMxcSessionClient)runtime.Backend; + bool browserLaunched = false; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, null, []), + ["open"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle, + launchBrowserAsync: _ => + { + browserLaunched = true; + return Task.CompletedTask; + }); + + string text = FlattenRenderedText(output.ToString()); + Assert.Equal(1, exitCode); + Assert.Contains("setup is incomplete", text, StringComparison.Ordinal); + Assert.Contains("clawctl setup", text, StringComparison.Ordinal); + Assert.DoesNotContain("This shouldn't happen", text, StringComparison.Ordinal); + Assert.Empty(backend.Calls); + Assert.False(browserLaunched); + } + + [Fact] + public async Task OpenWithNoRunningGatewayDoesNotRunTheDashboardOrLaunchTheBrowser() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var backend = (FakeMxcSessionClient)runtime.Backend; + int initialBackendCalls = backend.Calls.Count; + bool browserLaunched = false; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions( + Path.Combine(_testDirectory, "app"), + Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"), + []), + ["open"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: new StubbedRecoveryLifecycle(runtime), + launchBrowserAsync: _ => + { + browserLaunched = true; + return Task.CompletedTask; + }); + + string text = FlattenRenderedText(output.ToString()); + Assert.Equal(1, exitCode); + Assert.Contains("clawctl gateway-service start", text, StringComparison.Ordinal); + Assert.Equal(initialBackendCalls, backend.Calls.Count); + Assert.False(browserLaunched); + } + + [Fact] + public async Task OpenRunningGatewayLaunchesTheReturnedBrowserUrlWithoutLeakingIt() + { + SessionRuntime runtime = await SetUpSessionAsync(); + SessionRecord session = runtime.RequireSetup(); + var backend = (FakeMxcSessionClient)runtime.Backend; + const string browserUrl = "https://127.0.0.1:18789/control#token=one-time-secret"; + var logs = new List(); + var launchedUrls = new List(); + string[]? dashboardArguments = null; + File.WriteAllText( + Path.Combine(_testDirectory, "node-v24.20.0-win-x64.zip"), + "fixture"); + + runtime.GatewayState.Write(new GatewayRecord + { + SandboxId = session.SandboxId, + ProcessId = 42, + ProcessStartTimeUtc = DateTimeOffset.UtcNow, + HelperPath = runtime.HelperPath, + Port = 18789, + ObservedPorts = [18789], + }); + backend.ExecuteBehavior = _ => + { + string workspace = backend.Metadata!.EphemeralWorkspacePath; + string[] inspectionRequests = Directory.GetFiles(workspace, "inspect-*.json"); + if (inspectionRequests.Length == 1) + { + string inspectionRequestPath = inspectionRequests[0]; + SessionInspectRequest inspectionRequest = SessionInspectProtocol.ReadRequest( + File.ReadAllText(inspectionRequestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(inspectionRequestPath), + SessionInspectProtocol.SerializeResult(new SessionInspectResult + { + RequestId = inspectionRequest.RequestId, + ProcessFound = true, + StartTimeMatches = true, + PortListening = true, + ListeningPorts = [18789], + ListenerOwned = true, + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + } + + string requestPath = Directory.GetFiles(workspace, "launch-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + dashboardArguments = [.. request.Arguments!]; + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + })); + return Task.FromResult(new MxcExecutionResult( + 0, + $$"""{"ok":true,"browserUrl":"{{browserUrl}}"}""", + string.Empty)); + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions( + Path.Combine(_testDirectory, "app"), + Path.Combine(_testDirectory, "node-v24.20.0-win-x64.zip"), + []), + ["open", "--json"], + logs.Add, + output, + TextWriter.Null, + installationLifecycle: new StubbedRecoveryLifecycle(runtime), + launchBrowserAsync: url => + { + launchedUrls.Add(url); + return Task.CompletedTask; + }); + + string rendered = output.ToString(); + Assert.Equal(0, exitCode); + Assert.NotNull(dashboardArguments); + Assert.Equal( + [Path.Combine(_testDirectory, "app", "openclaw.mjs"), "dashboard", "--json"], + dashboardArguments); + Assert.Equal([browserUrl], launchedUrls); + using JsonDocument result = JsonDocument.Parse(rendered); + Assert.True(result.RootElement.GetProperty("ok").GetBoolean()); + Assert.Equal("open", result.RootElement.GetProperty("command").GetString()); + Assert.Equal("running", result.RootElement.GetProperty("gateway").GetProperty("state").GetString()); + Assert.DoesNotContain(browserUrl, rendered, StringComparison.Ordinal); + Assert.DoesNotContain("one-time-secret", rendered, StringComparison.Ordinal); + Assert.DoesNotContain(browserUrl, string.Join(Environment.NewLine, logs), StringComparison.Ordinal); + Assert.DoesNotContain("one-time-secret", string.Join(Environment.NewLine, logs), StringComparison.Ordinal); + } + [Fact] public async Task UndeterminedSupportStillProvisionsTheSession() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 32662921..733e42ae 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -37,6 +37,13 @@ private SessionExecutionRequest Request(params string[] arguments) => arguments, @"C:\work"); + private SessionCommandRequest CaptureRequest(params string[] arguments) => + new( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Program Files\nodejs\node.exe", + arguments, + @"C:\work"); + private SessionExecutor Create(Func? createRequestId = null) => new(_backend, _log.Add, createRequestId: createRequestId); @@ -103,6 +110,26 @@ private void RespondLaunched(int exitCode) => ExitCode = exitCode, }); + private void RespondAsCapturedHelper( + Func respond, + MxcExecutionResult? execution = null) + { + _backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(respond(request))); + return Task.FromResult(execution ?? new MxcExecutionResult( + 0, + "{\"browserUrl\":\"secret\"}", + "captured stderr")); + }; + } + private void RespondAsCollector( Func respond) { @@ -708,6 +735,141 @@ public void UnsafeRequestPathIsRefused() // A string-equality assertion here previously passed against a command line // that the command processor then broke apart at the first space. + [Fact] + public async Task CapturedCommandReturnsBackendOutputAfterSuccessfulHelperLaunch() + { + RespondAsCapturedHelper(request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }); + + SessionCommandCaptureResult result = await Create().ExecuteCommandCaptureAsync( + Record(), + CaptureRequest("dashboard", "--json"), + "Resolving the dashboard.", + "OpenClaw", + CancellationToken.None); + + Assert.Equal("{\"browserUrl\":\"secret\"}", result.StandardOutput); + Assert.Equal("captured stderr", result.StandardError); + Assert.Empty(_backend.AttachedCommandLines); + Assert.Single(_backend.ExecutedCommandLines); + Assert.All( + _log, + entry => Assert.DoesNotContain("secret", entry, StringComparison.Ordinal)); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task CapturedCommandRejectsMalformedHelperResult() + { + _backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json").Single(); + File.WriteAllText(SessionLaunchProtocol.ResultPathFor(requestPath), "{"); + return Task.FromResult(new MxcExecutionResult(0, "secret", string.Empty)); + }; + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteCommandCaptureAsync( + Record(), CaptureRequest(), "Resolving the dashboard.", "OpenClaw", CancellationToken.None)); + + Assert.Contains("unreadable launch result", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task CapturedCommandRejectsMissingHelperResult() + { + _backend.ExecuteBehavior = _ => Task.FromResult( + new MxcExecutionResult(0, "secret", "more secret")); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteCommandCaptureAsync( + Record(), CaptureRequest(), "Resolving the dashboard.", "OpenClaw", CancellationToken.None)); + + Assert.Contains("did not report a launch result", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain("secret", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task CapturedCommandRejectsMismatchedHelperResult() + { + RespondAsCapturedHelper(_ => new SessionLaunchResult + { + RequestId = "different-request", + Launched = true, + ExitCode = 0, + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteCommandCaptureAsync( + Record(), CaptureRequest(), "Resolving the dashboard.", "OpenClaw", CancellationToken.None)); + + Assert.Contains("different request", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task CapturedCommandRejectsBackendFailure() + { + RespondAsCapturedHelper( + request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }, + new MxcExecutionResult(23, "secret", "more secret")); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteCommandCaptureAsync( + Record(), CaptureRequest(), "Resolving the dashboard.", "OpenClaw", CancellationToken.None)); + + Assert.Contains("backend failed", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain("secret", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task CapturedCommandRejectsHelperFailure() + { + RespondAsCapturedHelper(request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = false, + Error = "The executable was not found.", + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteCommandCaptureAsync( + Record(), CaptureRequest(), "Resolving the dashboard.", "OpenClaw", CancellationToken.None)); + + Assert.Contains("could not be started", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task CapturedCommandRejectsChildFailure() + { + RespondAsCapturedHelper(request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 42, + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteCommandCaptureAsync( + Record(), CaptureRequest(), "Resolving the dashboard.", "OpenClaw", CancellationToken.None)); + + Assert.Contains("exited with code 42", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + [Fact] public async Task CancellationPropagates() { From 747ea81f17996aa1cca64c030810a1f3025976e0 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 21 Sep 2026 11:54:49 -0700 Subject: [PATCH 2/4] fix: secure clawctl open handoff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff501e8-3075-418e-9816-146dedceca22 --- scripts/LocalPackage.psm1 | 33 +++- scripts/Test-Deploy-LocalPackage.Tests.ps1 | 15 +- .../Gateway/ControlUiHandoff.cs | 8 +- src/OpenClaw.Launcher/Program.cs | 29 +++- .../Session/SessionExecutor.cs | 40 +++-- .../Session/SessionRuntime.cs | 28 +++- .../Gateway/ControlUiHandoffTests.cs | 30 +++- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 149 ++++++++++++++++-- .../Session/SessionExecutorTests.cs | 22 ++- 9 files changed, 290 insertions(+), 64 deletions(-) diff --git a/scripts/LocalPackage.psm1 b/scripts/LocalPackage.psm1 index 625c9d9d..087f22d3 100644 --- a/scripts/LocalPackage.psm1 +++ b/scripts/LocalPackage.psm1 @@ -394,6 +394,10 @@ function New-LocalPackageLayout { $manifest.Save($manifestPath) Copy-Item -LiteralPath $HostExecutable -Destination (Join-Path $LayoutDirectory 'openclaw.exe') -Force + $nodeScripts = Join-Path $LayoutDirectory 'node' + if (Test-Path -LiteralPath $nodeScripts) { Remove-Item -LiteralPath $nodeScripts -Recurse -Force } + Copy-Item -LiteralPath (Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\node') ` + -Destination $nodeScripts -Recurse $images = Join-Path $LayoutDirectory 'Images' if (Test-Path -LiteralPath $images) { Remove-Item -LiteralPath $images -Recurse -Force } Copy-Item -LiteralPath (Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\Images') ` @@ -474,10 +478,24 @@ function Test-LocalPackageOwnership { [IO.Path]::GetFullPath($LayoutDirectory).TrimEnd('\') } +function Get-LocalPackageFileInventory { + param([string]$Directory) + + return @( + Get-ChildItem -LiteralPath $Directory -File -Recurse | + Sort-Object FullName | + ForEach-Object { + $relativePath = [IO.Path]::GetRelativePath($Directory, $_.FullName) + "$relativePath`:$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" + } + ) +} + function Test-LocalPackageLayout { param( [string]$LayoutDirectory, [string]$PayloadDirectory, + [string]$NodeScriptsDirectory, [string]$RuntimeArchiveName, [string]$Architecture ) @@ -491,7 +509,8 @@ function Test-LocalPackageLayout { "session-host\$Architecture\openclaw-session-host.exe", "mxc\$Architecture\wxc-exec.exe", "mxc\$Architecture\plm.exe", - "mxc\$Architecture\mxc-runtime.json" + "mxc\$Architecture\mxc-runtime.json", + 'node\native-redirect.mjs' )) { if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory $relative) -PathType Leaf)) { return $false @@ -500,6 +519,11 @@ function Test-LocalPackageLayout { if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory 'Images') -PathType Container)) { return $false } + $expectedNodeScripts = Get-LocalPackageFileInventory -Directory $NodeScriptsDirectory + $actualNodeScripts = Get-LocalPackageFileInventory -Directory (Join-Path $LayoutDirectory 'node') + if ([string]::Join("`n", $actualNodeScripts) -cne [string]::Join("`n", $expectedNodeScripts)) { + return $false + } $link = Join-Path $LayoutDirectory 'app' if (-not (Test-Path -LiteralPath $link -PathType Container)) { return $false } $item = Get-Item -LiteralPath $link -Force @@ -865,6 +889,8 @@ function Invoke-LocalPackageDeployment { Sort-Object FullName | ForEach-Object { "$($_.Name):$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" } ) + $nodeScriptsDirectory = Join-Path $root 'src\OpenClaw.Launcher\node' + $nodeScriptHashes = Get-LocalPackageFileInventory -Directory $nodeScriptsDirectory $fingerprint = Get-LocalPackageFingerprint (@( $Architecture $payload.Directory @@ -874,7 +900,7 @@ function Invoke-LocalPackageDeployment { $sessionHostInfo.Length.ToString() $sessionHostHash (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash - ) + $imageHashes + $mxcHashes) + ) + $imageHashes + $nodeScriptHashes + $mxcHashes) $setupSatisfied = $SkipSetup -or ($null -ne $previous -and $previous['setupComplete'] -eq $true) if (-not $Force -and $null -ne $previous -and $null -ne $installed -and $installed.IsDevelopmentMode -and @@ -885,6 +911,7 @@ function Invoke-LocalPackageDeployment { $setupSatisfied -and (Test-LocalPackageLayout -LayoutDirectory $layoutDirectory ` -PayloadDirectory $payload.Directory ` + -NodeScriptsDirectory $nodeScriptsDirectory ` -RuntimeArchiveName ([IO.Path]::GetFileName($runtimeArchive)) ` -Architecture $Architecture)) { $total.Stop() @@ -920,7 +947,7 @@ function Invoke-LocalPackageDeployment { $sessionHostInfo.Length.ToString() $sessionHostHash (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash - ) + $imageHashes + $mxcHashes) + ) + $imageHashes + $nodeScriptHashes + $mxcHashes) } $manifestPath = Invoke-LocalPackagePhase $progress 'Assemble layout' { diff --git a/scripts/Test-Deploy-LocalPackage.Tests.ps1 b/scripts/Test-Deploy-LocalPackage.Tests.ps1 index b59352a6..b37488bc 100644 --- a/scripts/Test-Deploy-LocalPackage.Tests.ps1 +++ b/scripts/Test-Deploy-LocalPackage.Tests.ps1 @@ -72,6 +72,8 @@ function New-Fixture { $project = Join-Path $root 'src\OpenClaw.Launcher' New-Item -Path (Join-Path $project 'Images') -ItemType Directory -Force | Out-Null [IO.File]::WriteAllText((Join-Path $project 'Images\StoreLogo.png'), 'fixture image') + New-Item -Path (Join-Path $project 'node') -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $project 'node\native-redirect.mjs'), 'fixture redirect') [IO.File]::WriteAllText((Join-Path $project 'Package.appxmanifest'), @' @@ -246,6 +248,8 @@ try { Assert-True ((Get-Content (Join-Path $layout 'app\openclaw.mjs') -Raw) -eq 'first payload') 'Layout does not expose the payload application.' Assert-True ((Get-Item (Join-Path $layout 'app')).Attributes -band [IO.FileAttributes]::ReparsePoint) 'The layout copied the application instead of linking it.' Assert-True (Test-Path (Join-Path $layout 'openclaw.exe')) 'Layout is missing the launcher.' + Assert-True (Test-Path (Join-Path $layout 'node\native-redirect.mjs')) ` + 'Layout is missing the native redirect script.' Assert-True ( Test-Path (Join-Path $layout 'session-host\x64\openclaw-session-host.exe') ) 'Layout is missing the session host.' @@ -422,7 +426,7 @@ try { Assert-True ($afterSkip.Changed -and $sk.Setups -eq 1) 'A run after -SkipSetup did not complete the setup it skipped.' # A damaged live layout must not be reported as up to date. - foreach ($break in @('openclaw.exe', 'Images', 'app', 'runtime')) { + foreach ($break in @('openclaw.exe', 'Images', 'app', 'runtime', 'node\native-redirect.mjs')) { $d = New-Fixture $deployed = Invoke-Fixture $d $target = Join-Path $deployed.LayoutDirectory $break @@ -432,6 +436,15 @@ try { Assert-True (Test-Path -LiteralPath $target) "A layout missing '$break' was not repaired." } + $modifiedNodeScript = New-Fixture + $modifiedDeployment = Invoke-Fixture $modifiedNodeScript + $redirectScript = Join-Path $modifiedDeployment.LayoutDirectory 'node\native-redirect.mjs' + [IO.File]::WriteAllText($redirectScript, 'corrupt redirect') + $repairedNodeScript = Invoke-Fixture $modifiedNodeScript + Assert-True $repairedNodeScript.Changed 'A modified redirect script was reported as up to date.' + Assert-True ((Get-Content -LiteralPath $redirectScript -Raw) -eq 'fixture redirect') ` + 'A modified redirect script was not repaired.' + # The layout runtime copy is replaced, not trusted by name. $c = New-Fixture $cDeployed = Invoke-Fixture $c diff --git a/src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs b/src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs index e5029731..05d3350a 100644 --- a/src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs +++ b/src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs @@ -11,7 +11,10 @@ internal sealed partial class ControlUiHandoffJsonContext : JsonSerializerContex internal static class ControlUiHandoffParser { - internal static bool TryParse(string output, out string? browserUrl) + internal static bool TryParse( + string output, + IReadOnlyCollection observedPorts, + out string? browserUrl) { browserUrl = null; try @@ -23,7 +26,8 @@ internal static bool TryParse(string output, out string? browserUrl) string.IsNullOrWhiteSpace(handoff.BrowserUrl) || !Uri.TryCreate(handoff.BrowserUrl, UriKind.Absolute, out Uri? candidate) || (candidate.Scheme != Uri.UriSchemeHttp && candidate.Scheme != Uri.UriSchemeHttps) || - !candidate.IsLoopback) + !candidate.IsLoopback || + !observedPorts.Contains(candidate.Port)) { return false; } diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 768a14b2..16bd4189 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -449,7 +449,8 @@ internal static async Task RunControlAsync( ClawCtlOutputOptions? controlOutputOptions = null, Func? getLogonSessionId = null, TimeProvider? clock = null, - Func? launchBrowserAsync = null) + Func? launchBrowserAsync = null, + Action? beforeBrowserValidation = null) { Session.IInstallationLifecycle lifecycle = installationLifecycle ?? Session.InstallationLifecycle.Production; @@ -648,13 +649,22 @@ await Gateway.GatewayRuntime.Create( record.WorkspacePath!) { PathPrefix = Path.GetDirectoryName(nodePath), - AdditionalEnvironment = OpenClawRuntimeEnvironment.Build() + AdditionalEnvironment = BuildRuntimeEnvironment( + runtime, + applicationDirectory, + isInteractive: false, + readEnvironmentVariable ?? Environment.GetEnvironmentVariable), + NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime), + NativeRootPath = runtime.GetAgentNativeRoot() }, "Resolving the Control UI handoff in the isolated session.", "OpenClaw dashboard", cancellationToken) .ConfigureAwait(false); - if (!Gateway.ControlUiHandoffParser.TryParse(capture.StandardOutput, out string? browserUrl) || + if (!Gateway.ControlUiHandoffParser.TryParse( + capture.StandardOutput, + gateway.Record?.ObservedPorts ?? [], + out string? browserUrl) || browserUrl is null) { return WriteResult(new OpenCommandResult( @@ -663,6 +673,15 @@ await Gateway.GatewayRuntime.Create( 1)); } + beforeBrowserValidation?.Invoke(); + if (!runtime.IsCurrentSessionRecord(record)) + { + return WriteResult(new OpenCommandResult( + gateway.State, + "The isolated session changed before the Control UI could be opened. Retry the command.", + 1)); + } + try { await (launchBrowserAsync ?? (url => LaunchBrowserAsync(url)))(browserUrl) @@ -833,11 +852,11 @@ internal static Task LaunchBrowserAsync( string browserUrl, Func? startProcess = null) { + // Shell activation returns null when an already-running browser handles the URL. _ = (startProcess ?? Process.Start)(new ProcessStartInfo(browserUrl) { UseShellExecute = true - }) ?? throw new InvalidOperationException( - "Windows did not start a process for the default browser."); + }); return Task.CompletedTask; } diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index 6661fbd8..39287a91 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -177,18 +177,7 @@ public async Task ExecuteCommandAsync( string requestPath = operation.FilePath("launch", requestId); string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); - var launchRequest = new SessionLaunchRequest - { - RequestId = requestId, - Executable = request.Executable, - Arguments = request.Arguments, - WorkingDirectory = request.WorkingDirectory, - Environment = MergeEnvironment( - _buildEnvironment(), request.AdditionalEnvironment), - PathPrefix = request.PathPrefix, - NodeOptionsSuffix = request.NodeOptionsSuffix, - NativeRootPath = request.NativeRootPath, - }; + SessionLaunchRequest launchRequest = CreateLaunchRequest(requestId, request); try { @@ -239,16 +228,7 @@ public async Task ExecuteCommandCaptureAsync( using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); string requestPath = operation.FilePath("launch", requestId); string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); - var launchRequest = new SessionLaunchRequest - { - RequestId = requestId, - Executable = request.Executable, - Arguments = request.Arguments, - WorkingDirectory = request.WorkingDirectory, - Environment = MergeEnvironment( - _buildEnvironment(), request.AdditionalEnvironment), - PathPrefix = request.PathPrefix, - }; + SessionLaunchRequest launchRequest = CreateLaunchRequest(requestId, request); try { @@ -274,6 +254,22 @@ await operation.WriteTextNewAsync( } } + private SessionLaunchRequest CreateLaunchRequest( + string requestId, + SessionCommandRequest request) => + new() + { + RequestId = requestId, + Executable = request.Executable, + Arguments = request.Arguments, + WorkingDirectory = request.WorkingDirectory, + Environment = MergeEnvironment( + _buildEnvironment(), request.AdditionalEnvironment), + PathPrefix = request.PathPrefix, + NodeOptionsSuffix = request.NodeOptionsSuffix, + NativeRootPath = request.NativeRootPath, + }; + /// /// Asks the guest to stage diagnostic files into the shared workspace. /// diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 20a2d78d..4f446605 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -138,7 +138,10 @@ internal static SessionRuntime Create( return new SessionRuntime( coordinator, - new SessionExecutor(client, log, isCurrentRecord: IsCurrentSessionRecord), + new SessionExecutor( + client, + log, + isCurrentRecord: record => IsCurrentSessionRecord(coordinator, record)), client, ResolveHelperPath(baseDirectory), applicationId, @@ -147,13 +150,22 @@ internal static SessionRuntime Create( new GatewayStateStore(paths.GatewayStatePath), paths.SessionStatePath + "_Installation"); - bool IsCurrentSessionRecord(SessionRecord record) - { - SessionStatus status = coordinator.GetRecordedStatus(); - return status.Record is not null && - string.Equals(status.Record.SandboxId, record.SandboxId, StringComparison.Ordinal) && - string.Equals(status.Record.Generation, record.Generation, StringComparison.Ordinal); - } + } + + public bool IsCurrentSessionRecord(SessionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return IsCurrentSessionRecord(Coordinator, record); + } + + private static bool IsCurrentSessionRecord( + SessionCoordinator coordinator, + SessionRecord record) + { + SessionStatus status = coordinator.GetRecordedStatus(); + return status.Record is not null && + string.Equals(status.Record.SandboxId, record.SandboxId, StringComparison.Ordinal) && + string.Equals(status.Record.Generation, record.Generation, StringComparison.Ordinal); } /// diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs index e17e136f..c6f78287 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/ControlUiHandoffTests.cs @@ -11,6 +11,7 @@ public void AcceptsAbsoluteLoopbackHttpUrlWithoutRewritingIt() bool parsed = ControlUiHandoffParser.TryParse( $$"""{"ok":true,"browserUrl":"{{url}}"}""", + [18789], out string? browserUrl); Assert.True(parsed); @@ -26,9 +27,36 @@ public void AcceptsAbsoluteLoopbackHttpUrlWithoutRewritingIt() [InlineData("not-json")] public void RejectsUnsafeOrInvalidHandoffs(string output) { - bool parsed = ControlUiHandoffParser.TryParse(output, out string? browserUrl); + bool parsed = ControlUiHandoffParser.TryParse( + output, + [18789], + out string? browserUrl); + + Assert.False(parsed); + Assert.Null(browserUrl); + } + [Fact] + public void RejectsLoopbackUrlOnAnUnobservedPort() + { + bool parsed = ControlUiHandoffParser.TryParse( + """{"ok":true,"browserUrl":"http://127.0.0.1:3000/#token=secret"}""", + [18789], + out string? browserUrl); Assert.False(parsed); Assert.Null(browserUrl); } + + [Fact] + public void RejectsHandoffWhenNoGatewayPortWasObserved() + { + bool parsed = ControlUiHandoffParser.TryParse( + """{"ok":true,"browserUrl":"http://127.0.0.1:18789/#token=secret"}""", + [], + out string? browserUrl); + + Assert.False(parsed); + Assert.Null(browserUrl); + } + } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index d5161990..e91b1308 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1773,12 +1773,11 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( } [Fact] - public async Task BrowserLaunchFailsWhenShellExecutionDoesNotStartAProcess() + public async Task BrowserLaunchSucceedsWhenShellExecutionReturnsNoProcess() { - await Assert.ThrowsAsync( - () => Program.LaunchBrowserAsync( - "http://127.0.0.1:18789/#token=secret", - _ => null)); + await Program.LaunchBrowserAsync( + "http://127.0.0.1:18789/#token=secret", + _ => null); } [Fact] @@ -1880,19 +1879,30 @@ public async Task OpenWithNoRunningGatewayDoesNotRunTheDashboardOrLaunchTheBrows Assert.False(browserLaunched); } - [Fact] - public async Task OpenRunningGatewayLaunchesTheReturnedBrowserUrlWithoutLeakingIt() + [Theory] + [InlineData(18789, false, 0, 1)] + [InlineData(3000, false, 1, 0)] + [InlineData(18789, true, 1, 0)] + public async Task OpenBindsBrowserActivationToTheObservedGatewayAndCurrentSession( + int handoffPort, + bool replaceSessionBeforeValidation, + int expectedExitCode, + int expectedBrowserLaunches) { SessionRuntime runtime = await SetUpSessionAsync(); SessionRecord session = runtime.RequireSetup(); var backend = (FakeMxcSessionClient)runtime.Backend; - const string browserUrl = "https://127.0.0.1:18789/control#token=one-time-secret"; + string browserUrl = $"https://127.0.0.1:{handoffPort}/control#token=one-time-secret"; var logs = new List(); var launchedUrls = new List(); - string[]? dashboardArguments = null; + SessionLaunchRequest? dashboardRequest = null; File.WriteAllText( Path.Combine(_testDirectory, "node-v24.20.0-win-x64.zip"), "fixture"); + string nativeRoot = Path.Combine(_testDirectory, "agent-native"); + Directory.CreateDirectory(nativeRoot); + SetupRecord staged = runtime.SetupState.Read(runtime.ApplicationId).Record!; + runtime.SetupState.Write(staged with { AgentNativeRoot = nativeRoot }); runtime.GatewayState.Write(new GatewayRecord { @@ -1930,7 +1940,7 @@ public async Task OpenRunningGatewayLaunchesTheReturnedBrowserUrlWithoutLeakingI .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( File.ReadAllText(requestPath)); - dashboardArguments = [.. request.Arguments!]; + dashboardRequest = request; File.WriteAllText( SessionLaunchProtocol.ResultPathFor(requestPath), SessionLaunchProtocol.SerializeResult(new SessionLaunchResult @@ -1959,18 +1969,37 @@ public async Task OpenRunningGatewayLaunchesTheReturnedBrowserUrlWithoutLeakingI launchBrowserAsync: url => { launchedUrls.Add(url); - return Task.CompletedTask; - }); + return Program.LaunchBrowserAsync(url, _ => null); + }, + beforeBrowserValidation: replaceSessionBeforeValidation + ? () => new SessionStateStore(runtime.Paths.SessionStatePath).Write( + session with { Generation = "replacement-generation" }) + : null); string rendered = output.ToString(); - Assert.Equal(0, exitCode); - Assert.NotNull(dashboardArguments); + Assert.Equal(expectedExitCode, exitCode); + Assert.NotNull(dashboardRequest); Assert.Equal( [Path.Combine(_testDirectory, "app", "openclaw.mjs"), "dashboard", "--json"], - dashboardArguments); - Assert.Equal([browserUrl], launchedUrls); + dashboardRequest.Arguments); + Assert.Contains( + OpenClawRuntimeEnvironment.NativeRedirectFileName, + dashboardRequest.NodeOptionsSuffix, + StringComparison.Ordinal); + Assert.Equal(nativeRoot, dashboardRequest.NativeRootPath); + foreach ((string name, string value) in OpenClawRuntimeEnvironment.BuildNativeRedirect( + Path.Combine(_testDirectory, "app"), + nativeRoot)) + { + Assert.Equal(value, dashboardRequest.Environment![name]); + } + Assert.Equal(expectedBrowserLaunches, launchedUrls.Count); + if (expectedBrowserLaunches == 1) + { + Assert.Equal(browserUrl, launchedUrls[0]); + } using JsonDocument result = JsonDocument.Parse(rendered); - Assert.True(result.RootElement.GetProperty("ok").GetBoolean()); + Assert.Equal(expectedExitCode == 0, result.RootElement.GetProperty("ok").GetBoolean()); Assert.Equal("open", result.RootElement.GetProperty("command").GetString()); Assert.Equal("running", result.RootElement.GetProperty("gateway").GetProperty("state").GetString()); Assert.DoesNotContain(browserUrl, rendered, StringComparison.Ordinal); @@ -1979,6 +2008,92 @@ public async Task OpenRunningGatewayLaunchesTheReturnedBrowserUrlWithoutLeakingI Assert.DoesNotContain("one-time-secret", string.Join(Environment.NewLine, logs), StringComparison.Ordinal); } + [Fact] + public async Task OpenRunningGatewayHandlesBrowserShellFailureWithoutLeakingTheAuthenticatedUrl() + { + SessionRuntime runtime = await SetUpSessionAsync(); + SessionRecord session = runtime.RequireSetup(); + var backend = (FakeMxcSessionClient)runtime.Backend; + const string browserUrl = "https://127.0.0.1:18789/control#token=one-time-secret"; + var logs = new List(); + File.WriteAllText( + Path.Combine(_testDirectory, "node-v24.20.0-win-x64.zip"), + "fixture"); + + runtime.GatewayState.Write(new GatewayRecord + { + SandboxId = session.SandboxId, + ProcessId = 42, + ProcessStartTimeUtc = DateTimeOffset.UtcNow, + HelperPath = runtime.HelperPath, + Port = 18789, + ObservedPorts = [18789], + }); + backend.ExecuteBehavior = _ => + { + string workspace = backend.Metadata!.EphemeralWorkspacePath; + string[] inspectionRequests = Directory.GetFiles(workspace, "inspect-*.json"); + if (inspectionRequests.Length == 1) + { + string inspectionRequestPath = inspectionRequests[0]; + SessionInspectRequest inspectionRequest = SessionInspectProtocol.ReadRequest( + File.ReadAllText(inspectionRequestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(inspectionRequestPath), + SessionInspectProtocol.SerializeResult(new SessionInspectResult + { + RequestId = inspectionRequest.RequestId, + ProcessFound = true, + StartTimeMatches = true, + PortListening = true, + ListeningPorts = [18789], + ListenerOwned = true, + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + } + + string requestPath = Directory.GetFiles(workspace, "launch-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + 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(new MxcExecutionResult( + 0, + $$"""{"ok":true,"browserUrl":"{{browserUrl}}"}""", + string.Empty)); + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions( + Path.Combine(_testDirectory, "app"), + Path.Combine(_testDirectory, "node-v24.20.0-win-x64.zip"), + []), + ["open", "--json"], + logs.Add, + output, + TextWriter.Null, + installationLifecycle: new StubbedRecoveryLifecycle(runtime), + launchBrowserAsync: url => Program.LaunchBrowserAsync( + url, + _ => throw new NotSupportedException($"shell activation failed for {browserUrl}"))); + + string rendered = output.ToString(); + Assert.NotEqual(0, exitCode); + Assert.Contains("default browser could not be opened", rendered, StringComparison.Ordinal); + Assert.DoesNotContain(browserUrl, rendered, StringComparison.Ordinal); + Assert.DoesNotContain("one-time-secret", rendered, StringComparison.Ordinal); + Assert.DoesNotContain(browserUrl, string.Join(Environment.NewLine, logs), StringComparison.Ordinal); + Assert.DoesNotContain("one-time-secret", string.Join(Environment.NewLine, logs), StringComparison.Ordinal); + } + [Fact] public async Task UndeterminedSupportStillProvisionsTheSession() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 733e42ae..8d235f73 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -738,22 +738,34 @@ public void UnsafeRequestPathIsRefused() [Fact] public async Task CapturedCommandReturnsBackendOutputAfterSuccessfulHelperLaunch() { - RespondAsCapturedHelper(request => new SessionLaunchResult + SessionLaunchRequest? delivered = null; + RespondAsCapturedHelper(request => { - RequestId = request.RequestId, - Launched = true, - ExitCode = 0, + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; }); SessionCommandCaptureResult result = await Create().ExecuteCommandCaptureAsync( Record(), - CaptureRequest("dashboard", "--json"), + CaptureRequest("dashboard", "--json") with + { + NodeOptionsSuffix = "--require C:\\agent\\native-redirect.cjs", + NativeRootPath = @"C:\agent\native", + }, "Resolving the dashboard.", "OpenClaw", CancellationToken.None); Assert.Equal("{\"browserUrl\":\"secret\"}", result.StandardOutput); Assert.Equal("captured stderr", result.StandardError); + Assert.NotNull(delivered); + Assert.Equal("--require C:\\agent\\native-redirect.cjs", delivered.NodeOptionsSuffix); + Assert.Equal(@"C:\agent\native", delivered.NativeRootPath); Assert.Empty(_backend.AttachedCommandLines); Assert.Single(_backend.ExecutedCommandLines); Assert.All( From 867f0b5baabd326965664629b9db5c8c0d321dba Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 21 Sep 2026 12:25:03 -0700 Subject: [PATCH 3/4] fix: open the Control UI when the gateway uses a custom port Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff501e8-3075-418e-9816-146dedceca22 --- src/OpenClaw.Launcher/Program.cs | 24 +++++++++++--- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 33 +++++++++++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 16bd4189..b8047a5c 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher; @@ -639,6 +640,23 @@ await Gateway.GatewayRuntime.Create( string applicationDirectory = GetPackagedApplicationDirectory(options); string nodePath = runtime.RequireAgentNodePath( GetPackagedNodeArchivePath(options)); + IReadOnlyDictionary dashboardEnvironment = + BuildRuntimeEnvironment( + runtime, + applicationDirectory, + isInteractive: false, + readEnvironmentVariable ?? Environment.GetEnvironmentVariable); + if (gateway.Record?.ObservedPorts is { Count: 1 } observedPorts) + { + dashboardEnvironment = Session.SessionExecutor.MergeEnvironment( + dashboardEnvironment, + new Dictionary + { + [Gateway.GatewayConfigurationStore.PortVariable] = + observedPorts[0].ToString(CultureInfo.InvariantCulture) + }); + } + Session.SessionCommandCaptureResult capture = await runtime.Executor .ExecuteCommandCaptureAsync( record, @@ -649,11 +667,7 @@ await Gateway.GatewayRuntime.Create( record.WorkspacePath!) { PathPrefix = Path.GetDirectoryName(nodePath), - AdditionalEnvironment = BuildRuntimeEnvironment( - runtime, - applicationDirectory, - isInteractive: false, - readEnvironmentVariable ?? Environment.GetEnvironmentVariable), + AdditionalEnvironment = dashboardEnvironment, NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime), NativeRootPath = runtime.GetAgentNativeRoot() }, diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index e91b1308..c105c78f 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1880,18 +1880,28 @@ public async Task OpenWithNoRunningGatewayDoesNotRunTheDashboardOrLaunchTheBrows } [Theory] - [InlineData(18789, false, 0, 1)] - [InlineData(3000, false, 1, 0)] - [InlineData(18789, true, 1, 0)] + [InlineData(51789, 51789, 0, false, 0, 1, true)] + [InlineData(3000, 18789, 0, false, 1, 0, true)] + [InlineData(18789, 18789, 0, true, 1, 0, true)] + [InlineData(51789, 51789, 51790, false, 0, 1, false)] + [InlineData(51789, 0, 0, false, 1, 0, false)] public async Task OpenBindsBrowserActivationToTheObservedGatewayAndCurrentSession( int handoffPort, + int observedPort, + int additionalObservedPort, bool replaceSessionBeforeValidation, int expectedExitCode, - int expectedBrowserLaunches) + int expectedBrowserLaunches, + bool expectedPortOverride) { SessionRuntime runtime = await SetUpSessionAsync(); SessionRecord session = runtime.RequireSetup(); var backend = (FakeMxcSessionClient)runtime.Backend; + int[] observedPorts = observedPort == 0 + ? [] + : additionalObservedPort == 0 + ? [observedPort] + : [observedPort, additionalObservedPort]; string browserUrl = $"https://127.0.0.1:{handoffPort}/control#token=one-time-secret"; var logs = new List(); var launchedUrls = new List(); @@ -1911,7 +1921,7 @@ public async Task OpenBindsBrowserActivationToTheObservedGatewayAndCurrentSessio ProcessStartTimeUtc = DateTimeOffset.UtcNow, HelperPath = runtime.HelperPath, Port = 18789, - ObservedPorts = [18789], + ObservedPorts = observedPorts, }); backend.ExecuteBehavior = _ => { @@ -1930,7 +1940,7 @@ public async Task OpenBindsBrowserActivationToTheObservedGatewayAndCurrentSessio ProcessFound = true, StartTimeMatches = true, PortListening = true, - ListeningPorts = [18789], + ListeningPorts = observedPorts, ListenerOwned = true, })); return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); @@ -1993,6 +2003,17 @@ public async Task OpenBindsBrowserActivationToTheObservedGatewayAndCurrentSessio { Assert.Equal(value, dashboardRequest.Environment![name]); } + if (expectedPortOverride) + { + Assert.Equal( + observedPort.ToString(System.Globalization.CultureInfo.InvariantCulture), + dashboardRequest.Environment![GatewayConfigurationStore.PortVariable]); + } + else + { + Assert.False( + dashboardRequest.Environment!.ContainsKey(GatewayConfigurationStore.PortVariable)); + } Assert.Equal(expectedBrowserLaunches, launchedUrls.Count); if (expectedBrowserLaunches == 1) { From 04b5124c241fea9d0b369921516655e575773edb Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 21 Sep 2026 12:48:53 -0700 Subject: [PATCH 4/4] test: validate the handoff against upstream's real response shape Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff501e8-3075-418e-9816-146dedceca22 --- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index c105c78f..390d5e12 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1961,7 +1961,11 @@ public async Task OpenBindsBrowserActivationToTheObservedGatewayAndCurrentSessio })); return Task.FromResult(new MxcExecutionResult( 0, - $$"""{"ok":true,"browserUrl":"{{browserUrl}}"}""", + // Mirrors the packaged upstream `dashboard --json` field layout so the + // handoff is validated against the shape the guest actually returns. + $$""" + {"ok":true,"url":"https://127.0.0.1:{{handoffPort}}/","httpUrl":"https://127.0.0.1:{{handoffPort}}/","wsUrl":"ws://127.0.0.1:{{handoffPort}}","port":{{handoffPort}},"tokenIncluded":false,"browserUrl":"{{browserUrl}}","browserBootstrapExpiresAtMs":1790019149639} + """, string.Empty)); }; using var output = new StringWriter();