From 870657f899fe631b09400dd57d28ff9456e439c4 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:01:10 -0700 Subject: [PATCH 1/2] Define attached session launch protocol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .github/workflows/gateway-msix.yml | 5 + OpenClaw.Gateway.MSIX.slnx | 2 + scripts/Build-LocalMSIX.ps1 | 9 + scripts/Build-MSIX.ps1 | 54 +++ scripts/Test-SigningInputs.Tests.ps1 | 40 +++ scripts/Test-SigningInputs.ps1 | 73 +++++ src/OpenClaw.SessionHost/AssemblyInfo.cs | 9 + .../OpenClaw.SessionHost.csproj | 32 ++ src/OpenClaw.SessionHost/Program.cs | 101 ++++++ .../SessionProcessLauncher.cs | 81 +++++ .../OpenClaw.SessionProtocol.csproj | 24 ++ .../SessionLaunchProtocol.cs | 307 ++++++++++++++++++ .../Session/SessionHostModeTests.cs | 51 +++ .../Session/SessionLaunchProtocolTests.cs | 188 +++++++++++ 14 files changed, 976 insertions(+) create mode 100644 src/OpenClaw.SessionHost/AssemblyInfo.cs create mode 100644 src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj create mode 100644 src/OpenClaw.SessionHost/Program.cs create mode 100644 src/OpenClaw.SessionHost/SessionProcessLauncher.cs create mode 100644 src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj create mode 100644 src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 2e2fb7a4..ebed3387 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -449,6 +449,11 @@ jobs: -p:PublishAot=true ` -p:IncludePackagingContent=true ` -p:Platform='${{ matrix.architecture }}' + dotnet restore ` + .\src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj ` + --runtime 'win-${{ matrix.architecture }}' ` + -p:PublishAot=true ` + -p:Platform='${{ matrix.architecture }}' - name: Compose unsigned MSIX shell: pwsh diff --git a/OpenClaw.Gateway.MSIX.slnx b/OpenClaw.Gateway.MSIX.slnx index d0f21dff..bdeeeca4 100644 --- a/OpenClaw.Gateway.MSIX.slnx +++ b/OpenClaw.Gateway.MSIX.slnx @@ -1,6 +1,8 @@ + + diff --git a/scripts/Build-LocalMSIX.ps1 b/scripts/Build-LocalMSIX.ps1 index 57d36643..2e8c89e8 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -127,6 +127,15 @@ try { -p:IncludePackagingContent=true ` "-p:Platform=$Architecture" } + Invoke-CheckedCommand ` + -FailureMessage 'Session host dependency restore failed.' ` + -Command { + & dotnet restore ` + .\src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj ` + --runtime "win-$Architecture" ` + -p:PublishAot=true ` + "-p:Platform=$Architecture" + } $sourceCommit = (& git rev-parse HEAD) -join '' if ($LASTEXITCODE -ne 0 -or diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index e00225b7..849869aa 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -364,6 +364,51 @@ New-Item ` try { Add-VswhereToPath + $sessionHostProject = Join-Path ` + $repositoryRoot ` + 'src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj' + $sessionHostOutput = Join-Path ` + $repositoryRoot ` + "content\session-host\$Architecture" + Remove-DirectoryIfPresent -Path $sessionHostOutput + New-Item -Path $sessionHostOutput -ItemType Directory -Force | Out-Null + Write-Host "Publishing the NativeAOT win-$Architecture session host." + Invoke-CheckedCommand ` + -FailureMessage 'NativeAOT session host publish failed.' ` + -Command { + & dotnet publish $sessionHostProject ` + --configuration Release ` + --runtime "win-$Architecture" ` + --self-contained ` + --no-restore ` + "-p:Platform=$Architecture" ` + -p:PublishAot=true ` + --output $sessionHostOutput ` + --nologo + } + $sessionHostFiles = @( + Get-ChildItem -LiteralPath $sessionHostOutput -File -Force -Recurse | + Where-Object Extension -notin '.pdb', '.xml' | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $sessionHostOutput, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } | + Sort-Object path + ) + if (-not ($sessionHostFiles.path -contains 'openclaw-session-host.exe')) { + throw "The published $Architecture session host is incomplete." + } + $appxOutput = $msixBuildDirectory.TrimEnd('\') + '\' Write-Host "Building unsigned NativeAOT win-$Architecture MSIX with MSBuild." Invoke-CheckedCommand ` @@ -444,6 +489,14 @@ try { } ) } + foreach ($sessionHostFile in $sessionHostFiles) { + $expectedPackageFiles.Add( + "session-host/$Architecture/$($sessionHostFile.path)", + [pscustomobject]@{ + Hash = $sessionHostFile.sha256 + } + ) + } $packageEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -588,6 +641,7 @@ try { nodeRuntimeSha256 = $nodeArchiveHash mxcRuntimeVersion = [string]$mxcProvenance.version mxcRuntimeFiles = $mxcRuntimeFiles + sessionHostFiles = $sessionHostFiles architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 530e19b0..c52c6ba3 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -203,6 +203,31 @@ function New-TestArtifact { Sort-Object path ) + $sessionHostDirectory = Join-Path $staging "session-host\$Architecture" + New-Item -Path $sessionHostDirectory -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $sessionHostDirectory 'openclaw-session-host.exe'), + "session-host-$Architecture") + $sessionHostFiles = @( + Get-ChildItem -LiteralPath $sessionHostDirectory -File -Recurse | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $sessionHostDirectory, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash ` + -LiteralPath $_.FullName ` + -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } + ) + $msixName = "OpenClawGateway-$Architecture.msix" $msixPath = Join-Path $directory $msixName [IO.Compression.ZipFile]::CreateFromDirectory($staging, $msixPath) @@ -227,6 +252,7 @@ function New-TestArtifact { nodeRuntimeSha256 = $nodeRuntimeHash mxcRuntimeVersion = $mxcRuntimeVersion mxcRuntimeFiles = $mxcRuntimeFiles + sessionHostFiles = $sessionHostFiles architecture = $Architecture archive = $msixName sha256 = $msixHash @@ -464,6 +490,20 @@ try { -RequestedRef 'v2026.8.2' } + Reset-TestArtifacts + Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { + param($Expanded) + Set-Content ` + -LiteralPath ( + Join-Path $Expanded 'session-host\x64\openclaw-session-host.exe' + ) ` + -Value 'tampered' ` + -Encoding utf8 + } + Assert-Fails ` + -MessagePattern 'session host file is invalid' ` + -Action { Invoke-PolicyValidation -Root $testRoot } + $x64MetadataPath = Join-Path $testRoot 'x64\msix-metadata.json' $x64Metadata = Get-Content -LiteralPath $x64MetadataPath -Raw | ConvertFrom-Json diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 5ff7d201..62c9b4b1 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -223,6 +223,7 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.nodeRuntimeSha256 -notmatch '^[0-9a-fA-F]{64}$' -or [string]::IsNullOrWhiteSpace([string]$metadata.mxcRuntimeVersion) -or @($metadata.mxcRuntimeFiles).Count -eq 0 -or + @($metadata.sessionHostFiles).Count -eq 0 -or $metadata.architecture -ne $architecture -or $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or @@ -378,6 +379,78 @@ foreach ($architecture in @('x64', 'arm64')) { throw "The embedded $architecture MXC runtime file set is invalid." } + $expectedSessionHostPaths = + [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $hasSessionHost = $false + foreach ($file in @($metadata.sessionHostFiles)) { + $relativePath = [string]$file.path + $segments = @($relativePath.Split('/')) + if ( + [string]::IsNullOrWhiteSpace($relativePath) -or + $relativePath.StartsWith('/') -or + [IO.Path]::IsPathRooted($relativePath) -or + $relativePath.Contains('\') -or + $relativePath.Contains(':') -or + $segments -contains '' -or + $segments -contains '.' -or + $segments -contains '..' -or + $file.length -isnot [int64] -or + $file.length -lt 0 -or + $file.sha256 -notmatch '^[0-9a-fA-F]{64}$' + ) { + throw "The embedded $architecture session host inventory is invalid." + } + + $packagePath = "session-host/$architecture/$relativePath" + if (-not $expectedSessionHostPaths.Add($packagePath)) { + throw ( + "The embedded $architecture session host inventory has " + + 'duplicate paths.' + ) + } + + $entry = Get-PackageEntry ` + -EntriesByPath $entriesByPath ` + -Path $packagePath + if ( + $entry.Length -ne $file.length -or + (Get-PackageEntrySha256 -Entry $entry) -ine $file.sha256 + ) { + throw ( + "The embedded $architecture session host file is invalid: " + + $relativePath + ) + } + + if ($relativePath -ieq 'openclaw-session-host.exe') { + $hasSessionHost = $true + } + } + + if (-not $hasSessionHost) { + throw "The embedded $architecture session host is incomplete." + } + + $actualSessionHostPaths = @( + $entriesByPath.Keys | + Where-Object { + $_.StartsWith( + "session-host/$architecture/", + [StringComparison]::OrdinalIgnoreCase + ) + } + ) + if ( + $actualSessionHostPaths.Count -ne $expectedSessionHostPaths.Count -or + @($actualSessionHostPaths | Where-Object { + -not $expectedSessionHostPaths.Contains($_) + }).Count -ne 0 + ) { + throw "The embedded $architecture session host file set is invalid." + } + [xml]$manifest = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` -Path 'AppxManifest.xml' diff --git a/src/OpenClaw.SessionHost/AssemblyInfo.cs b/src/OpenClaw.SessionHost/AssemblyInfo.cs new file mode 100644 index 00000000..c448bf45 --- /dev/null +++ b/src/OpenClaw.SessionHost/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Runtime.InteropServices; + +// Every P/Invoke in this assembly targets kernel32.dll or iphlpapi.dll, both of +// which always live in System32. Restricting the search path to System32 +// assembly-wide prevents DLL planting: without it the loader would also probe +// the application directory and the current directory. That matters more here +// than in the launcher, because this helper runs inside the isolated session +// alongside whatever the agent has written to its own workspace. +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] diff --git a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj new file mode 100644 index 00000000..bc3a8b60 --- /dev/null +++ b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj @@ -0,0 +1,32 @@ + + + + Exe + net10.0-windows10.0.19041.0 + 10.0.19041.0 + enable + enable + all + openclaw-session-host + OpenClaw.SessionHost + x64;arm64 + win-x64;win-arm64 + false + true + true + + + None + false + $(DefineConstants);OPENCLAW_SESSION_HOST + + + + + + + + diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs new file mode 100644 index 00000000..6d3e8b9c --- /dev/null +++ b/src/OpenClaw.SessionHost/Program.cs @@ -0,0 +1,101 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The guest-side helper that runs inside an isolated session. +/// +/// +/// It exists because the backend's execution API takes a command-line string +/// that is flattened through cmd.exe. The MXC command therefore launches +/// only this controlled helper, which reads the real argument vector as data +/// and replays it byte for byte. +/// +internal static class Program +{ + public static int Main(string[] args) => + Run(args, new SessionProcessLauncher(), Console.Error, File.ReadAllText, WriteResult); + + internal static int Run( + IReadOnlyList args, + ISessionProcessLauncher launcher, + TextWriter errorOutput, + Func readFile, + Action writeResult) + { + 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 "); + return SessionLaunchProtocol.HelperFailureExitCode; + } + + string requestPath = args[1]; + + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + + try + { + SessionLaunchRequest request = + SessionLaunchProtocol.ReadRequest(readFile(requestPath)); + requestId = request.RequestId; + if (request.Mode != SessionLaunchMode.Attached) + { + throw new SessionLaunchException( + $"Launch mode '{request.Mode}' is not supported by this helper."); + } + + int exitCode = launcher.Run(request); + writeResult( + resultPath, + new SessionLaunchResult + { + RequestId = requestId, + Launched = true, + ExitCode = exitCode + }); + return exitCode; + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + errorOutput.WriteLine($"openclaw-session-host: {exception.Message}"); + TryWriteFailure(writeResult, resultPath, requestId, exception.Message, errorOutput); + return SessionLaunchProtocol.HelperFailureExitCode; + } + } + + private static void TryWriteFailure( + Action writeResult, + string resultPath, + string? requestId, + string error, + TextWriter errorOutput) + { + try + { + writeResult( + resultPath, + new SessionLaunchResult + { + RequestId = requestId, + Launched = false, + Error = error + }); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + errorOutput.WriteLine( + $"openclaw-session-host: unable to record the failure: {exception.Message}"); + } + } + + private static void WriteResult(string path, SessionLaunchResult result) => + File.WriteAllText(path, SessionLaunchProtocol.SerializeResult(result)); +} diff --git a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs new file mode 100644 index 00000000..89095dc4 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// Starts a process from a launch request, preserving the argument vector +/// exactly. +/// +internal interface ISessionProcessLauncher +{ + /// Runs the request to completion and returns its exit code. + int Run(SessionLaunchRequest request); + +} + +/// +/// The supervising process left behind by a detached launch. +/// +/// +/// The creation time is carried with the identifier because Windows reuses +/// process identifiers. An identifier alone would eventually name an unrelated +/// process, which the gateway would then claim and could be asked to stop. +/// +/// +/// Launches the requested executable shell-free, so no quoting, metacharacter, +/// or %VAR% interpretation can alter the arguments. +/// +internal sealed class SessionProcessLauncher : ISessionProcessLauncher +{ + public int Run(SessionLaunchRequest request) + { + string workingDirectory = request.WorkingDirectory!; + if (!Directory.Exists(workingDirectory)) + { + // Falling back to another directory would run the caller's command + // somewhere they never asked for, so this is fatal. + throw new SessionLaunchException( + $"The requested working directory does not exist: {workingDirectory}"); + } + + ProcessStartInfo startInfo = new() + { + FileName = request.Executable!, + + // No shell, and no stream redirection: the isolated session's + // console handles are inherited so interactive and piped OpenClaw + // behave as they do on the host. + UseShellExecute = false, + WorkingDirectory = workingDirectory + }; + + foreach (string argument in request.Arguments!) + { + startInfo.ArgumentList.Add(argument); + } + + foreach ((string name, string value) in request.Environment ?? new Dictionary()) + { + startInfo.Environment[name] = value; + } + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException or + PlatformNotSupportedException) + { + throw new SessionLaunchException( + $"Unable to start '{request.Executable}': {exception.Message}"); + } + + process.WaitForExit(); + return process.ExitCode; + } + +} diff --git a/src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj b/src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj new file mode 100644 index 00000000..020f1893 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj @@ -0,0 +1,24 @@ + + + + net10.0-windows10.0.19041.0 + 10.0.19041.0 + enable + enable + all + OpenClaw.SessionProtocol + x64;arm64 + win-x64;win-arm64 + + + true + + + + + + + diff --git a/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs b/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs new file mode 100644 index 00000000..a8546895 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs @@ -0,0 +1,307 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// +/// A single OpenClaw invocation, expressed as data for the guest helper. +/// +/// +/// The MXC execution API accepts one command-line string, which the pinned +/// backend flattens through cmd.exe. That silently expands +/// %VAR% and lets a quote in one argument truncate a later one, so the +/// real argument vector must never appear in that command line. It travels +/// here instead and is replayed verbatim by +/// OpenClaw.SessionHost. +/// +public sealed record SessionLaunchRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + /// + /// Identifies this invocation so the helper's control result cannot be + /// confused with a concurrent invocation's. + /// + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// + /// Whether the helper waits for the application or leaves it running. + /// + [JsonPropertyName("mode")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public SessionLaunchMode Mode { get; init; } = SessionLaunchMode.Attached; + + [JsonPropertyName("executable")] + public string? Executable { get; init; } + + [JsonPropertyName("arguments")] + public IReadOnlyList? Arguments { get; init; } + + /// + /// Required. Execution does not inherit the caller's working directory; + /// without this the guest command would silently run in the system + /// directory. + /// + [JsonPropertyName("workingDirectory")] + public string? WorkingDirectory { get; init; } + + [JsonPropertyName("environment")] + public IReadOnlyDictionary? Environment { get; init; } + + /// + /// Detached only. Where the application's output is written, because a + /// detached process has no console to inherit and the pipe it was started + /// through closes as soon as the launching execution returns. + /// + [JsonPropertyName("logPath")] + public string? LogPath { get; init; } + + /// + /// Detached only. Where the supervising helper records that this launch + /// generation is running or has exited. + /// + /// + /// The path is unguessable and unique per launch, so a process left behind + /// by an earlier launch cannot write to it and cannot make itself look like + /// the current generation. + /// + [JsonPropertyName("statusPath")] + public string? StatusPath { get; init; } +} + +/// +/// Whether a launch is awaited or left running. +/// +public enum SessionLaunchMode +{ + /// + /// The helper waits for the application and returns its exit code. The + /// console is inherited, so interactive and piped OpenClaw behave as they + /// do on the host. + /// + Attached, + + /// + /// The helper leaves the application running and reports the identity of + /// the process that supervises it. Used for the gateway, which must outlive + /// the execution that started it. + /// + Detached, +} + +/// +/// The guest helper's own outcome, kept separate from the launched +/// application's stdout, stderr, and exit code. +/// +/// +/// Without this separation a helper that failed to start Node at all would be +/// indistinguishable from OpenClaw itself exiting with the same code. +/// +public sealed record SessionLaunchResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// Whether the helper actually started the application. + [JsonPropertyName("launched")] + public bool Launched { get; init; } + + /// The application's exit code; null when it never started. + [JsonPropertyName("exitCode")] + public int? ExitCode { get; init; } + + /// + /// Detached only. The supervising process's identifier. + /// + [JsonPropertyName("processId")] + public int? ProcessId { get; init; } + + /// + /// Detached only. The supervising process's creation time. + /// + /// + /// Recorded with the identifier because Windows reuses process + /// identifiers. The identifier alone would eventually name an unrelated + /// process, which the gateway would then claim as its own and, worse, + /// could be asked to stop. + /// + [JsonPropertyName("processStartTimeUtc")] + public DateTimeOffset? ProcessStartTimeUtc { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +/// +/// Raised when a launch request or result cannot be honored. The message is +/// written to the control result rather than to application output. +/// +public sealed class SessionLaunchException : Exception +{ + public SessionLaunchException() + { + } + + public SessionLaunchException(string message) + : base(message) + { + } + + public SessionLaunchException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(SessionLaunchRequest))] +[JsonSerializable(typeof(SessionLaunchResult))] +internal sealed partial class SessionJsonContext : JsonSerializerContext; + +public static class SessionLaunchProtocol +{ + /// + /// Incremented whenever the request or result shape changes. The helper and + /// the launcher ship in the same package, so a mismatch means a stale file + /// or a mixed installation and is always an error rather than something to + /// interpret leniently. + /// + public const int CurrentSchemaVersion = 1; + + /// + /// Exit code used when the helper itself fails. It is deliberately + /// ambiguous with an application exit code, which is exactly why the + /// control result file is authoritative. + /// + public const int HelperFailureExitCode = 64; + + public static string ResultPathFor(string requestPath) => + requestPath + ".result.json"; + + public static string SerializeRequest(SessionLaunchRequest request) => + JsonSerializer.Serialize(request, SessionJsonContext.Default.SessionLaunchRequest); + + public static string SerializeResult(SessionLaunchResult result) => + JsonSerializer.Serialize(result, SessionJsonContext.Default.SessionLaunchResult); + + /// + /// Parses and fully validates a request. Validation happens here so the + /// helper and its tests agree on what a usable request is. + /// + public static SessionLaunchRequest ReadRequest(string json) + { + SessionLaunchRequest? request; + try + { + request = JsonSerializer.Deserialize( + json, + SessionJsonContext.Default.SessionLaunchRequest); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The launch request is not valid JSON: {exception.Message}"); + } + + if (request is null) + { + throw new SessionLaunchException("The launch request is empty."); + } + + if (request.SchemaVersion != CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Launch request schema version {request.SchemaVersion} is not " + + $"supported; this helper implements version {CurrentSchemaVersion}."); + } + + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new SessionLaunchException("The launch request has no request id."); + } + + if (string.IsNullOrWhiteSpace(request.Executable)) + { + throw new SessionLaunchException("The launch request has no executable."); + } + + if (request.Arguments is null) + { + throw new SessionLaunchException("The launch request has no argument vector."); + } + + // An absent working directory would silently become the system + // directory, which is never what the caller asked for. + if (string.IsNullOrWhiteSpace(request.WorkingDirectory)) + { + throw new SessionLaunchException( + "The launch request has no working directory."); + } + + if (request.Mode == SessionLaunchMode.Detached) + { + // A detached process has no console to inherit and the pipe it was + // started through closes as soon as the launching execution + // returns. Without a log it would write into a dead handle and + // leave nothing behind to explain a failed start. + if (string.IsNullOrWhiteSpace(request.LogPath)) + { + throw new SessionLaunchException( + "A detached launch request has no log path."); + } + + if (string.IsNullOrWhiteSpace(request.StatusPath)) + { + throw new SessionLaunchException( + "A detached launch request has no status path."); + } + } + + return request; + } + + public static SessionLaunchResult ReadResult(string json) + { + SessionLaunchResult? result; + try + { + result = JsonSerializer.Deserialize( + json, + SessionJsonContext.Default.SessionLaunchResult); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The launch result is not valid JSON: {exception.Message}"); + } + + if (result is null) + { + throw new SessionLaunchException("The launch result is empty."); + } + + if (result.SchemaVersion != CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Launch result schema version {result.SchemaVersion} is not " + + $"supported; this launcher implements version {CurrentSchemaVersion}."); + } + + if (result.Launched && string.IsNullOrWhiteSpace(result.RequestId)) + { + // A result may legitimately carry no request id when the helper + // failed before it could parse the request, but a launched + // application must always be attributable to its invocation. + throw new SessionLaunchException( + "The launch result reports a launch but has no request id."); + } + + return result; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs new file mode 100644 index 00000000..1af19cb0 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs @@ -0,0 +1,51 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; +using SessionHostProgram = OpenClaw.SessionHost.Program; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionHostModeTests +{ + [Theory] + [InlineData(SessionLaunchMode.Detached)] + [InlineData((SessionLaunchMode)42)] + public void UnsupportedModeIsRejectedBeforeLaunching(SessionLaunchMode mode) + { + var launcher = new RecordingLauncher(); + SessionLaunchResult? result = null; + var request = new SessionLaunchRequest + { + RequestId = "request-1", + Mode = mode, + Executable = @"C:\fixture\openclaw.exe", + Arguments = [], + WorkingDirectory = @"C:\fixture", + LogPath = @"C:\fixture\gateway.log", + StatusPath = @"C:\fixture\gateway.json" + }; + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\shared\request.json"], + launcher, + TextWriter.Null, + _ => SessionLaunchProtocol.SerializeRequest(request), + (_, value) => result = value); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.Equal(0, launcher.RunCount); + Assert.NotNull(result); + Assert.False(result.Launched); + Assert.Contains("mode", result.Error, StringComparison.OrdinalIgnoreCase); + } + + private sealed class RecordingLauncher : ISessionProcessLauncher + { + public int RunCount { get; private set; } + + public int Run(SessionLaunchRequest request) + { + RunCount++; + return 0; + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs new file mode 100644 index 00000000..512b8bd2 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs @@ -0,0 +1,188 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionLaunchProtocolTests +{ + private static SessionLaunchRequest Valid() => new() + { + RequestId = "r-1", + Executable = @"C:\Program Files\nodejs\node.exe", + Arguments = [@"C:\app\openclaw.mjs", "--help"], + WorkingDirectory = @"C:\work" + }; + + [Fact] + public void AHostileArgumentVectorSurvivesTheRoundTripByteForByte() + { + // These are the exact cases that cmd.exe corrupted when the vector was + // passed as a command line: a quote in one argument truncated a later + // one, and %USERPROFILE% was expanded before the process saw it. + string[] hostile = + [ + "%USERPROFILE%", + "q\"x", + "a&b", + "has spaces", + "trailing\\", + "pipe|caret^semi;", + "(parens)", + "bang!", + string.Empty, + "unicode-\u00e9\u4e2d\u6587" + ]; + + SessionLaunchRequest request = Valid() with { Arguments = hostile }; + + SessionLaunchRequest restored = SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request)); + + Assert.Equal(hostile, restored.Arguments); + } + + [Fact] + public void EnvironmentAndWorkingDirectorySurviveTheRoundTrip() + { + SessionLaunchRequest request = Valid() with + { + WorkingDirectory = @"E:\repo\some project", + Environment = new Dictionary + { + ["OPENCLAW_SUPERVISOR_MODE"] = "external", + ["LITERAL"] = "%NOT_EXPANDED%" + } + }; + + SessionLaunchRequest restored = SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request)); + + Assert.Equal(@"E:\repo\some project", restored.WorkingDirectory); + Assert.Equal("%NOT_EXPANDED%", restored.Environment!["LITERAL"]); + } + + [Fact] + public void AnUnsupportedSchemaVersionIsRejectedRatherThanInterpreted() + { + // The helper and the launcher ship together, so a version mismatch + // means a stale file or a mixed installation, never something to + // guess at. + string json = SessionLaunchProtocol.SerializeRequest( + Valid() with { SchemaVersion = SessionLaunchProtocol.CurrentSchemaVersion + 1 }); + + SessionLaunchException exception = Assert.Throws( + () => SessionLaunchProtocol.ReadRequest(json)); + + Assert.Contains("schema version", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AMissingWorkingDirectoryIsRejected() + { + // Execution does not inherit the caller's directory, so an absent + // value would silently become the system directory. + string json = SessionLaunchProtocol.SerializeRequest( + Valid() with { WorkingDirectory = null }); + + SessionLaunchException exception = Assert.Throws( + () => SessionLaunchProtocol.ReadRequest(json)); + + Assert.Contains("working directory", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("requestId")] + [InlineData("executable")] + [InlineData("arguments")] + public void AnIncompleteRequestIsRejected(string omitted) + { + SessionLaunchRequest request = omitted switch + { + "requestId" => Valid() with { RequestId = null }, + "executable" => Valid() with { Executable = null }, + _ => Valid() with { Arguments = null } + }; + + Assert.Throws( + () => SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request))); + } + + [Fact] + public void MalformedJsonIsRejected() + { + Assert.Throws( + () => SessionLaunchProtocol.ReadRequest("{ not json")); + } + + [Fact] + public void AnEmptyArgumentVectorIsAllowed() + { + // `openclaw` with no arguments is upstream-owned behavior, so it must + // reach the application rather than being rejected here. + SessionLaunchRequest restored = SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(Valid() with { Arguments = [] })); + + Assert.Empty(restored.Arguments!); + } + + [Fact] + public void ResultRoundTripsAndDistinguishesAFailedLaunch() + { + SessionLaunchResult failed = SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = "r-1", + Launched = false, + Error = "node.exe is missing" + })); + + Assert.False(failed.Launched); + Assert.Null(failed.ExitCode); + Assert.Equal("node.exe is missing", failed.Error); + + SessionLaunchResult succeeded = SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = "r-1", + Launched = true, + ExitCode = 64 + })); + + // 64 is also the helper's own failure code; only `launched` can tell + // these apart, which is why the control result exists. + Assert.True(succeeded.Launched); + Assert.Equal(64, succeeded.ExitCode); + } + + [Fact] + public void AFailedResultMayCarryNoRequestIdButALaunchedOneMayNot() + { + // The helper cannot know the request id when the request itself was + // unreadable, which is precisely when the control result matters most. + SessionLaunchResult anonymousFailure = SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + Launched = false, + Error = "the request is not valid JSON" + })); + + Assert.False(anonymousFailure.Launched); + Assert.Null(anonymousFailure.RequestId); + + Assert.Throws( + () => SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + Launched = true, + ExitCode = 0 + }))); + } + + [Fact] + public void TheResultPathIsDerivedFromTheRequestPath() + { + Assert.Equal( + @"C:\ws\req-1.json.result.json", + SessionLaunchProtocol.ResultPathFor(@"C:\ws\req-1.json")); + } +} From b0e304adee62aa6caf49a68e0fa9e49ef979ec7e Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:01:11 -0700 Subject: [PATCH 2/2] Build attached session guest helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/OpenClaw.Launcher.csproj | 7 +++++++ .../OpenClaw.Launcher.Tests/OpenClaw.Launcher.Tests.csproj | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index adbf872a..e2d557c4 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -83,6 +83,7 @@ x64 arm64 $(MSBuildThisFileDirectory)..\..\content\mxc\$(MxcRuntimeArchitecture)\ + $(MSBuildThisFileDirectory)..\..\content\session-host\$(MxcRuntimeArchitecture)\ @@ -99,6 +100,10 @@ + + + +