From 50b4db5e174943b8612a82dc852eafe9ec65a4ec Mon Sep 17 00:00:00 2001 From: "Anna Koenig (Dingler)" Date: Mon, 21 Sep 2026 11:25:09 -0700 Subject: [PATCH 1/4] fix: keep native addons available in agent turns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53ed1116-ce5e-44e8-82bb-aa010e7b78b1 --- src/OpenClaw.Launcher/Program.cs | 28 ++++-- .../Session/AgentToolShim.cs | 29 +++++- .../SessionToolInstaller.cs | 11 +++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 34 ++++++- .../Session/SessionToolInstallerTests.cs | 95 +++++++++++++++++++ 5 files changed, 183 insertions(+), 14 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 73a379c4..4da3ce49 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -392,10 +392,12 @@ await guidance.EvaluateAsync( /// account owns. /// /// - /// Built in one place so every launch path - foreground, gateway, and the - /// agent's own shell - resolves native addons the same way. The redirect's - /// preload is not here: it belongs in the agent's NODE_OPTIONS, and - /// this process's own NODE_OPTIONS is the host's, not the agent's. + /// Built in one place so foreground and gateway launch paths resolve native + /// addons the same way. The agent shell carries equivalent values through + /// its command shim because agent tooling may replace process environment + /// values before invoking openclaw. The redirect's preload is not + /// here: it belongs in the agent's NODE_OPTIONS, and this process's + /// own NODE_OPTIONS is the host's, not the agent's. /// private static IReadOnlyDictionary BuildRuntimeEnvironment( Session.SessionRuntime runtime, @@ -1125,6 +1127,11 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) "The installed agent command shim has no parent directory."), installedTools.ShimPath!); Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); + string? nativeRootPath = runtime.GetAgentNativeRoot(); + string? nativeRedirectOption = nativeRootPath is { Length: > 0 } + ? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption( + ResolveNativeRedirectPreloadPath()) + : null; return await runtime.Executor.ExecuteCommandAsync( record, @@ -1139,14 +1146,15 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) record.WorkspacePath!) { AdditionalEnvironment = Session.SessionExecutor.MergeEnvironment( - BuildRuntimeEnvironment( - runtime, - applicationDirectory, + OpenClawRuntimeEnvironment.Build( WindowsHostConsole.Instance.IsInteractive, Environment.GetEnvironmentVariable), - Session.AgentToolShim.BuildEnvironment(agentNodePath, applicationDirectory)), - NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime), - NativeRootPath = runtime.GetAgentNativeRoot() + Session.AgentToolShim.BuildEnvironment( + agentNodePath, + applicationDirectory, + nativeRootPath, + nativeRedirectOption)), + NativeRootPath = nativeRootPath }, $"Opening {shell.DisplayName} in the isolated session.", shell.DisplayName, diff --git a/src/OpenClaw.Launcher/Session/AgentToolShim.cs b/src/OpenClaw.Launcher/Session/AgentToolShim.cs index d0318a15..9eb7367f 100644 --- a/src/OpenClaw.Launcher/Session/AgentToolShim.cs +++ b/src/OpenClaw.Launcher/Session/AgentToolShim.cs @@ -8,18 +8,43 @@ internal static class AgentToolShim { internal const string NodeVariable = "OPENCLAW_SHIM_NODE"; internal const string EntryPointVariable = "OPENCLAW_SHIM_ENTRY"; + internal const string NativeApplicationRootVariable = + "OPENCLAW_SHIM_NATIVE_APP_ROOT"; + internal const string NativeStagedRootVariable = + "OPENCLAW_SHIM_NATIVE_STAGED_ROOT"; + internal const string NodeOptionsSuffixVariable = + "OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX"; public static IReadOnlyDictionary BuildEnvironment( string nodePath, - string applicationDirectory) + string applicationDirectory, + string? nativeRootPath = null, + string? nodeOptionsSuffix = null) { ArgumentException.ThrowIfNullOrWhiteSpace(nodePath); ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory); - return new Dictionary(StringComparer.OrdinalIgnoreCase) + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) { [NodeVariable] = nodePath, [EntryPointVariable] = Path.Combine(applicationDirectory, "openclaw.mjs"), }; + + if (string.IsNullOrWhiteSpace(nativeRootPath)) + { + return environment; + } + + if (string.IsNullOrWhiteSpace(nodeOptionsSuffix)) + { + throw new ArgumentException( + "A native redirect preload is required with a staged native root.", + nameof(nodeOptionsSuffix)); + } + + environment[NativeApplicationRootVariable] = applicationDirectory; + environment[NativeStagedRootVariable] = nativeRootPath; + environment[NodeOptionsSuffixVariable] = nodeOptionsSuffix; + return environment; } } diff --git a/src/OpenClaw.SessionHost/SessionToolInstaller.cs b/src/OpenClaw.SessionHost/SessionToolInstaller.cs index 3c6d12d8..0620f8cf 100644 --- a/src/OpenClaw.SessionHost/SessionToolInstaller.cs +++ b/src/OpenClaw.SessionHost/SessionToolInstaller.cs @@ -13,6 +13,17 @@ internal static class SessionToolInstaller "setlocal\r\n" + "if not defined OPENCLAW_SHIM_NODE goto :missing\r\n" + "if not defined OPENCLAW_SHIM_ENTRY goto :missing\r\n" + + "if not defined OPENCLAW_SHIM_NATIVE_STAGED_ROOT goto :launch\r\n" + + "if not defined OPENCLAW_SHIM_NATIVE_APP_ROOT goto :missing\r\n" + + "if not defined OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX goto :missing\r\n" + + "set \"OPENCLAW_NATIVE_APP_ROOT=%OPENCLAW_SHIM_NATIVE_APP_ROOT%\"\r\n" + + "set \"OPENCLAW_NATIVE_STAGED_ROOT=%OPENCLAW_SHIM_NATIVE_STAGED_ROOT%\"\r\n" + + "if defined NODE_OPTIONS goto :append-node-options\r\n" + + "set \"NODE_OPTIONS=%OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX%\"\r\n" + + "goto :launch\r\n" + + ":append-node-options\r\n" + + "set \"NODE_OPTIONS=%NODE_OPTIONS% %OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX%\"\r\n" + + ":launch\r\n" + "\"%OPENCLAW_SHIM_NODE%\" \"%OPENCLAW_SHIM_ENTRY%\" %*\r\n" + "exit /b %ERRORLEVEL%\r\n" + ":missing\r\n" + diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 6ea6ee2f..b41d8a79 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -399,7 +399,7 @@ public async Task AgentControlCExitsSilentlyWithPortableInterruptedCode() } [Fact] - public async Task PowerShellControlCExitsSilentlyWithPortableInterruptedCode() + public async Task PowerShellLaunchUsesTheNativeAwareShimAndMapsControlC() { string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); HostOptions options = CreateSetupOptions(applicationDirectory); @@ -413,6 +413,10 @@ public async Task PowerShellControlCExitsSilentlyWithPortableInterruptedCode() TextWriter.Null, installationLifecycle: lifecycle); Assert.Equal(0, setupExitCode); + string nativeRoot = Path.Combine(_testDirectory, "agent-native", "0123456789abcdef"); + Directory.CreateDirectory(nativeRoot); + SetupRecord staged = runtime.SetupState.Read(runtime.ApplicationId).Record!; + runtime.SetupState.Write(staged with { AgentNativeRoot = nativeRoot }); _lastSessionBackend!.ExecuteBehavior = _ => { string requestPath = Directory.GetFiles( @@ -429,8 +433,15 @@ public async Task PowerShellControlCExitsSilentlyWithPortableInterruptedCode() })); return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); }; + SessionLaunchRequest? launched = null; _lastSessionBackend!.AttachedBehavior = _ => - Task.FromResult(unchecked((int)0xc000013a)); + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "launch-*.json").Single(); + launched = SessionLaunchProtocol.ReadRequest(File.ReadAllText(requestPath)); + return Task.FromResult(unchecked((int)0xc000013a)); + }; using var output = new StringWriter(); using var error = new StringWriter(); @@ -445,6 +456,25 @@ public async Task PowerShellControlCExitsSilentlyWithPortableInterruptedCode() Assert.Equal(130, exitCode); Assert.Equal(string.Empty, output.ToString()); Assert.Equal(string.Empty, error.ToString()); + Assert.NotNull(launched); + Assert.Equal(nativeRoot, launched.NativeRootPath); + Assert.Null(launched.NodeOptionsSuffix); + Assert.False( + launched.Environment!.ContainsKey( + OpenClawRuntimeEnvironment.NativeApplicationRootVariable)); + Assert.False( + launched.Environment.ContainsKey( + OpenClawRuntimeEnvironment.NativeStagedRootVariable)); + Assert.Equal( + applicationDirectory, + launched.Environment[AgentToolShim.NativeApplicationRootVariable]); + Assert.Equal( + nativeRoot, + launched.Environment[AgentToolShim.NativeStagedRootVariable]); + Assert.Contains( + OpenClawRuntimeEnvironment.NativeRedirectFileName, + launched.Environment[AgentToolShim.NodeOptionsSuffixVariable], + StringComparison.Ordinal); } [Fact] diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs index 3a64b886..913a0e87 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using OpenClaw.Launcher.Session; using OpenClaw.SessionHost; using OpenClaw.SessionProtocol; @@ -41,4 +43,97 @@ public void GuestInstallerCreatesTheCommandShimInItsWorkspace() Path.Combine(workspace, ".openclaw-tools", "openclaw.cmd"), result.ShimPath); } + + [Theory] + [InlineData(null)] + [InlineData("--max-old-space-size=4096")] + public async Task CommandShimRestoresNativeRedirectForAgentInvocations( + string? existingNodeOptions) + { + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(workspace); + string requestPath = Path.Combine(workspace, "tools.json"); + File.WriteAllText( + requestPath, + SessionRuntimeProtocol.SerializeToolInstallRequest(new SessionToolInstallRequest + { + RequestId = "tools1", + WorkspacePath = workspace + })); + Assert.Equal( + 0, + SessionToolInstaller.Run( + requestPath, + File.ReadAllText, + File.WriteAllText)); + SessionToolInstallResult result = SessionRuntimeProtocol.ReadToolInstallResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(requestPath))); + + string outputPath = Path.Combine(workspace, "environment.txt"); + string fakeNodePath = Path.Combine(workspace, "node.cmd"); + File.WriteAllText( + fakeNodePath, + "@echo off\r\n" + + "> \"%OPENCLAW_TEST_OUTPUT%\" echo %OPENCLAW_NATIVE_APP_ROOT%\r\n" + + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %OPENCLAW_NATIVE_STAGED_ROOT%\r\n" + + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %NODE_OPTIONS%\r\n"); + + string applicationDirectory = @"C:\Program Files\WindowsApps\OpenClaw\app"; + string nativeRoot = @"C:\Users\agent\AppData\Local\openclaw\native\abc"; + string nodeOptionsSuffix = + "--import file:///C:/Program%20Files/WindowsApps/OpenClaw/node/native-redirect.mjs"; + IReadOnlyDictionary shimEnvironment = + AgentToolShim.BuildEnvironment( + fakeNodePath, + applicationDirectory, + nativeRoot, + nodeOptionsSuffix); + string commandInterpreter = Environment.GetEnvironmentVariable("ComSpec") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe"); + var startInfo = new ProcessStartInfo + { + FileName = commandInterpreter, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workspace + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/s"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add($"\"{result.ShimPath}\" doctor"); + startInfo.Environment.Remove(OpenClawRuntimeEnvironment.NativeApplicationRootVariable); + startInfo.Environment.Remove(OpenClawRuntimeEnvironment.NativeStagedRootVariable); + if (existingNodeOptions is null) + { + startInfo.Environment.Remove(OpenClawRuntimeEnvironment.NodeOptionsVariable); + } + else + { + startInfo.Environment[OpenClawRuntimeEnvironment.NodeOptionsVariable] = + existingNodeOptions; + } + startInfo.Environment["OPENCLAW_TEST_OUTPUT"] = outputPath; + foreach ((string name, string value) in shimEnvironment) + { + startInfo.Environment[name] = value; + } + + using Process process = new() { StartInfo = startInfo }; + Assert.True(process.Start()); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await process.WaitForExitAsync(timeout.Token); + + Assert.Equal(0, process.ExitCode); + Assert.Equal( + [ + applicationDirectory, + nativeRoot, + existingNodeOptions is null + ? nodeOptionsSuffix + : $"{existingNodeOptions} {nodeOptionsSuffix}" + ], + File.ReadAllLines(outputPath)); + } } From df556d73332f7d3ef2bbcf59d12c3d6bd6a3469f Mon Sep 17 00:00:00 2001 From: "Anna Koenig (Dingler)" Date: Mon, 21 Sep 2026 11:48:31 -0700 Subject: [PATCH 2/4] fix: preserve agent Node options in command shim Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53ed1116-ce5e-44e8-82bb-aa010e7b78b1 --- src/OpenClaw.Launcher/Program.cs | 7 ++--- .../Session/AgentToolShim.cs | 12 ++++---- .../node/native-redirect.mjs | 8 ++++++ .../SessionToolInstaller.cs | 14 ++++------ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 2 +- .../Session/SessionToolInstallerTests.cs | 28 +++++++++++-------- 6 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 4da3ce49..57fae926 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -1128,9 +1128,8 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) installedTools.ShimPath!); Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); string? nativeRootPath = runtime.GetAgentNativeRoot(); - string? nativeRedirectOption = nativeRootPath is { Length: > 0 } - ? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption( - ResolveNativeRedirectPreloadPath()) + string? nativePreloadUrl = nativeRootPath is { Length: > 0 } + ? new Uri(ResolveNativeRedirectPreloadPath()).AbsoluteUri : null; return await runtime.Executor.ExecuteCommandAsync( @@ -1153,7 +1152,7 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) agentNodePath, applicationDirectory, nativeRootPath, - nativeRedirectOption)), + nativePreloadUrl)), NativeRootPath = nativeRootPath }, $"Opening {shell.DisplayName} in the isolated session.", diff --git a/src/OpenClaw.Launcher/Session/AgentToolShim.cs b/src/OpenClaw.Launcher/Session/AgentToolShim.cs index 9eb7367f..e1e26e89 100644 --- a/src/OpenClaw.Launcher/Session/AgentToolShim.cs +++ b/src/OpenClaw.Launcher/Session/AgentToolShim.cs @@ -12,14 +12,14 @@ internal static class AgentToolShim "OPENCLAW_SHIM_NATIVE_APP_ROOT"; internal const string NativeStagedRootVariable = "OPENCLAW_SHIM_NATIVE_STAGED_ROOT"; - internal const string NodeOptionsSuffixVariable = - "OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX"; + internal const string NativePreloadUrlVariable = + "OPENCLAW_SHIM_NATIVE_PRELOAD_URL"; public static IReadOnlyDictionary BuildEnvironment( string nodePath, string applicationDirectory, string? nativeRootPath = null, - string? nodeOptionsSuffix = null) + string? nativePreloadUrl = null) { ArgumentException.ThrowIfNullOrWhiteSpace(nodePath); ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory); @@ -35,16 +35,16 @@ public static IReadOnlyDictionary BuildEnvironment( return environment; } - if (string.IsNullOrWhiteSpace(nodeOptionsSuffix)) + if (string.IsNullOrWhiteSpace(nativePreloadUrl)) { throw new ArgumentException( "A native redirect preload is required with a staged native root.", - nameof(nodeOptionsSuffix)); + nameof(nativePreloadUrl)); } environment[NativeApplicationRootVariable] = applicationDirectory; environment[NativeStagedRootVariable] = nativeRootPath; - environment[NodeOptionsSuffixVariable] = nodeOptionsSuffix; + environment[NativePreloadUrlVariable] = nativePreloadUrl; return environment; } } diff --git a/src/OpenClaw.Launcher/node/native-redirect.mjs b/src/OpenClaw.Launcher/node/native-redirect.mjs index 9c89ecc4..9188813e 100644 --- a/src/OpenClaw.Launcher/node/native-redirect.mjs +++ b/src/OpenClaw.Launcher/node/native-redirect.mjs @@ -22,6 +22,14 @@ const appRoot = process.env.OPENCLAW_NATIVE_APP_ROOT; const stagedRoot = process.env.OPENCLAW_NATIVE_STAGED_ROOT; if (appRoot && stagedRoot) { + const preloadOption = `--import ${import.meta.url}`; + const inheritedNodeOptions = process.env.NODE_OPTIONS; + if (!inheritedNodeOptions?.includes(preloadOption)) { + process.env.NODE_OPTIONS = inheritedNodeOptions?.trim() + ? `${inheritedNodeOptions} ${preloadOption}` + : preloadOption; + } + const from = join(appRoot, "node_modules") + sep; const to = join(stagedRoot, "node_modules") + sep; diff --git a/src/OpenClaw.SessionHost/SessionToolInstaller.cs b/src/OpenClaw.SessionHost/SessionToolInstaller.cs index 0620f8cf..3777e11f 100644 --- a/src/OpenClaw.SessionHost/SessionToolInstaller.cs +++ b/src/OpenClaw.SessionHost/SessionToolInstaller.cs @@ -13,17 +13,15 @@ internal static class SessionToolInstaller "setlocal\r\n" + "if not defined OPENCLAW_SHIM_NODE goto :missing\r\n" + "if not defined OPENCLAW_SHIM_ENTRY goto :missing\r\n" + - "if not defined OPENCLAW_SHIM_NATIVE_STAGED_ROOT goto :launch\r\n" + + "if not defined OPENCLAW_SHIM_NATIVE_STAGED_ROOT goto :launch-direct\r\n" + "if not defined OPENCLAW_SHIM_NATIVE_APP_ROOT goto :missing\r\n" + - "if not defined OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX goto :missing\r\n" + + "if not defined OPENCLAW_SHIM_NATIVE_PRELOAD_URL goto :missing\r\n" + "set \"OPENCLAW_NATIVE_APP_ROOT=%OPENCLAW_SHIM_NATIVE_APP_ROOT%\"\r\n" + "set \"OPENCLAW_NATIVE_STAGED_ROOT=%OPENCLAW_SHIM_NATIVE_STAGED_ROOT%\"\r\n" + - "if defined NODE_OPTIONS goto :append-node-options\r\n" + - "set \"NODE_OPTIONS=%OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX%\"\r\n" + - "goto :launch\r\n" + - ":append-node-options\r\n" + - "set \"NODE_OPTIONS=%NODE_OPTIONS% %OPENCLAW_SHIM_NODE_OPTIONS_SUFFIX%\"\r\n" + - ":launch\r\n" + + "\"%OPENCLAW_SHIM_NODE%\" --import \"%OPENCLAW_SHIM_NATIVE_PRELOAD_URL%\" " + + "\"%OPENCLAW_SHIM_ENTRY%\" %*\r\n" + + "exit /b %ERRORLEVEL%\r\n" + + ":launch-direct\r\n" + "\"%OPENCLAW_SHIM_NODE%\" \"%OPENCLAW_SHIM_ENTRY%\" %*\r\n" + "exit /b %ERRORLEVEL%\r\n" + ":missing\r\n" + diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index b41d8a79..ab251767 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -473,7 +473,7 @@ public async Task PowerShellLaunchUsesTheNativeAwareShimAndMapsControlC() launched.Environment[AgentToolShim.NativeStagedRootVariable]); Assert.Contains( OpenClawRuntimeEnvironment.NativeRedirectFileName, - launched.Environment[AgentToolShim.NodeOptionsSuffixVariable], + launched.Environment[AgentToolShim.NativePreloadUrlVariable], StringComparison.Ordinal); } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs index 913a0e87..3f35b572 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs @@ -47,6 +47,7 @@ public void GuestInstallerCreatesTheCommandShimInItsWorkspace() [Theory] [InlineData(null)] [InlineData("--max-old-space-size=4096")] + [InlineData("--require \"C:\\agent data\\a&b\\preload.cjs\"")] public async Task CommandShimRestoresNativeRedirectForAgentInvocations( string? existingNodeOptions) { @@ -74,20 +75,26 @@ public async Task CommandShimRestoresNativeRedirectForAgentInvocations( File.WriteAllText( fakeNodePath, "@echo off\r\n" + + "setlocal EnableDelayedExpansion\r\n" + "> \"%OPENCLAW_TEST_OUTPUT%\" echo %OPENCLAW_NATIVE_APP_ROOT%\r\n" + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %OPENCLAW_NATIVE_STAGED_ROOT%\r\n" + - ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %NODE_OPTIONS%\r\n"); + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo(!NODE_OPTIONS!\r\n" + + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %~1\r\n" + + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %~2\r\n" + + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %~3\r\n" + + ">> \"%OPENCLAW_TEST_OUTPUT%\" echo %~4\r\n" + + "exit /b 0\r\n"); string applicationDirectory = @"C:\Program Files\WindowsApps\OpenClaw\app"; string nativeRoot = @"C:\Users\agent\AppData\Local\openclaw\native\abc"; - string nodeOptionsSuffix = - "--import file:///C:/Program%20Files/WindowsApps/OpenClaw/node/native-redirect.mjs"; + string nativePreloadUrl = + "file:///C:/Program%20Files/WindowsApps/OpenClaw/node/native-redirect.mjs"; IReadOnlyDictionary shimEnvironment = AgentToolShim.BuildEnvironment( fakeNodePath, applicationDirectory, nativeRoot, - nodeOptionsSuffix); + nativePreloadUrl); string commandInterpreter = Environment.GetEnvironmentVariable("ComSpec") ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.System), @@ -95,14 +102,11 @@ public async Task CommandShimRestoresNativeRedirectForAgentInvocations( var startInfo = new ProcessStartInfo { FileName = commandInterpreter, + Arguments = $"/d /c call \"{result.ShimPath}\" doctor", UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = workspace }; - startInfo.ArgumentList.Add("/d"); - startInfo.ArgumentList.Add("/s"); - startInfo.ArgumentList.Add("/c"); - startInfo.ArgumentList.Add($"\"{result.ShimPath}\" doctor"); startInfo.Environment.Remove(OpenClawRuntimeEnvironment.NativeApplicationRootVariable); startInfo.Environment.Remove(OpenClawRuntimeEnvironment.NativeStagedRootVariable); if (existingNodeOptions is null) @@ -130,9 +134,11 @@ public async Task CommandShimRestoresNativeRedirectForAgentInvocations( [ applicationDirectory, nativeRoot, - existingNodeOptions is null - ? nodeOptionsSuffix - : $"{existingNodeOptions} {nodeOptionsSuffix}" + existingNodeOptions ?? string.Empty, + "--import", + nativePreloadUrl, + Path.Combine(applicationDirectory, "openclaw.mjs"), + "doctor" ], File.ReadAllLines(outputPath)); } From ef3a1c13e29319f7cea6f9db28e6181f589d4935 Mon Sep 17 00:00:00 2001 From: "Anna Koenig (Dingler)" Date: Mon, 21 Sep 2026 14:52:42 -0700 Subject: [PATCH 3/4] fix: preserve native preload in agent CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53ed1116-ce5e-44e8-82bb-aa010e7b78b1 --- CONTRIBUTING.md | 11 +++--- README.md | 17 ++++----- src/AGENTS.md | 2 +- .../Gateway/GatewayRuntime.cs | 10 +++--- .../Gateway/SessionGatewayClient.cs | 16 ++++++--- .../OpenClawRuntimeEnvironment.cs | 27 ++++++-------- src/OpenClaw.Launcher/Program.cs | 12 +++---- .../Session/SessionExecutor.cs | 11 +++++- .../Gateway/SessionGatewayClientTests.cs | 18 +++++----- .../OpenClawRuntimeEnvironmentTests.cs | 20 +++++------ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 14 +++++--- .../Session/SessionExecutorTests.cs | 36 +++++++++++++++++++ 12 files changed, 122 insertions(+), 72 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 128f5a20..8517c2d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -201,11 +201,12 @@ bypassable, and required CI checks remain authoritative. dependency packages that carry native artifacts into agent LocalState and redirects resolution to them. That set is discovered by scanning, never hard-coded, and everything else keeps executing from the package. The agent - account owns its own `PATH` and `NODE_OPTIONS`: name the directory or the - option in the launch request and let the guest compose them, because - host-supplied environment values are assigned over the agent's. Reclaim a - staged root only when nothing is running from it; every launch holds its root - for its lifetime, and a held root is left whole for a later setup. + account owns its own `PATH` and `NODE_OPTIONS`: name the runtime directory in + the launch request, place the preload on Node's argument vector, and let the + preload append itself to the agent's options for child processes. Do not + assign the invoking host's values over the agent's. Reclaim a staged root + only when nothing is running from it; every launch holds its root for its + lifetime, and a held root is left whole for a later setup. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, and signing validation. - Restore `src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj` separately diff --git a/README.md b/README.md index 7315ab32..f71aa99f 100644 --- a/README.md +++ b/README.md @@ -247,14 +247,15 @@ list, so an upstream revision that introduces a new native dependency is staged automatically. Whole owning package directories are copied rather than individual binaries, because a package locates its sibling libraries and helper executables relative to its own directory. A packaged preload then redirects -both CommonJS and ESM resolution to the staged copies, delivered through -`NODE_OPTIONS` so that the Node.js workers OpenClaw starts inherit it. The -launcher names that preload rather than composing the variable, and the guest -appends it to the agent account's own `NODE_OPTIONS`, so an option the agent -set survives and the invoking host's value never reaches it. Staging is -idempotent, keyed by package content, and reclaims the superseded copy after an -upgrade once nothing is still running from it; a launch holds its root for its -whole lifetime, and a root that is still held is left whole for a later setup. +both CommonJS and ESM resolution to the staged copies. The launcher places that +preload on the agent Node.js argument vector so OpenClaw retains it when an +agent invokes `openclaw` again. Once loaded, the preload appends itself to the +agent account's own `NODE_OPTIONS` for ordinary Node.js workers, so an option +the agent set survives and the invoking host's value never reaches it. Staging +is idempotent, keyed by package content, and reclaims the superseded copy after +an upgrade once nothing is still running from it; a launch holds its root for +its whole lifetime, and a root that is still held is left whole for a later +setup. Run setup before using `openclaw`, `clawctl pwsh`, or gateway-service start. There is no session-free mode: `openclaw` runs inside the session recorded by diff --git a/src/AGENTS.md b/src/AGENTS.md index fbca1236..69384d51 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -13,7 +13,7 @@ Read the repository-root `AGENTS.md` before this file. This scope owns the three - `openclaw` arguments belong to upstream. Forward the original vector unchanged and keep System.CommandLine scoped to `clawctl`. - Sessions are mandatory and explicit. `clawctl setup` provisions and records one; `openclaw` starts only the recorded session and never provisions implicitly. - Keep packaged `app\openclaw.mjs` immutable and execute it from the package inside the session. Install Node.js in the agent profile and prepend that runtime to the agent process path. Mirror only packages discovered to carry `.node`, `.dll`, or `.exe` artifacts into agent LocalState, preserving each owning package directory; never hard-code the package set. -- The launch request names the staged native root and preload option. The session host composes them with the agent account's `PATH` and `NODE_OPTIONS`; invoking-host values must not leak into the guest. +- The launch request names the staged native root and places the preload on the agent Node.js argument vector so OpenClaw's reconstructed agent CLI retains it. The preload appends itself to the agent account's `NODE_OPTIONS` for ordinary child processes; invoking-host values must not leak into the guest. - Every launch using a staged native root holds it for its entire lifetime. Leave a held superseded root intact for a later setup to reclaim; a successful rename or delete is not proof that no process is using it. - Stage the session helper into the shared workspace during setup; the agent cannot execute it in place from another package identity's WindowsApps directory. - Preserve caller working directory and package-qualified entrypoint resolution. Unrecognized entrypoint names fall back only as documented and tested. diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index cc8c56fd..58ae8b0e 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -212,7 +212,7 @@ Task CreateRequestAsync(CancellationToken cancellationToken session.Backend, log, buildEnvironment: BuildGatewayEnvironment, - buildNodeOptionsSuffix: BuildGatewayNodeOptionsSuffix, + buildNodeArgumentsPrefix: BuildGatewayNodeArgumentsPrefix, getNativeRootPath: session.GetAgentNativeRoot, isCurrentRecord: IsCurrentSessionRecord), session.GatewayState, @@ -240,13 +240,11 @@ IReadOnlyDictionary BuildGatewayEnvironment() nativeRoot)); } - // The preload is named, not merged into the environment above: the - // agent's NODE_OPTIONS is the agent's, and this process's is the - // invoking host's. - string? BuildGatewayNodeOptionsSuffix() => + // Runtime arguments survive OpenClaw's reconstructed agent CLI. + IReadOnlyList? BuildGatewayNodeArgumentsPrefix() => options.PackagedApplicationDirectory is { Length: > 0 } && session.GetAgentNativeRoot() is { Length: > 0 } - ? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption( + ? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeArguments( Program.ResolveNativeRedirectPreloadPath()) : null; diff --git a/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs index 9646cb3e..08b0b613 100644 --- a/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs +++ b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs @@ -69,13 +69,13 @@ internal sealed class SessionGatewayClient : ISessionGatewayClient private readonly IMxcSessionClient _backend; private readonly Action _log; private readonly Func> _buildEnvironment; - private readonly Func _buildNodeOptionsSuffix; + private readonly Func?> _buildNodeArgumentsPrefix; private readonly Func _getNativeRootPath; private readonly Func _isCurrentRecord; public SessionGatewayClient(IMxcSessionClient backend, Action log, Func>? buildEnvironment = null, - Func? buildNodeOptionsSuffix = null, + Func?>? buildNodeArgumentsPrefix = null, Func? getNativeRootPath = null, Func? isCurrentRecord = null) { @@ -85,7 +85,7 @@ public SessionGatewayClient(IMxcSessionClient backend, Action log, _log = log; _buildEnvironment = buildEnvironment ?? OpenClawRuntimeEnvironment.Build; - _buildNodeOptionsSuffix = buildNodeOptionsSuffix ?? (() => null); + _buildNodeArgumentsPrefix = buildNodeArgumentsPrefix ?? (() => null); _getNativeRootPath = getNativeRootPath ?? (() => null); _isCurrentRecord = isCurrentRecord ?? (_ => true); } @@ -118,7 +118,14 @@ public async Task StartAsync( // --port is added only when the user pinned one. Passing a port always // would outrank `gateway.port` in OpenClaw's own configuration and // silently move the gateway away from where its clients look. - List arguments = [entryPoint, "gateway", "run"]; + var arguments = new List(); + if (_buildNodeArgumentsPrefix() is { } nodeArgumentsPrefix) + { + arguments.AddRange(nodeArgumentsPrefix); + } + arguments.Add(entryPoint); + arguments.Add("gateway"); + arguments.Add("run"); if (request.Port is int port) { arguments.Add("--port"); @@ -136,7 +143,6 @@ public async Task StartAsync( PathPrefix = Path.GetDirectoryName(request.NodePath) ?? throw new SessionException( "The agent's Node.js runtime has no parent directory."), - NodeOptionsSuffix = _buildNodeOptionsSuffix(), NativeRootPath = _getNativeRootPath(), LogPath = logPath, StatusPath = statusPath diff --git a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs index ed8be4df..e6b56e29 100644 --- a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs +++ b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs @@ -129,9 +129,10 @@ public static IReadOnlyDictionary Build( /// staged native dependency packages. /// /// - /// The preload that reads them is not here. It belongs in - /// NODE_OPTIONS, which the agent account owns; see - /// . + /// The preload that reads them is not here. The launcher places it on the + /// Node.js argument vector so OpenClaw's reconstructed agent CLI retains + /// the runtime hook; the preload then propagates itself to ordinary child + /// processes through the agent-owned NODE_OPTIONS. /// public static IReadOnlyDictionary BuildNativeRedirect( string applicationDirectory, @@ -148,32 +149,24 @@ public static IReadOnlyDictionary BuildNativeRedirect( } /// - /// Builds the Node.js option that loads the native dependency redirect. + /// Builds the Node.js arguments that load the native dependency redirect. /// /// /// - /// Delivered through NODE_OPTIONS rather than the command line - /// because OpenClaw starts its own Node.js workers and child services, - /// which inherit the environment but not this process's arguments. Those - /// children load the same native addons, so the redirect has to reach them - /// too. - /// - /// - /// Only the option is produced here. Appending it to an existing - /// NODE_OPTIONS happens where the agent's process environment is - /// built, because this process's own NODE_OPTIONS belongs to the - /// invoking host and says nothing about the agent's. + /// OpenClaw reconstructs its current Node.js invocation when an agent calls + /// openclaw. Runtime arguments survive that reconstruction, whereas + /// relying only on ambient NODE_OPTIONS does not. /// /// /// The preload is named as a percent-encoded file URL, which keeps the /// space in "Program Files" out of the option string. /// /// - public static string BuildNativeRedirectNodeOption(string preloadPath) + public static IReadOnlyList BuildNativeRedirectNodeArguments(string preloadPath) { ArgumentException.ThrowIfNullOrWhiteSpace(preloadPath); - return $"--import {new Uri(preloadPath).AbsoluteUri}"; + return ["--import", new Uri(preloadPath).AbsoluteUri]; } /// diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 57fae926..e661ed01 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -324,7 +324,7 @@ await runtime.StartForExecutionAsync(CancellationToken.None) applicationDirectory, interactive, environmentReader), - NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime), + NodeArgumentsPrefix = BuildNativeRedirectNodeArguments(runtime), NativeRootPath = runtime.GetAgentNativeRoot() }, CancellationToken.None).ConfigureAwait(false); @@ -396,8 +396,7 @@ await guidance.EvaluateAsync( /// addons the same way. The agent shell carries equivalent values through /// its command shim because agent tooling may replace process environment /// values before invoking openclaw. The redirect's preload is not - /// here: it belongs in the agent's NODE_OPTIONS, and this process's - /// own NODE_OPTIONS is the host's, not the agent's. + /// here: it belongs on the agent Node.js argument vector. /// private static IReadOnlyDictionary BuildRuntimeEnvironment( Session.SessionRuntime runtime, @@ -420,12 +419,13 @@ private static IReadOnlyDictionary BuildRuntimeEnvironment( } /// - /// The Node.js option that loads the redirect, or + /// The Node.js arguments that load the redirect, or /// when setup staged nothing to redirect to. /// - private static string? BuildNativeRedirectNodeOption(Session.SessionRuntime runtime) => + private static IReadOnlyList? BuildNativeRedirectNodeArguments( + Session.SessionRuntime runtime) => runtime.GetAgentNativeRoot() is { Length: > 0 } - ? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption( + ? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeArguments( ResolveNativeRedirectPreloadPath()) : null; diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index f2cc8207..399261f4 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -18,6 +18,9 @@ internal sealed record SessionExecutionRequest( /// public IReadOnlyDictionary? AdditionalEnvironment { get; init; } + /// Node.js runtime arguments placed before the application entrypoint. + public IReadOnlyList? NodeArgumentsPrefix { get; init; } + /// /// Node.js options the agent appends to its own NODE_OPTIONS. /// @@ -539,7 +542,13 @@ private static List BuildNodeArguments( SessionExecutionRequest request) { string entryPoint = Path.Combine(request.ApplicationDirectory, "openclaw.mjs"); - var arguments = new List(request.Arguments.Count + 1) { entryPoint }; + var arguments = new List( + (request.NodeArgumentsPrefix?.Count ?? 0) + request.Arguments.Count + 1); + if (request.NodeArgumentsPrefix is not null) + { + arguments.AddRange(request.NodeArgumentsPrefix); + } + arguments.Add(entryPoint); arguments.AddRange(request.Arguments); return arguments; } diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs index 7a530557..93b9c9ae 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs @@ -96,12 +96,11 @@ public async Task RuntimeStartUsesSetupRecordedAgentNodeWithoutHostNodeResolutio delivered.Environment![OpenClawRuntimeEnvironment.GatewayIsolationVariable]); } - // The detached gateway launch is a second guest boundary. It must name the - // redirect the same way the attached launch does, so the supervised agent - // appends the preload to its own NODE_OPTIONS and the staged root is held - // for the gateway's lifetime. + // The detached gateway launch is a second guest boundary. The preload must + // be a Node runtime argument so the gateway's reconstructed agent CLI keeps + // it, and the staged root must be held for the gateway's lifetime. [Fact] - public async Task ADetachedGatewayLaunchNamesTheNativeRedirectForTheAgentToCompose() + public async Task ADetachedGatewayLaunchCarriesTheNativeRedirectOnNodeArguments() { string nativeRootPath = Path.Combine(_root, "agent-native", "content"); Directory.CreateDirectory(nativeRootPath); @@ -109,12 +108,14 @@ public async Task ADetachedGatewayLaunchNamesTheNativeRedirectForTheAgentToCompo (SessionLaunchRequest delivered, _, _) = await StartGatewayAsync(nativeRootPath); Assert.Equal(nativeRootPath, delivered.NativeRootPath); - Assert.NotNull(delivered.NodeOptionsSuffix); - Assert.StartsWith("--import ", delivered.NodeOptionsSuffix, StringComparison.Ordinal); + Assert.Equal("--import", delivered.Arguments![0]); Assert.Contains( OpenClawRuntimeEnvironment.NativeRedirectFileName, - delivered.NodeOptionsSuffix, + delivered.Arguments[1], StringComparison.Ordinal); + Assert.EndsWith("openclaw.mjs", delivered.Arguments[2], StringComparison.Ordinal); + Assert.Equal(["gateway", "run"], delivered.Arguments.Skip(3)); + Assert.Null(delivered.NodeOptionsSuffix); Assert.False(delivered.Environment!.ContainsKey("NODE_OPTIONS")); } @@ -126,6 +127,7 @@ public async Task AGatewayLaunchWithoutStagedNativesNamesNoRedirect() Assert.Null(delivered.NativeRootPath); Assert.Null(delivered.NodeOptionsSuffix); + Assert.EndsWith("openclaw.mjs", delivered.Arguments![0], StringComparison.Ordinal); } private async Task<(SessionLaunchRequest Delivered, string AgentNodePath, SessionRuntime Session)> diff --git a/tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs b/tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs index 20192e16..d1ca5fc8 100644 --- a/tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs +++ b/tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs @@ -3,9 +3,7 @@ namespace OpenClaw.Launcher.Tests; public sealed class OpenClawRuntimeEnvironmentTests { /// - /// The redirect travels in NODE_OPTIONS because OpenClaw starts its - /// own Node.js workers, which inherit the environment but not this - /// process's arguments. + /// The redirect roots remain separate from the agent's own Node.js options. /// [Fact] public void NativeRedirectNamesBothRootsAndLeavesNodeOptionsToTheAgent() @@ -26,11 +24,11 @@ public void NativeRedirectNamesBothRootsAndLeavesNodeOptionsToTheAgent() } [Fact] - public void NativeRedirectNodeOptionImportsThePreload() + public void NativeRedirectNodeArgumentsImportThePreload() { Assert.Equal( - "--import file:///C:/Package/node/native-redirect.mjs", - OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption( + ["--import", "file:///C:/Package/node/native-redirect.mjs"], + OpenClawRuntimeEnvironment.BuildNativeRedirectNodeArguments( @"C:\Package\node\native-redirect.mjs")); } @@ -41,12 +39,14 @@ public void NativeRedirectNodeOptionImportsThePreload() [Fact] public void NativeRedirectEncodesSpacesInThePreloadPath() { - string options = OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption( + IReadOnlyList arguments = + OpenClawRuntimeEnvironment.BuildNativeRedirectNodeArguments( @"C:\Program Files\WindowsApps\OpenClaw\node\native-redirect.mjs"); + string preloadUrl = arguments[1]; - Assert.DoesNotContain("Program Files", options, StringComparison.Ordinal); - Assert.Contains("Program%20Files", options, StringComparison.Ordinal); - Assert.Single(options.Split(' '), static part => part.StartsWith("file:", StringComparison.Ordinal)); + Assert.Equal("--import", arguments[0]); + Assert.DoesNotContain("Program Files", preloadUrl, StringComparison.Ordinal); + Assert.Contains("Program%20Files", preloadUrl, StringComparison.Ordinal); } [Fact] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index ab251767..128fc3d7 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -295,10 +295,8 @@ await File.ReadAllTextAsync( } /// - /// The host never composes the agent's NODE_OPTIONS. It names the - /// preload, and the guest appends it to whatever the agent already set, so - /// settings such as --max-old-space-size survive setup and the - /// host's own value never reaches the agent. + /// The preload travels on Node's argument vector so OpenClaw's reconstructed + /// agent CLI retains it without replacing the agent's NODE_OPTIONS. /// [Fact] public async Task AgentLaunchNamesTheNativeRedirectWithoutSettingNodeOptions() @@ -357,10 +355,16 @@ await Program.RunAgentAsync( : null).ConfigureAwait(true); Assert.NotNull(launched); + Assert.Equal("--import", launched.Arguments![0]); Assert.Contains( OpenClawRuntimeEnvironment.NativeRedirectFileName, - launched.NodeOptionsSuffix, + launched.Arguments[1], StringComparison.Ordinal); + Assert.Equal( + Path.Combine(applicationDirectory, "openclaw.mjs"), + launched.Arguments[2]); + Assert.Equal("doctor", launched.Arguments[3]); + Assert.Null(launched.NodeOptionsSuffix); Assert.Equal(nativeRoot, launched.NativeRootPath); // The assigned environment must not carry NODE_OPTIONS at all: it diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 32662921..899d3e01 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -405,6 +405,42 @@ public async Task EntryPointIsTheFirstNodeArgument() Assert.Equal(@"C:\Program Files\nodejs\node.exe", delivered.Executable); } + [Fact] + public async Task NodeArgumentsPrefixPrecedesTheApplicationEntryPoint() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + SessionExecutionRequest request = Request("doctor") with + { + NodeArgumentsPrefix = + [ + "--import", + "file:///C:/Package/node/native-redirect.mjs" + ] + }; + + await Create().ExecuteAsync(Record(), request, CancellationToken.None); + + Assert.Equal( + [ + "--import", + "file:///C:/Package/node/native-redirect.mjs", + @"C:\Package\app\openclaw.mjs", + "doctor" + ], + delivered!.Arguments); + } + [Fact] public async Task WorkingDirectoryIsCarriedExplicitly() { From 99fa6a407bc3afaaff02e3bfea529920cec588c3 Mon Sep 17 00:00:00 2001 From: "Anna Koenig (Dingler)" Date: Mon, 21 Sep 2026 15:01:06 -0700 Subject: [PATCH 4/4] fix: preload native redirect for dashboard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53ed1116-ce5e-44e8-82bb-aa010e7b78b1 --- src/OpenClaw.Launcher/Program.cs | 8 ++++++-- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 13 ++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 71cbdb35..fdb59e56 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -665,12 +665,16 @@ await Gateway.GatewayRuntime.Create( new Session.SessionCommandRequest( runtime.RequireStagedHelper(record), nodePath, - [Path.Combine(applicationDirectory, "openclaw.mjs"), "dashboard", "--json"], + [ + .. BuildNativeRedirectNodeArguments(runtime) ?? [], + Path.Combine(applicationDirectory, "openclaw.mjs"), + "dashboard", + "--json" + ], record.WorkspacePath!) { PathPrefix = Path.GetDirectoryName(nodePath), AdditionalEnvironment = dashboardEnvironment, - NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime), NativeRootPath = runtime.GetAgentNativeRoot() }, "Resolving the Control UI handoff in the isolated session.", diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 9b0d0afd..eb64dfc4 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -2028,12 +2028,15 @@ public async Task OpenBindsBrowserActivationToTheObservedGatewayAndCurrentSessio Assert.Equal(expectedExitCode, exitCode); Assert.NotNull(dashboardRequest); Assert.Equal( - [Path.Combine(_testDirectory, "app", "openclaw.mjs"), "dashboard", "--json"], + [ + "--import", + new Uri(Program.ResolveNativeRedirectPreloadPath()).AbsoluteUri, + Path.Combine(_testDirectory, "app", "openclaw.mjs"), + "dashboard", + "--json" + ], dashboardRequest.Arguments); - Assert.Contains( - OpenClawRuntimeEnvironment.NativeRedirectFileName, - dashboardRequest.NodeOptionsSuffix, - StringComparison.Ordinal); + Assert.Null(dashboardRequest.NodeOptionsSuffix); Assert.Equal(nativeRoot, dashboardRequest.NativeRootPath); foreach ((string name, string value) in OpenClawRuntimeEnvironment.BuildNativeRedirect( Path.Combine(_testDirectory, "app"),