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 1548de91..5a050b05 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 b8047a5c..fdb59e56 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -326,7 +326,7 @@ await runtime.StartForExecutionAsync(CancellationToken.None) applicationDirectory, interactive, environmentReader), - NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime), + NodeArgumentsPrefix = BuildNativeRedirectNodeArguments(runtime), NativeRootPath = runtime.GetAgentNativeRoot() }, CancellationToken.None).ConfigureAwait(false); @@ -394,10 +394,11 @@ 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 on the agent Node.js argument vector. /// private static IReadOnlyDictionary BuildRuntimeEnvironment( Session.SessionRuntime runtime, @@ -420,12 +421,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; @@ -663,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.", @@ -1251,6 +1257,10 @@ 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? nativePreloadUrl = nativeRootPath is { Length: > 0 } + ? new Uri(ResolveNativeRedirectPreloadPath()).AbsoluteUri + : null; return await runtime.Executor.ExecuteCommandAsync( record, @@ -1265,14 +1275,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, + nativePreloadUrl)), + 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..e1e26e89 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 NativePreloadUrlVariable = + "OPENCLAW_SHIM_NATIVE_PRELOAD_URL"; public static IReadOnlyDictionary BuildEnvironment( string nodePath, - string applicationDirectory) + string applicationDirectory, + string? nativeRootPath = null, + string? nativePreloadUrl = 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(nativePreloadUrl)) + { + throw new ArgumentException( + "A native redirect preload is required with a staged native root.", + nameof(nativePreloadUrl)); + } + + environment[NativeApplicationRootVariable] = applicationDirectory; + environment[NativeStagedRootVariable] = nativeRootPath; + environment[NativePreloadUrlVariable] = nativePreloadUrl; + return environment; } } diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index 39287a91..1701a593 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -19,6 +19,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. /// @@ -599,7 +602,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/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 3c6d12d8..3777e11f 100644 --- a/src/OpenClaw.SessionHost/SessionToolInstaller.cs +++ b/src/OpenClaw.SessionHost/SessionToolInstaller.cs @@ -13,6 +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-direct\r\n" + + "if not defined OPENCLAW_SHIM_NATIVE_APP_ROOT 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" + + "\"%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/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 390d5e12..eb64dfc4 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 @@ -399,7 +403,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 +417,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 +437,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 +460,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.NativePreloadUrlVariable], + StringComparison.Ordinal); } [Fact] @@ -1994,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"), diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 8d235f73..1ccc7da6 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -432,6 +432,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() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs index 3a64b886..3f35b572 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,103 @@ public void GuestInstallerCreatesTheCommandShimInItsWorkspace() Path.Combine(workspace, ".openclaw-tools", "openclaw.cmd"), result.ShimPath); } + + [Theory] + [InlineData(null)] + [InlineData("--max-old-space-size=4096")] + [InlineData("--require \"C:\\agent data\\a&b\\preload.cjs\"")] + 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" + + "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 %~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 nativePreloadUrl = + "file:///C:/Program%20Files/WindowsApps/OpenClaw/node/native-redirect.mjs"; + IReadOnlyDictionary shimEnvironment = + AgentToolShim.BuildEnvironment( + fakeNodePath, + applicationDirectory, + nativeRoot, + nativePreloadUrl); + string commandInterpreter = Environment.GetEnvironmentVariable("ComSpec") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe"); + var startInfo = new ProcessStartInfo + { + FileName = commandInterpreter, + Arguments = $"/d /c call \"{result.ShimPath}\" doctor", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workspace + }; + 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 ?? string.Empty, + "--import", + nativePreloadUrl, + Path.Combine(applicationDirectory, "openclaw.mjs"), + "doctor" + ], + File.ReadAllLines(outputPath)); + } }