From 998a70aea983f77972977c8a04c0ad8cfc96d47e Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:41:39 -0700 Subject: [PATCH 1/5] Add the isolated agent PowerShell shell Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 8 +- src/OpenClaw.Launcher/Program.cs | 62 +++++++- .../Session/AgentShellResolver.cs | 64 ++++++++ .../Session/AgentToolShim.cs | 119 +++++++++++++++ .../ClawCtlCommandLineTests.cs | 16 +- .../ClawCtlParserDefaultsTests.cs | 3 +- .../Session/AgentShellTests.cs | 138 ++++++++++++++++++ .../Session/SessionExecutorTests.cs | 53 +++++++ .../Session/SessionRuntimeTests.cs | 59 ++++++++ 9 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 src/OpenClaw.Launcher/Session/AgentShellResolver.cs create mode 100644 src/OpenClaw.Launcher/Session/AgentToolShim.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 86f098a1..9ac3ec96 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -9,6 +9,7 @@ internal sealed record ClawCtlHandlers public required Func> Setup { get; init; } public required Func> Status { get; init; } public required Func> Teardown { get; init; } + public required Func> PowerShell { get; init; } } // The clawctl command tree. Only the package-readiness surface belongs here: @@ -58,12 +59,17 @@ public static RootCommand Create(ClawCtlHandlers handlers) teardown.Options.Add(force); teardown.SetAction((parsed, cancellationToken) => handlers.Teardown(parsed.GetValue(force), cancellationToken)); + Command powerShell = new( + "pwsh", + "Open an interactive PowerShell session inside the isolated agent."); + powerShell.SetAction((_, cancellationToken) => handlers.PowerShell(cancellationToken)); RootCommand root = new(RootDescription) { setup, status, - teardown + teardown, + powerShell }; // Bare `clawctl` is a discovery request, not a usage error, so the root diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 556c1833..07040593 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -353,7 +353,13 @@ await runtime.Coordinator.RemoveAsync(cancellationToken) await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); return 0; - } + }, + PowerShell = cancellationToken => RunPowerShellAsync( + options, + GetSessionRuntime(), + output, + resolveNode, + cancellationToken) }); InvocationConfiguration configuration = new() @@ -378,6 +384,60 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); } + private static async Task RunPowerShellAsync( + HostOptions options, + Session.SessionRuntime runtime, + TextWriter output, + Func>? resolveNode, + CancellationToken cancellationToken) + { + Session.SessionRecord record = runtime.RequireSetup(); + record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) + .ConfigureAwait(false); + string helperPath = runtime.RequireStagedHelper(record); + string applicationDirectory = GetPackagedApplicationDirectory(options); + NodeRuntime packagedNode = resolveNode is null + ? NodeRuntimeResolver.Resolve(GetPackagedNodeArchivePath(options)) + : await resolveNode(cancellationToken).ConfigureAwait(false); + string agentNodePath = runtime.RequireAgentNodePath(packagedNode.Version); + string nodeDirectory = Path.GetDirectoryName(agentNodePath) + ?? throw new Session.SessionException( + "The agent's Node.js runtime has no parent directory."); + Session.AgentTools tools = Session.AgentToolShim.Install(record.WorkspacePath!); + Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); + + await output.WriteLineAsync( + $"Opening {shell.DisplayName} as {record.AgentUserName ?? "the agent account"} " + + "in the isolated session.").ConfigureAwait(false); + await output.WriteLineAsync( + "`openclaw` and `node` are on PATH; `clawctl` is not, because it " + + "manages this session from outside it.").ConfigureAwait(false); + await output.WriteLineAsync("Exit the shell to return.").ConfigureAwait(false); + + return await runtime.Executor.ExecuteCommandAsync( + record, + new Session.SessionCommandRequest( + helperPath, + shell.ExecutablePath, + Session.AgentShellResolver.BuildArguments( + record.WorkspacePath!, + record.AgentUserName ?? "agent", + tools.DirectoryPath, + nodeDirectory), + record.WorkspacePath!) + { + AdditionalEnvironment = Session.SessionExecutor.MergeEnvironment( + OpenClawRuntimeEnvironment.Build( + WindowsHostConsole.Instance.IsInteractive, + Environment.GetEnvironmentVariable, + GatewayIsolationMode.Enabled), + Session.AgentToolShim.BuildEnvironment(agentNodePath, applicationDirectory)) + }, + $"Opening {shell.DisplayName} in the isolated session.", + shell.DisplayName, + cancellationToken).ConfigureAwait(false); + } + private static async Task RunSetupAsync( HostOptions options, Action log, diff --git a/src/OpenClaw.Launcher/Session/AgentShellResolver.cs b/src/OpenClaw.Launcher/Session/AgentShellResolver.cs new file mode 100644 index 00000000..a1413600 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/AgentShellResolver.cs @@ -0,0 +1,64 @@ +namespace OpenClaw.Launcher.Session; + +/// The shell opened inside the agent session. +internal sealed record AgentShell(string ExecutablePath, string DisplayName); + +/// Chooses the machine-wide PowerShell available to the agent. +internal static class AgentShellResolver +{ + internal static readonly string[] PowerShell7Paths = + [ + @"C:\Program Files\PowerShell\7\pwsh.exe", + @"C:\Program Files\PowerShell\7-preview\pwsh.exe", + ]; + + internal static string WindowsPowerShellPath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + + public static AgentShell Resolve(Func fileExists) + { + ArgumentNullException.ThrowIfNull(fileExists); + + foreach (string candidate in PowerShell7Paths) + { + if (fileExists(candidate)) + { + return new AgentShell(candidate, "PowerShell 7"); + } + } + + return new AgentShell(WindowsPowerShellPath, "Windows PowerShell"); + } + + public static IReadOnlyList BuildArguments( + string workspacePath, + string agentName, + string toolsDirectory, + string nodeDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + ArgumentException.ThrowIfNullOrWhiteSpace(toolsDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(nodeDirectory); + + string prompt = + "function prompt { " + + $"Write-Host '[openclaw {Escape(agentName)}]' -NoNewline -ForegroundColor Cyan; " + + "return ' ' + $ExecutionContext.SessionState.Path.CurrentLocation + '> ' }"; + string path = + "$env:PATH = " + + $"'{Escape(toolsDirectory)}' + [IO.Path]::PathSeparator + " + + $"'{Escape(nodeDirectory)}' + [IO.Path]::PathSeparator + $env:PATH; "; + string preparation = + path + + $"Set-Location -LiteralPath '{Escape(workspacePath)}' -ErrorAction SilentlyContinue; " + + prompt; + + return ["-NoLogo", "-NoProfile", "-NoExit", "-Command", preparation]; + } + + private static string Escape(string value) => + value.Replace("'", "''", StringComparison.Ordinal); +} diff --git a/src/OpenClaw.Launcher/Session/AgentToolShim.cs b/src/OpenClaw.Launcher/Session/AgentToolShim.cs new file mode 100644 index 00000000..d05ee689 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/AgentToolShim.cs @@ -0,0 +1,119 @@ +using System.Text; + +namespace OpenClaw.Launcher.Session; + +/// Where the agent's openclaw command lives. +internal sealed record AgentTools(string DirectoryPath, string ShimPath); + +/// Installs the dynamic command shim used inside the agent session. +internal static class AgentToolShim +{ + internal const string DirectoryName = ".openclaw-tools"; + internal const string ShimFileName = "openclaw.cmd"; + internal const string NodeVariable = "OPENCLAW_SHIM_NODE"; + internal const string EntryPointVariable = "OPENCLAW_SHIM_ENTRY"; + + internal const string ShimContent = + "@echo off\r\n" + + "setlocal\r\n" + + "if not defined " + NodeVariable + " goto :missing\r\n" + + "if not defined " + EntryPointVariable + " goto :missing\r\n" + + "\"%" + NodeVariable + "%\" \"%" + EntryPointVariable + "%\" %*\r\n" + + "exit /b %ERRORLEVEL%\r\n" + + ":missing\r\n" + + "echo openclaw: this shim only runs inside an OpenClaw agent session.>&2\r\n" + + "exit /b 9009\r\n"; + + public static AgentTools Install(string workspacePath) => + Install(workspacePath, PhysicalAgentToolFileSystem.Instance); + + internal static AgentTools Install( + string workspacePath, + IAgentToolFileSystem fileSystem) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + ArgumentNullException.ThrowIfNull(fileSystem); + + string workspace = Path.GetFullPath(workspacePath); + string directory = Path.GetFullPath(Path.Combine(workspace, DirectoryName)); + string shimPath = Path.Combine(directory, ShimFileName); + try + { + TrustedPath.EnsureNoReparsePoints( + workspace, directory, fileSystem.GetAttributes); + fileSystem.CreateDirectory(directory); + TrustedPath.EnsureNoReparsePoints( + workspace, shimPath, fileSystem.GetAttributes); + if (!IsCurrent(shimPath, fileSystem)) + { + fileSystem.WriteAllText(shimPath, ShimContent, Encoding.ASCII); + } + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + throw new SessionException( + "The agent's `openclaw` command could not be installed in the " + + $"shared workspace: {exception.Message}", + exception); + } + + return new AgentTools(directory, shimPath); + } + + public static IReadOnlyDictionary BuildEnvironment( + string nodePath, + string applicationDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(nodePath); + ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [NodeVariable] = nodePath, + [EntryPointVariable] = Path.Combine(applicationDirectory, "openclaw.mjs"), + }; + } + + private static bool IsCurrent(string shimPath, IAgentToolFileSystem fileSystem) + { + if (!fileSystem.FileExists(shimPath)) + { + return false; + } + + try + { + return string.Equals( + fileSystem.ReadAllText(shimPath, Encoding.ASCII), + ShimContent, + StringComparison.Ordinal); + } + catch (IOException) + { + return false; + } + } +} + +internal interface IAgentToolFileSystem +{ + FileAttributes GetAttributes(string path); + void CreateDirectory(string path); + bool FileExists(string path); + string ReadAllText(string path, Encoding encoding); + void WriteAllText(string path, string content, Encoding encoding); +} + +internal sealed class PhysicalAgentToolFileSystem : IAgentToolFileSystem +{ + public static PhysicalAgentToolFileSystem Instance { get; } = new(); + + public FileAttributes GetAttributes(string path) => File.GetAttributes(path); + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + public bool FileExists(string path) => File.Exists(path); + public string ReadAllText(string path, Encoding encoding) => + File.ReadAllText(path, encoding); + public void WriteAllText(string path, string content, Encoding encoding) => + File.WriteAllText(path, content, encoding); +} diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index 89d27c83..b9f0627e 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -73,11 +73,12 @@ public void OnlyTheReadinessCommandIsExposed() { Setup = _ => Task.FromResult(0), Status = _ => Task.FromResult(0), - Teardown = (_, _) => Task.FromResult(0) + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0) }); Assert.Equal( - [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown"], + [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown", "pwsh"], root.Subcommands.Select(command => command.Name)); } @@ -97,6 +98,17 @@ public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() StringComparison.Ordinal); } + [Fact] + public async Task PowerShellHelpDescribesTheIsolatedAgentShell() + { + (int exitCode, string output, string error) = + await RunAsync("pwsh", "--help").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains("isolated agent", Normalize(output), StringComparison.Ordinal); + } + // The built-in version action reports the entry assembly, which under a test // host or scenario runner is not the launcher. Assert the launcher's own // version so that substitution is caught. diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 688f8ced..75edd7ea 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -74,7 +74,8 @@ public void CompletionSuggestsCommandsWithoutResolvingNode() Setup = _ => throw new InvalidOperationException( "Completion started the readiness operation."), Status = _ => Task.FromResult(0), - Teardown = (_, _) => Task.FromResult(0) + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0) }); IEnumerable completions = root diff --git a/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs b/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs new file mode 100644 index 00000000..0cf388ca --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs @@ -0,0 +1,138 @@ +using System.Diagnostics; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class AgentShellTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + [Fact] + public void InstallCreatesAnAsciiDynamicCommandShim() + { + AgentTools tools = AgentToolShim.Install(_root); + byte[] bytes = File.ReadAllBytes(tools.ShimPath); + string content = Encoding.ASCII.GetString(bytes); + + Assert.Equal(AgentToolShim.ShimContent, content); + Assert.All(bytes, value => Assert.InRange(value, (byte)0, (byte)127)); + Assert.Contains("%OPENCLAW_SHIM_NODE%", content, StringComparison.Ordinal); + Assert.Contains("%OPENCLAW_SHIM_ENTRY%", content, StringComparison.Ordinal); + } + + [Fact] + public void InstallRejectsAReparsePointBeforeHostWrite() + { + string tools = Path.Combine(_root, AgentToolShim.DirectoryName); + var fileSystem = new RecordingAgentToolFileSystem( + _root, + tools, + FileAttributes.Directory | FileAttributes.ReparsePoint); + + SessionException exception = Assert.Throws( + () => AgentToolShim.Install(_root, fileSystem)); + + Assert.Contains("reparse point", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, fileSystem.CreateCount); + Assert.Equal(0, fileSystem.WriteCount); + } + + [Fact] + public void PowerShell7IsPreferredBeforePreviewAndWindowsPowerShell() + { + AgentShell shell = AgentShellResolver.Resolve( + path => path == AgentShellResolver.PowerShell7Paths[0] || + path == AgentShellResolver.PowerShell7Paths[1]); + + Assert.Equal(AgentShellResolver.PowerShell7Paths[0], shell.ExecutablePath); + Assert.Equal("PowerShell 7", shell.DisplayName); + } + + [Fact] + public void WindowsPowerShellIsUsedWhenNoMachineWidePowerShell7Exists() + { + AgentShell shell = AgentShellResolver.Resolve(_ => false); + + Assert.Equal(AgentShellResolver.WindowsPowerShellPath, shell.ExecutablePath); + Assert.Equal("Windows PowerShell", shell.DisplayName); + } + + [Fact] + public async Task ProductionPowerShellPathStartsSuccessfully() + { + AgentShell shell = AgentShellResolver.Resolve(File.Exists); + Assert.True(File.Exists(shell.ExecutablePath), shell.ExecutablePath); + + using Process process = new() + { + StartInfo = new ProcessStartInfo + { + FileName = shell.ExecutablePath, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.StartInfo.ArgumentList.Add("-NoLogo"); + process.StartInfo.ArgumentList.Add("-NoProfile"); + process.StartInfo.ArgumentList.Add("-Command"); + process.StartInfo.ArgumentList.Add("exit 0"); + + Assert.True(process.Start()); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await process.WaitForExitAsync(timeout.Token); + Assert.Equal(0, process.ExitCode); + } + + [Fact] + public void ShellPreparationPutsToolsThenAgentNodeAheadOfExistingPath() + { + IReadOnlyList arguments = AgentShellResolver.BuildArguments( + @"C:\shared workspace", + "agent's name", + @"C:\shared workspace\.openclaw-tools", + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node-v24"); + + Assert.Equal(["-NoLogo", "-NoProfile", "-NoExit", "-Command"], arguments.Take(4)); + string preparation = arguments[4]; + Assert.Contains( + @"C:\shared workspace\.openclaw-tools' + [IO.Path]::PathSeparator + 'C:\Users\agent", + preparation, + StringComparison.Ordinal); + Assert.Contains("agent''s name", preparation, StringComparison.Ordinal); + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + private sealed class RecordingAgentToolFileSystem( + string root, + string redirectedPath, + FileAttributes redirectedAttributes) : IAgentToolFileSystem + { + public int CreateCount { get; private set; } + public int WriteCount { get; private set; } + + public FileAttributes GetAttributes(string path) + { + if (path.Equals(redirectedPath, StringComparison.OrdinalIgnoreCase)) + { + return redirectedAttributes; + } + + if (path.Equals(root, StringComparison.OrdinalIgnoreCase)) + { + return FileAttributes.Directory; + } + + throw new FileNotFoundException(); + } + + public void CreateDirectory(string path) => CreateCount++; + public bool FileExists(string path) => false; + public string ReadAllText(string path, Encoding encoding) => throw new IOException(); + public void WriteAllText(string path, string content, Encoding encoding) => WriteCount++; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index bded9d3d..be523782 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -115,6 +115,59 @@ public async Task ApplicationExitCodeIsReturned() Assert.Equal(42, exitCode); } + [Fact] + public async Task CommandRequestCarriesInteractiveAndShimEnvironment() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + IReadOnlyDictionary environment = + SessionExecutor.MergeEnvironment( + OpenClawRuntimeEnvironment.Build( + isInteractive: true, + _ => null, + GatewayIsolationMode.Enabled), + AgentToolShim.BuildEnvironment( + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node.exe", + @"C:\Package\app")); + + int exitCode = await Create().ExecuteCommandAsync( + Record(), + new SessionCommandRequest( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Program Files\PowerShell\7\pwsh.exe", + ["-NoExit"], + Workspace) + { + AdditionalEnvironment = environment + }, + "Opening PowerShell 7 in the isolated session.", + "PowerShell 7", + CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.Equal("3", delivered!.Environment!["FORCE_COLOR"]); + Assert.Equal("1", delivered.Environment["WT_SESSION"]); + Assert.Equal( + "enabled", + delivered.Environment[OpenClawRuntimeEnvironment.GatewayIsolationVariable]); + Assert.Equal( + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node.exe", + delivered.Environment[AgentToolShim.NodeVariable]); + Assert.Equal( + @"C:\Package\app\openclaw.mjs", + delivered.Environment[AgentToolShim.EntryPointVariable]); + } + [Fact] public async Task ArgumentsTravelAsDataNotOnTheCommandLine() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs index 8a0f8279..a141a899 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs @@ -71,6 +71,65 @@ public void RequireSetupRefusesAnUnmarkedInstallationWithoutContactingMxc() Assert.Empty(backend.Calls); } + [Fact] + public async Task CompletedSetupAuthorizesTheRecordedSessionAndAgentRuntime() + { + var backend = new FakeMxcSessionClient(); + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { }, + backend); + SessionRecord session = await host.Coordinator.EnsureStartedAsync(CancellationToken.None); + + host.CompleteSetup( + session, + new OpenClaw.SessionProtocol.SessionRuntimeInstallResult + { + ExecutablePath = @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip" + }, + startupEnabled: true); + + Assert.Equal(session.SandboxId, host.RequireSetup().SandboxId); + Assert.Equal( + @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + host.RequireAgentNodePath( + @"C:\package\runtime\node-v24.20.0-win-x64.zip")); + } + + [Fact] + public async Task SameVersionWrongArchitectureIsRejected() + { + var backend = new FakeMxcSessionClient(); + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { }, + backend); + SessionRecord session = await host.Coordinator.EnsureStartedAsync( + CancellationToken.None); + + host.CompleteSetup( + session, + new OpenClaw.SessionProtocol.SessionRuntimeInstallResult + { + ExecutablePath = @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip" + }, + startupEnabled: true); + + SessionException failure = Assert.Throws(() => + host.RequireAgentNodePath( + @"C:\package\runtime\node-v24.20.0-win-arm64.zip")); + + Assert.Contains("does not match", failure.Message, StringComparison.Ordinal); + } + [Fact] public void TheGuestHelperIsResolvedPerArchitectureBesideTheMxcRuntime() { From 448db6438adbcc23128321430099d0106db05e87 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 11:33:49 -0700 Subject: [PATCH 2/5] Install agent command tools inside the session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/Program.cs | 10 +- .../Session/AgentToolShim.cs | 96 +------------------ .../Session/SessionExecutor.cs | 71 ++++++++++++++ src/OpenClaw.SessionHost/Program.cs | 10 +- .../SessionToolInstaller.cs | 68 +++++++++++++ .../SessionRuntimeProtocol.cs | 68 +++++++++++++ .../Session/AgentShellTests.cs | 59 ------------ .../Session/SessionToolInstallerTests.cs | 44 +++++++++ 8 files changed, 270 insertions(+), 156 deletions(-) create mode 100644 src/OpenClaw.SessionHost/SessionToolInstaller.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 07040593..2685378c 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -403,7 +403,15 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) string nodeDirectory = Path.GetDirectoryName(agentNodePath) ?? throw new Session.SessionException( "The agent's Node.js runtime has no parent directory."); - Session.AgentTools tools = Session.AgentToolShim.Install(record.WorkspacePath!); + SessionToolInstallResult installedTools = await runtime.Executor.InstallToolsAsync( + record, + helperPath, + cancellationToken).ConfigureAwait(false); + Session.AgentTools tools = new( + Path.GetDirectoryName(installedTools.ShimPath) + ?? throw new Session.SessionException( + "The installed agent command shim has no parent directory."), + installedTools.ShimPath!); Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); await output.WriteLineAsync( diff --git a/src/OpenClaw.Launcher/Session/AgentToolShim.cs b/src/OpenClaw.Launcher/Session/AgentToolShim.cs index d05ee689..d0318a15 100644 --- a/src/OpenClaw.Launcher/Session/AgentToolShim.cs +++ b/src/OpenClaw.Launcher/Session/AgentToolShim.cs @@ -1,66 +1,14 @@ -using System.Text; - namespace OpenClaw.Launcher.Session; /// Where the agent's openclaw command lives. internal sealed record AgentTools(string DirectoryPath, string ShimPath); -/// Installs the dynamic command shim used inside the agent session. +/// Builds environment values consumed by the guest-side command shim. internal static class AgentToolShim { - internal const string DirectoryName = ".openclaw-tools"; - internal const string ShimFileName = "openclaw.cmd"; internal const string NodeVariable = "OPENCLAW_SHIM_NODE"; internal const string EntryPointVariable = "OPENCLAW_SHIM_ENTRY"; - internal const string ShimContent = - "@echo off\r\n" + - "setlocal\r\n" + - "if not defined " + NodeVariable + " goto :missing\r\n" + - "if not defined " + EntryPointVariable + " goto :missing\r\n" + - "\"%" + NodeVariable + "%\" \"%" + EntryPointVariable + "%\" %*\r\n" + - "exit /b %ERRORLEVEL%\r\n" + - ":missing\r\n" + - "echo openclaw: this shim only runs inside an OpenClaw agent session.>&2\r\n" + - "exit /b 9009\r\n"; - - public static AgentTools Install(string workspacePath) => - Install(workspacePath, PhysicalAgentToolFileSystem.Instance); - - internal static AgentTools Install( - string workspacePath, - IAgentToolFileSystem fileSystem) - { - ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); - ArgumentNullException.ThrowIfNull(fileSystem); - - string workspace = Path.GetFullPath(workspacePath); - string directory = Path.GetFullPath(Path.Combine(workspace, DirectoryName)); - string shimPath = Path.Combine(directory, ShimFileName); - try - { - TrustedPath.EnsureNoReparsePoints( - workspace, directory, fileSystem.GetAttributes); - fileSystem.CreateDirectory(directory); - TrustedPath.EnsureNoReparsePoints( - workspace, shimPath, fileSystem.GetAttributes); - if (!IsCurrent(shimPath, fileSystem)) - { - fileSystem.WriteAllText(shimPath, ShimContent, Encoding.ASCII); - } - } - catch (Exception exception) when ( - exception is IOException or UnauthorizedAccessException) - { - throw new SessionException( - "The agent's `openclaw` command could not be installed in the " + - $"shared workspace: {exception.Message}", - exception); - } - - return new AgentTools(directory, shimPath); - } - public static IReadOnlyDictionary BuildEnvironment( string nodePath, string applicationDirectory) @@ -74,46 +22,4 @@ public static IReadOnlyDictionary BuildEnvironment( [EntryPointVariable] = Path.Combine(applicationDirectory, "openclaw.mjs"), }; } - - private static bool IsCurrent(string shimPath, IAgentToolFileSystem fileSystem) - { - if (!fileSystem.FileExists(shimPath)) - { - return false; - } - - try - { - return string.Equals( - fileSystem.ReadAllText(shimPath, Encoding.ASCII), - ShimContent, - StringComparison.Ordinal); - } - catch (IOException) - { - return false; - } - } -} - -internal interface IAgentToolFileSystem -{ - FileAttributes GetAttributes(string path); - void CreateDirectory(string path); - bool FileExists(string path); - string ReadAllText(string path, Encoding encoding); - void WriteAllText(string path, string content, Encoding encoding); -} - -internal sealed class PhysicalAgentToolFileSystem : IAgentToolFileSystem -{ - public static PhysicalAgentToolFileSystem Instance { get; } = new(); - - public FileAttributes GetAttributes(string path) => File.GetAttributes(path); - public void CreateDirectory(string path) => Directory.CreateDirectory(path); - public bool FileExists(string path) => File.Exists(path); - public string ReadAllText(string path, Encoding encoding) => - File.ReadAllText(path, encoding); - public void WriteAllText(string path, string content, Encoding encoding) => - File.WriteAllText(path, content, encoding); } diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index 469671a7..c1fc7109 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -265,6 +265,77 @@ internal static IReadOnlyDictionary MergeEnvironment( return merged; } + /// Installs the agent command shim under the guest identity. + public async Task InstallToolsAsync( + SessionRecord record, + string helperPath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(helperPath); + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("tools", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionRuntimeProtocol.SerializeToolInstallRequest(new SessionToolInstallRequest + { + RequestId = requestId, + WorkspacePath = record.WorkspacePath + }), + cancellationToken).ConfigureAwait(false); + + _log("Installing OpenClaw agent tools in the isolated session."); + MxcExecutionResult execution = await _backend.ExecuteAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + BuildGuestCommandLine(helperPath, requestPath, "--install-tools")), + null, + cancellationToken).ConfigureAwait(false); + string resultText; + try + { + resultText = await operation.ReadTextAsync(resultPath, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + throw new SessionException( + "The isolated session did not report a tool install " + + DescribeMissingResult(execution)); + } + + SessionToolInstallResult result = + SessionRuntimeProtocol.ReadToolInstallResult(resultText); + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a tool install result for a different request."); + } + if (!string.IsNullOrWhiteSpace(result.Error)) + { + throw new SessionException( + $"The OpenClaw agent tools could not be installed: {result.Error}"); + } + if (string.IsNullOrWhiteSpace(result.ShimPath)) + { + throw new SessionException( + "The isolated session did not report the installed command shim."); + } + + return result; + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + private static List BuildNodeArguments( SessionExecutionRequest request) { diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs index a7d74d26..fa540af0 100644 --- a/src/OpenClaw.SessionHost/Program.cs +++ b/src/OpenClaw.SessionHost/Program.cs @@ -31,13 +31,21 @@ internal static int Run( File.WriteAllText); } + if (args.Count == 2 && args[0] == "--install-tools") + { + return SessionToolInstaller.Run( + args[1], + readFile, + File.WriteAllText); + } + if (args.Count != 2 || args[0] != "--request") { // No request path means no control file to report through, so this // is the one failure that can only surface on stderr. errorOutput.WriteLine( "openclaw-session-host: usage: openclaw-session-host " + - "--request "); + "--request|--install-runtime|--install-tools "); return SessionLaunchProtocol.HelperFailureExitCode; } diff --git a/src/OpenClaw.SessionHost/SessionToolInstaller.cs b/src/OpenClaw.SessionHost/SessionToolInstaller.cs new file mode 100644 index 00000000..3c6d12d8 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionToolInstaller.cs @@ -0,0 +1,68 @@ +using System.Text; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// Installs the agent command shim from inside the isolated account. +internal static class SessionToolInstaller +{ + private const string DirectoryName = ".openclaw-tools"; + private const string ShimFileName = "openclaw.cmd"; + private const string ShimContent = + "@echo off\r\n" + + "setlocal\r\n" + + "if not defined OPENCLAW_SHIM_NODE goto :missing\r\n" + + "if not defined OPENCLAW_SHIM_ENTRY goto :missing\r\n" + + "\"%OPENCLAW_SHIM_NODE%\" \"%OPENCLAW_SHIM_ENTRY%\" %*\r\n" + + "exit /b %ERRORLEVEL%\r\n" + + ":missing\r\n" + + "echo openclaw: this shim only runs inside an OpenClaw agent session.>&2\r\n" + + "exit /b 9009\r\n"; + + public static int Run( + string requestPath, + Func readFile, + Action writeFile) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + try + { + SessionToolInstallRequest request = + SessionRuntimeProtocol.ReadToolInstallRequest(readFile(requestPath)); + requestId = request.RequestId; + string directory = Path.Combine(request.WorkspacePath!, DirectoryName); + string shimPath = Path.Combine(directory, ShimFileName); + Directory.CreateDirectory(directory); + File.WriteAllText(shimPath, ShimContent, Encoding.ASCII); + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeToolInstallResult(new SessionToolInstallResult + { + RequestId = requestId, + ShimPath = shimPath + })); + return 0; + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or UnauthorizedAccessException) + { + try + { + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeToolInstallResult(new SessionToolInstallResult + { + RequestId = requestId, + Error = exception.Message + })); + } + catch (Exception writeException) when ( + writeException is IOException or UnauthorizedAccessException) + { + } + + return SessionLaunchProtocol.HelperFailureExitCode; + } + } +} diff --git a/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs index 359450f3..4c84fa83 100644 --- a/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs +++ b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs @@ -78,10 +78,41 @@ public sealed record SessionRuntimeInstallResult public string? Error { get; init; } } +/// Asks the guest to install its OpenClaw command shim. +public sealed record SessionToolInstallRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("workspacePath")] + public string? WorkspacePath { get; init; } +} + +/// Reports guest-side command shim installation. +public sealed record SessionToolInstallResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("shimPath")] + public string? ShimPath { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(SessionRuntimeInstallRequest))] [JsonSerializable(typeof(SessionRuntimeInstallResult))] +[JsonSerializable(typeof(SessionToolInstallRequest))] +[JsonSerializable(typeof(SessionToolInstallResult))] internal sealed partial class SessionRuntimeJsonContext : JsonSerializerContext; public static class SessionRuntimeProtocol @@ -96,6 +127,16 @@ public static string SerializeResult(SessionRuntimeInstallResult result) => result, SessionRuntimeJsonContext.Default.SessionRuntimeInstallResult); + public static string SerializeToolInstallRequest(SessionToolInstallRequest request) => + JsonSerializer.Serialize( + request, + SessionRuntimeJsonContext.Default.SessionToolInstallRequest); + + public static string SerializeToolInstallResult(SessionToolInstallResult result) => + JsonSerializer.Serialize( + result, + SessionRuntimeJsonContext.Default.SessionToolInstallResult); + public static SessionRuntimeInstallRequest ReadRequest(string json) { SessionRuntimeInstallRequest request = Deserialize( @@ -124,6 +165,33 @@ public static SessionRuntimeInstallRequest ReadRequest(string json) return request; } + public static SessionToolInstallRequest ReadToolInstallRequest(string json) + { + SessionToolInstallRequest request = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionToolInstallRequest, + "tool install request"); + RequireCurrentSchema(request.SchemaVersion, "tool install request", "helper"); + if (string.IsNullOrWhiteSpace(request.RequestId) || + string.IsNullOrWhiteSpace(request.WorkspacePath)) + { + throw new SessionLaunchException( + "The tool install request is missing its request ID or workspace path."); + } + + return request; + } + + public static SessionToolInstallResult ReadToolInstallResult(string json) + { + SessionToolInstallResult result = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionToolInstallResult, + "tool install result"); + RequireCurrentSchema(result.SchemaVersion, "tool install result", "launcher"); + return result; + } + public static SessionRuntimeInstallResult ReadResult(string json) { SessionRuntimeInstallResult result = Deserialize( diff --git a/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs b/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs index 0cf388ca..25ff8ed0 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs @@ -7,36 +7,6 @@ public sealed class AgentShellTests : IDisposable { private readonly string _root = TestDirectory.Create(); - [Fact] - public void InstallCreatesAnAsciiDynamicCommandShim() - { - AgentTools tools = AgentToolShim.Install(_root); - byte[] bytes = File.ReadAllBytes(tools.ShimPath); - string content = Encoding.ASCII.GetString(bytes); - - Assert.Equal(AgentToolShim.ShimContent, content); - Assert.All(bytes, value => Assert.InRange(value, (byte)0, (byte)127)); - Assert.Contains("%OPENCLAW_SHIM_NODE%", content, StringComparison.Ordinal); - Assert.Contains("%OPENCLAW_SHIM_ENTRY%", content, StringComparison.Ordinal); - } - - [Fact] - public void InstallRejectsAReparsePointBeforeHostWrite() - { - string tools = Path.Combine(_root, AgentToolShim.DirectoryName); - var fileSystem = new RecordingAgentToolFileSystem( - _root, - tools, - FileAttributes.Directory | FileAttributes.ReparsePoint); - - SessionException exception = Assert.Throws( - () => AgentToolShim.Install(_root, fileSystem)); - - Assert.Contains("reparse point", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(0, fileSystem.CreateCount); - Assert.Equal(0, fileSystem.WriteCount); - } - [Fact] public void PowerShell7IsPreferredBeforePreviewAndWindowsPowerShell() { @@ -106,33 +76,4 @@ public void Dispose() Directory.Delete(_root, recursive: true); GC.SuppressFinalize(this); } - - private sealed class RecordingAgentToolFileSystem( - string root, - string redirectedPath, - FileAttributes redirectedAttributes) : IAgentToolFileSystem - { - public int CreateCount { get; private set; } - public int WriteCount { get; private set; } - - public FileAttributes GetAttributes(string path) - { - if (path.Equals(redirectedPath, StringComparison.OrdinalIgnoreCase)) - { - return redirectedAttributes; - } - - if (path.Equals(root, StringComparison.OrdinalIgnoreCase)) - { - return FileAttributes.Directory; - } - - throw new FileNotFoundException(); - } - - public void CreateDirectory(string path) => CreateCount++; - public bool FileExists(string path) => false; - public string ReadAllText(string path, Encoding encoding) => throw new IOException(); - public void WriteAllText(string path, string content, Encoding encoding) => WriteCount++; - } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs new file mode 100644 index 00000000..3a64b886 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs @@ -0,0 +1,44 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionToolInstallerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + [Fact] + public void GuestInstallerCreatesTheCommandShimInItsWorkspace() + { + 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 + })); + + int exitCode = SessionToolInstaller.Run( + requestPath, + File.ReadAllText, + File.WriteAllText); + + Assert.Equal(0, exitCode); + SessionToolInstallResult result = SessionRuntimeProtocol.ReadToolInstallResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(requestPath))); + Assert.Equal("tools1", result.RequestId); + Assert.True(File.Exists(result.ShimPath)); + Assert.Equal( + Path.Combine(workspace, ".openclaw-tools", "openclaw.cmd"), + result.ShimPath); + } +} From 96444be01a1eb836df30ee131e2a54b66b0adf24 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 18:43:29 -0700 Subject: [PATCH 3/5] Include session host job support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj index bc3a8b60..50c0a6d6 100644 --- a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj +++ b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj @@ -27,6 +27,7 @@ + From b7c10cea2f7c595251d9e647c07bb9ea25e5478c Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 18:52:42 -0700 Subject: [PATCH 4/5] Avoid duplicating launcher job support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj index 50c0a6d6..bc3a8b60 100644 --- a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj +++ b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj @@ -27,7 +27,6 @@ - From 6fad787ca300e2437fd359d3aa3cec1b05e37061 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 18:55:29 -0700 Subject: [PATCH 5/5] Scope shared job support to the session host Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/Program.cs | 8 ++------ src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs | 4 ++++ src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 2685378c..3ec1658e 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -358,7 +358,6 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") options, GetSessionRuntime(), output, - resolveNode, cancellationToken) }); @@ -388,7 +387,6 @@ private static async Task RunPowerShellAsync( HostOptions options, Session.SessionRuntime runtime, TextWriter output, - Func>? resolveNode, CancellationToken cancellationToken) { Session.SessionRecord record = runtime.RequireSetup(); @@ -396,10 +394,8 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) .ConfigureAwait(false); string helperPath = runtime.RequireStagedHelper(record); string applicationDirectory = GetPackagedApplicationDirectory(options); - NodeRuntime packagedNode = resolveNode is null - ? NodeRuntimeResolver.Resolve(GetPackagedNodeArchivePath(options)) - : await resolveNode(cancellationToken).ConfigureAwait(false); - string agentNodePath = runtime.RequireAgentNodePath(packagedNode.Version); + string agentNodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); string nodeDirectory = Path.GetDirectoryName(agentNodePath) ?? throw new Session.SessionException( "The agent's Node.js runtime has no parent directory."); diff --git a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs index 17cd7b0f..5eaff088 100644 --- a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs +++ b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs @@ -4,7 +4,11 @@ using System.Text; using Microsoft.Win32.SafeHandles; +#if OPENCLAW_SESSION_HOST +namespace OpenClaw.SessionHost; +#else namespace OpenClaw.Launcher; +#endif internal sealed class WindowsKillOnCloseJob : IDisposable { diff --git a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj index bc3a8b60..50c0a6d6 100644 --- a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj +++ b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj @@ -27,6 +27,7 @@ +