Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/OpenClaw.Launcher/ClawCtlCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal sealed record ClawCtlHandlers
public required Func<CancellationToken, Task<int>> Setup { get; init; }
public required Func<CancellationToken, Task<int>> Status { get; init; }
public required Func<bool, CancellationToken, Task<int>> Teardown { get; init; }
public required Func<CancellationToken, Task<int>> PowerShell { get; init; }
}

// The clawctl command tree. Only the package-readiness surface belongs here:
Expand Down Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion src/OpenClaw.Launcher/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,12 @@ await runtime.Coordinator.RemoveAsync(cancellationToken)
await output.WriteLineAsync("OpenClaw isolated session was removed.")
.ConfigureAwait(false);
return 0;
}
},
PowerShell = cancellationToken => RunPowerShellAsync(
options,
GetSessionRuntime(),
output,
cancellationToken)
});

InvocationConfiguration configuration = new()
Expand All @@ -378,6 +383,65 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.")
.ConfigureAwait(false);
}

private static async Task<int> RunPowerShellAsync(
HostOptions options,
Session.SessionRuntime runtime,
TextWriter output,
CancellationToken cancellationToken)
{
Session.SessionRecord record = runtime.RequireSetup();
record = await runtime.Coordinator.StartRecordedAsync(cancellationToken)
.ConfigureAwait(false);
string helperPath = runtime.RequireStagedHelper(record);
string applicationDirectory = GetPackagedApplicationDirectory(options);
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.");
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(
$"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<int> RunSetupAsync(
HostOptions options,
Action<string> log,
Expand Down
64 changes: 64 additions & 0 deletions src/OpenClaw.Launcher/Session/AgentShellResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
namespace OpenClaw.Launcher.Session;

/// <summary>The shell opened inside the agent session.</summary>
internal sealed record AgentShell(string ExecutablePath, string DisplayName);

/// <summary>Chooses the machine-wide PowerShell available to the agent.</summary>
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<string, bool> 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<string> 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);
}
25 changes: 25 additions & 0 deletions src/OpenClaw.Launcher/Session/AgentToolShim.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace OpenClaw.Launcher.Session;

/// <summary>Where the agent's <c>openclaw</c> command lives.</summary>
internal sealed record AgentTools(string DirectoryPath, string ShimPath);

/// <summary>Builds environment values consumed by the guest-side command shim.</summary>
internal static class AgentToolShim
{
internal const string NodeVariable = "OPENCLAW_SHIM_NODE";
internal const string EntryPointVariable = "OPENCLAW_SHIM_ENTRY";

public static IReadOnlyDictionary<string, string> BuildEnvironment(
string nodePath,
string applicationDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodePath);
ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory);

return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[NodeVariable] = nodePath,
[EntryPointVariable] = Path.Combine(applicationDirectory, "openclaw.mjs"),
};
}
}
71 changes: 71 additions & 0 deletions src/OpenClaw.Launcher/Session/SessionExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,77 @@ internal static IReadOnlyDictionary<string, string> MergeEnvironment(
return merged;
}

/// <summary>Installs the agent command shim under the guest identity.</summary>
public async Task<SessionToolInstallResult> 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<string> BuildNodeArguments(
SessionExecutionRequest request)
{
Expand Down
4 changes: 4 additions & 0 deletions src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenClaw.SessionProtocol\OpenClaw.SessionProtocol.csproj" />
<InternalsVisibleTo Include="OpenClaw.Launcher.Tests" />
<Compile Include="..\OpenClaw.Launcher\WindowsKillOnCloseJob.cs" Link="WindowsKillOnCloseJob.cs" />
</ItemGroup>

</Project>
10 changes: 9 additions & 1 deletion src/OpenClaw.SessionHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>");
"--request|--install-runtime|--install-tools <path>");
return SessionLaunchProtocol.HelperFailureExitCode;
}

Expand Down
68 changes: 68 additions & 0 deletions src/OpenClaw.SessionHost/SessionToolInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Text;
using OpenClaw.SessionProtocol;

namespace OpenClaw.SessionHost;

/// <summary>Installs the agent command shim from inside the isolated account.</summary>
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<string, string> readFile,
Action<string, string> 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;
}
}
}
Loading
Loading