From 3493693cd680a630484fdc1a303b6a5b230bdbef Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:08:14 -0700 Subject: [PATCH 01/10] Add durable session ownership and setup state Add synchronization and package-derived writable paths for owned sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/HostPaths.cs | 165 ++++++++ .../Session/LazyMxcSessionClient.cs | 67 +++ .../Session/SessionCoordinator.cs | 370 ++++++++++++++++ src/OpenClaw.Launcher/Session/SessionLock.cs | 142 +++++++ .../Session/SessionRoutingPolicy.cs | 152 +++++++ .../Session/SessionStateStore.cs | 287 +++++++++++++ .../Session/SetupStateStore.cs | 202 +++++++++ .../Session/FakeMxcSessionClient.cs | 124 ++++++ .../Session/HostPathsTests.cs | 72 ++++ .../Session/SessionCoordinatorTests.cs | 399 ++++++++++++++++++ .../Session/SessionLockTests.cs | 159 +++++++ .../Session/SessionRoutingPolicyTests.cs | 241 +++++++++++ .../Session/SessionStateStoreTests.cs | 278 ++++++++++++ .../Session/SessionTestDoubles.cs | 29 ++ .../Session/SetupStateStoreTests.cs | 89 ++++ 15 files changed, 2776 insertions(+) create mode 100644 src/OpenClaw.Launcher/HostPaths.cs create mode 100644 src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionCoordinator.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionLock.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionStateStore.cs create mode 100644 src/OpenClaw.Launcher/Session/SetupStateStore.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs diff --git a/src/OpenClaw.Launcher/HostPaths.cs b/src/OpenClaw.Launcher/HostPaths.cs new file mode 100644 index 00000000..886c9ebc --- /dev/null +++ b/src/OpenClaw.Launcher/HostPaths.cs @@ -0,0 +1,165 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher; + +/// +/// The running process's MSIX package identity. +/// +internal static class PackageIdentity +{ + private const int ErrorInsufficientBuffer = 122; + private const int AppModelErrorNoPackage = 15700; + + /// + /// Prefix required by MXC when a packaged caller provisions a sandbox. + /// + public const string ApplicationIdPrefix = "PFN:"; + + /// + /// The package family name, or null when running unpackaged. + /// + /// + /// Unpackaged is a normal development configuration, not an error, so it + /// is reported as null rather than thrown. + /// + public static string? TryGetPackageFamilyName() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + uint length = 0; + int result = GetCurrentPackageFamilyName(ref length, null); + if (result == AppModelErrorNoPackage) + { + return null; + } + + if (result != ErrorInsufficientBuffer || length == 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + var value = new char[length]; + result = GetCurrentPackageFamilyName(ref length, value); + if (result != 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + return new string(value, 0, checked((int)length - 1)); + } + + /// + /// Builds the MXC application id for a package family name. + /// + /// + /// The backend fixes this value for the sandbox lifetime, so it must be the + /// real family name of the calling package. This package and the internal + /// MSIX therefore never share a session. + /// + public static string ToApplicationId(string packageFamilyName) => + ApplicationIdPrefix + packageFamilyName; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFamilyName( + ref uint packageFamilyNameLength, + [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] + char[]? packageFamilyName); +} + +/// +/// Where this installation keeps its own writable state. +/// +/// +/// Every writable path is derived here so nothing duplicates the packaged +/// versus unpackaged decision, and so tests can redirect the root instead of +/// touching real profile state. +/// +internal sealed class HostPaths +{ + public const string UnpackagedDirectoryName = "OpenClawGatewayMSIX"; + + private HostPaths(string stateRoot, string? packageFamilyName) + { + StateRoot = stateRoot; + PackageFamilyName = packageFamilyName; + } + + public string StateRoot { get; } + + public string? PackageFamilyName { get; } + + public string LogPath => Path.Combine(StateRoot, "Logs", "openclaw.log"); + + public string SessionStatePath => Path.Combine(StateRoot, "session.json"); + + /// + /// The marker written only after explicit setup completes all of its + /// session, launch-configuration, and sign-in-recovery steps. + /// + public string SetupStatePath => Path.Combine(StateRoot, "setup.json"); + + /// + /// The script the logon task runs. + /// + /// + /// The task deliberately does not invoke the app alias directly. This file + /// is rewritten without elevation on every install, whereas changing the + /// task's own arguments requires re-registering it. Routing through it + /// keeps the launch command free to change, and keeps the Startup-folder + /// lane calling the same single definition instead of a second one that + /// can drift. + /// + public string GatewayLauncherPath => + Path.Combine(StateRoot, "gateway-launcher.cmd"); + + /// + /// Where the recorded gateway process and its persistence choices live. + /// + public string GatewayStatePath => Path.Combine(StateRoot, "gateway.json"); + + /// + /// The gateway's launch configuration, kept separate from its recorded + /// process so that stopping the gateway never discards the user's port. + /// + public string GatewayConfigurationPath => + Path.Combine(StateRoot, "gateway-config.json"); + + public static HostPaths Create() => + Create( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + PackageIdentity.TryGetPackageFamilyName()); + + internal static HostPaths Create(string localAppData, string? packageFamilyName) + { + if (string.IsNullOrWhiteSpace(localAppData)) + { + throw new InvalidOperationException( + "The local application data directory is unavailable."); + } + + // A packaged process writes inside its own LocalState so the state is + // removed with the package and cannot collide with another + // installation's. + string stateRoot = packageFamilyName is null + ? Path.Combine(localAppData, UnpackagedDirectoryName) + : Path.Combine( + localAppData, + "Packages", + packageFamilyName, + "LocalState", + UnpackagedDirectoryName); + + return new HostPaths(stateRoot, packageFamilyName); + } + + /// + /// Builds paths rooted at an arbitrary directory, for tests and diagnosis. + /// + internal static HostPaths ForRoot(string stateRoot, string? packageFamilyName = null) => + new(Path.GetFullPath(stateRoot), packageFamilyName); +} diff --git a/src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs b/src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs new file mode 100644 index 00000000..60cfcf62 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs @@ -0,0 +1,67 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Defers backend construction until a lifecycle call actually needs it. +/// +/// +/// clawctl status can report local ownership without constructing +/// the backend, so it must work on a machine where the MXC runtime is missing. +/// Constructing the real client eagerly would turn a read-only status query +/// into a runtime-availability failure and hide the recorded state the user +/// asked about. +/// +internal sealed class LazyMxcSessionClient(Func create) + : IMxcSessionClient +{ + private readonly Lazy _inner = new(create); + + public Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) => + _inner.Value.ProvisionAsync(request, cancellationToken); + + public Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.StartAsync(sandboxId, correlationVector, cancellationToken); + + public Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.ExecuteAsync( + sandboxId, + request, + correlationVector, + cancellationToken); + + public Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.ExecuteAttachedAsync( + sandboxId, + request, + correlationVector, + cancellationToken); + + public Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.StopAsync(sandboxId, correlationVector, cancellationToken); + + public Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.DeprovisionAsync( + sandboxId, + correlationVector, + cancellationToken); +} diff --git a/src/OpenClaw.Launcher/Session/SessionCoordinator.cs b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs new file mode 100644 index 00000000..b2d16af1 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs @@ -0,0 +1,370 @@ +using System.Diagnostics.CodeAnalysis; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// A session lifecycle operation could not proceed. +/// +internal class SessionException : Exception +{ + public SessionException() + { + } + + public SessionException(string message) + : base(message) + { + } + + public SessionException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// The recorded session exists but cannot be used, and replacing it silently +/// would risk abandoning a live backend session. +/// +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = + "The fault is what callers act on: it distinguishes a missing record " + + "from one that is merely unreadable, which decides whether replacing " + + "the session is safe. A message-only constructor would allow that " + + "distinction to be lost.")] +internal sealed class SessionStateException : SessionException +{ + public SessionStateException(SessionStateFault fault, string detail) + : base(detail) => Fault = fault; + + public SessionStateFault Fault { get; } +} + +/// +/// Another process held the lifecycle lock for longer than the caller allowed. +/// +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = + "The timeout is the message: this exception exists to tell the user " + + "how long another process was given before the wait was abandoned. A " + + "message-only form would let that number be omitted or contradicted.")] +internal sealed class SessionBusyException : SessionException +{ + public SessionBusyException(TimeSpan timeout) + : base( + "Another OpenClaw process is changing the session and did not " + + $"finish within {timeout.TotalSeconds:0.#} seconds.") + { + } +} + +/// +/// Whether a usable session is recorded, without consulting the backend. +/// +internal enum SessionAvailability +{ + /// No session has been recorded; a first one may be created. + None, + + /// A usable record exists. It says nothing about liveness. + Recorded, + + /// A record exists but cannot be used. Recovery is required. + Unusable, +} + +/// +/// What the local record says. Liveness is deliberately not claimed here: the +/// backend offers no authoritative session enumeration, so recorded identity +/// and live evidence must stay separate. +/// +internal sealed record SessionStatus( + SessionAvailability Availability, + SessionRecord? Record, + SessionStateFault? Fault, + string? Detail); + +/// +/// The outcome of removing the owned session. +/// +/// False when there was nothing to remove. +/// +/// Set when stop failed but deprovision was still attempted, so the caller can +/// report the degraded path instead of claiming a clean teardown. +/// +internal sealed record SessionRemovalResult(bool Removed, string? StopFailure); + +/// +/// Owns this installation's isolated session across processes. +/// +internal sealed class SessionCoordinator +{ + public static readonly TimeSpan DefaultLockTimeout = TimeSpan.FromSeconds(30); + + private readonly IMxcSessionClient _backend; + private readonly SessionStateStore _store; + private readonly ISessionLock _lifecycleLock; + private readonly string _applicationId; + private readonly Action _log; + private readonly TimeProvider _clock; + private readonly TimeSpan _lockTimeout; + + public SessionCoordinator( + IMxcSessionClient backend, + SessionStateStore store, + ISessionLock lifecycleLock, + string applicationId, + Action log, + TimeProvider? clock = null, + TimeSpan? lockTimeout = null) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(lifecycleLock); + ArgumentException.ThrowIfNullOrWhiteSpace(applicationId); + ArgumentNullException.ThrowIfNull(log); + + _backend = backend; + _store = store; + _lifecycleLock = lifecycleLock; + _applicationId = applicationId; + _log = log; + _clock = clock ?? TimeProvider.System; + _lockTimeout = lockTimeout ?? DefaultLockTimeout; + } + + /// + /// Reports the recorded session without provisioning, starting, or writing. + /// + public SessionStatus GetRecordedStatus() + { + SessionStateResult result = _store.Read(_applicationId); + if (result.Record is not null) + { + return new SessionStatus( + SessionAvailability.Recorded, + result.Record, + null, + null); + } + + SessionAvailability availability = result.Fault == SessionStateFault.Missing + ? SessionAvailability.None + : SessionAvailability.Unusable; + + return new SessionStatus(availability, null, result.Fault, result.Detail); + } + + /// + /// Returns the owned, started session, creating it only on first use. + /// + public async Task EnsureStartedAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionStateResult state = _store.Read(_applicationId); + if (state.Record is not null) + { + _log("Reusing the recorded OpenClaw session."); + await StartAsync(state.Record, cancellationToken) + .ConfigureAwait(false); + return state.Record; + } + + if (state.Fault != SessionStateFault.Missing) + { + // Provisioning over a record we merely failed to read would abandon + // a live backend session and the user's guest profile with it. + throw new SessionStateException(state.Fault!.Value, state.Detail!); + } + + _log("Creating the first OpenClaw session for this installation."); + MxcProvisionResult provisioned = await _backend + .ProvisionAsync(new MxcProvisionRequest(_applicationId), cancellationToken) + .ConfigureAwait(false); + + // Ownership is recorded before the session is started. A crash between + // provision and start would otherwise leave a sandbox nothing claims, + // and the backend cannot be asked which sandboxes are ours. + var record = new SessionRecord + { + SandboxId = provisioned.SandboxId.Value, + ApplicationId = _applicationId, + AgentUserName = provisioned.Metadata?.AgentUserName, + AgentUserSid = provisioned.Metadata?.AgentUserSid, + WorkspacePath = provisioned.Metadata?.EphemeralWorkspacePath, + Generation = Guid.NewGuid().ToString("N"), + CreatedUtc = _clock.GetUtcNow(), + }; + try + { + _store.Write(record); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _log($"The new session could not be recorded: {exception.Message}. Deprovisioning this attempt."); + try + { + await _backend.DeprovisionAsync(provisioned.SandboxId, null, CancellationToken.None) + .ConfigureAwait(false); + } + catch (MxcException cleanup) + { + throw new SessionException( + $"The session record could not be saved, and deprovision failed: {cleanup.Message}. " + + $"Recover the owned sandbox ID from diagnostics before retrying. ID: {provisioned.SandboxId.Value}", + exception); + } + throw; + } + + await StartAsync(record, cancellationToken).ConfigureAwait(false); + return record; + } + + /// + /// Starts the recorded session without provisioning a replacement. + /// + /// + /// OpenClaw execution and gateway recovery use this path after explicit + /// setup. A missing record is a setup error, never permission to create a + /// new session implicitly. + /// + public async Task StartRecordedAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionRecord record = RequireUsableRecordOrNull() + ?? throw new SessionException( + "No isolated session is recorded. Run `clawctl setup` first."); + + await StartAsync(record, cancellationToken).ConfigureAwait(false); + return record; + } + + /// + /// Stops the session, keeping the provision and the guest profile. + /// + /// False when no session is recorded. + public async Task StopAsync(CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionRecord? record = RequireUsableRecordOrNull(); + if (record is null) + { + return false; + } + + await _backend + .StopAsync(record.ToSandboxIdOrThrow(), null, cancellationToken) + .ConfigureAwait(false); + _log("Stopped the OpenClaw session; its profile and data are retained."); + return true; + } + + /// + /// Stops and deprovisions the session, then forgets it. + /// + /// + /// This destroys the guest profile and workspace. The local record is + /// cleared only after deprovision succeeds, so a failed teardown leaves the + /// session owned rather than orphaned beyond recovery. + /// + public async Task RemoveAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionRecord? record = RequireUsableRecordOrNull(); + if (record is null) + { + return new SessionRemovalResult(false, null); + } + + MxcSandboxId sandboxId = record.ToSandboxIdOrThrow(); + + string? stopFailure = null; + try + { + await _backend.StopAsync(sandboxId, null, cancellationToken) + .ConfigureAwait(false); + } + catch (MxcException exception) + { + // Deprovision is what actually releases the account, profile, and + // workspace. Abandoning removal because stop failed would leave + // more behind than continuing does, so the failure is reported + // rather than used to stop the teardown. + stopFailure = exception.Message; + _log($"Stop failed before removal; continuing to deprovision: {exception.Message}"); + } + + await _backend.DeprovisionAsync(sandboxId, null, cancellationToken) + .ConfigureAwait(false); + _store.Clear(); + _log("Removed the OpenClaw session and its guest profile."); + return new SessionRemovalResult(true, stopFailure); + } + + /// + /// Discards the local record without contacting the backend. + /// + /// + /// This is the deliberate escape from a record that cannot be read well + /// enough to deprovision. It abandons whatever the backend still holds, so + /// it is never reached implicitly by another operation. + /// + public void ForgetRecordedState() + { + using ISessionLockHandle handle = AcquireLock(); + _store.Clear(); + _log("Discarded the local session record without contacting the backend."); + } + + private async Task StartAsync( + SessionRecord record, + CancellationToken cancellationToken) + { + // Start is issued on every path, including reuse. A stopped session + // fails execution with backend_error rather than restarting itself, and + // the backend offers no way to ask whether a session is running, so the + // only way to guarantee a usable session is to start it. + await _backend + .StartAsync(record.ToSandboxIdOrThrow(), null, cancellationToken) + .ConfigureAwait(false); + } + + private SessionRecord? RequireUsableRecordOrNull() + { + SessionStateResult state = _store.Read(_applicationId); + if (state.Record is not null) + { + return state.Record; + } + + if (state.Fault == SessionStateFault.Missing) + { + return null; + } + + throw new SessionStateException(state.Fault!.Value, state.Detail!); + } + + private ISessionLockHandle AcquireLock() => + _lifecycleLock.TryAcquire(_lockTimeout) + ?? throw new SessionBusyException(_lockTimeout); +} + +internal static class SessionRecordExtensions +{ + public static MxcSandboxId ToSandboxIdOrThrow(this SessionRecord record) => + MxcSandboxId.Parse(record.SandboxId); +} diff --git a/src/OpenClaw.Launcher/Session/SessionLock.cs b/src/OpenClaw.Launcher/Session/SessionLock.cs new file mode 100644 index 00000000..8d993fbd --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionLock.cs @@ -0,0 +1,142 @@ +namespace OpenClaw.Launcher.Session; + +/// +/// A held lifecycle lock. Disposing releases it. +/// +internal interface ISessionLockHandle : IDisposable +{ +} + +/// +/// Serializes session lifecycle transitions across processes. +/// +/// +/// This guards provision, start, stop, and deprovision only. It is deliberately +/// not held for the lifetime of a foreground OpenClaw invocation: a long +/// interactive run would otherwise block every other command indefinitely, and +/// a crash would leave the next caller waiting on a lock whose owner is gone. +/// +internal interface ISessionLock +{ + /// + /// Acquires the lock, or returns null if elapses. + /// + ISessionLockHandle? TryAcquire(TimeSpan timeout); +} + +/// +/// Named-mutex lifecycle lock, scoped to one user and package identity. +/// +internal sealed class NamedSessionLock : ISessionLock +{ + private readonly string _name; + + public NamedSessionLock(string scope) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + + // Local\ keeps the lock inside the session of the current user, which + // matches the one-session-per-user-and-package rule and avoids needing + // rights on a Global object. + _name = "Local\\OpenClawSessionLifecycle_" + Sanitize(scope); + } + + public string Name => _name; + + public ISessionLockHandle? TryAcquire(TimeSpan timeout) + { + ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero); + var handle = new Handle(_name, timeout); + try + { + if (handle.Acquired) + { + return handle; + } + } + catch + { + handle.Dispose(); + throw; + } + + handle.Dispose(); + return null; + } + + private static string Sanitize(string scope) + { + Span buffer = scope.Length <= 128 + ? stackalloc char[scope.Length] + : new char[scope.Length]; + + for (int index = 0; index < scope.Length; index++) + { + char value = scope[index]; + buffer[index] = char.IsAsciiLetterOrDigit(value) ? value : '_'; + } + + return new string(buffer); + } + + private sealed class Handle : ISessionLockHandle + { + private readonly ManualResetEventSlim _release = new(); + private readonly TaskCompletionSource _acquired = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Thread _owner; + private int _disposed; + + public Handle(string name, TimeSpan timeout) + { + // Mutex ownership is thread-affine; lifecycle methods await backend + // I/O and can dispose on a different thread. Keep ownership on one + // dedicated thread while the caller holds this transferable lease. + _owner = new Thread(() => Hold(name, timeout)) { IsBackground = true }; + _owner.Start(); + } + + public bool Acquired => _acquired.Task.GetAwaiter().GetResult(); + + private void Hold(string name, TimeSpan timeout) + { + try + { + using var mutex = new Mutex(false, name); + bool acquired; + try + { + acquired = mutex.WaitOne(timeout); + } + catch (AbandonedMutexException) + { + acquired = true; + } + + _acquired.SetResult(acquired); + if (acquired) + { + _release.Wait(); + mutex.ReleaseMutex(); + } + } + catch (Exception exception) when ( + exception is UnauthorizedAccessException or IOException or + WaitHandleCannotBeOpenedException) + { + _acquired.TrySetException(exception); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + _release.Set(); + _owner.Join(); + _release.Dispose(); + } + } +} diff --git a/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs b/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs new file mode 100644 index 00000000..a4c917a2 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs @@ -0,0 +1,152 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Whether the user has asked for, or ruled out, isolated-session execution. +/// +internal enum SessionMode +{ + /// Use a session wherever the backend reports support. + Automatic, + + /// Never use a session. + Disabled, + + /// Use a session, and fail rather than run outside one. + Required, +} + +/// +/// Where an openclaw invocation will run. +/// +internal enum SessionRouting +{ + /// Inside the owned isolated session. + Session, + + /// Directly on the host, as before this feature existed. + Direct, +} + +/// +/// The routing choice and the reason for it, so diagnostics can explain it. +/// +internal sealed record SessionRoutingDecision(SessionRouting Routing, string Reason); + +/// +/// Chooses between isolated-session and direct execution. +/// +internal static class SessionRoutingPolicy +{ + /// + /// Overrides the automatic choice. Unset means automatic. + /// + public const string ModeVariable = "OPENCLAW_SESSION"; + + public static SessionMode ReadMode(Func readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + + string? value = readEnvironmentVariable(ModeVariable)?.Trim(); + if (string.IsNullOrEmpty(value)) + { + return SessionMode.Automatic; + } + + // Upper-case normalization, because CA1308 warns that lower-casing can + // lose information for some cultures. The comparison set is ASCII, so + // either direction matches; upper-case is the safe convention. + return value.ToUpperInvariant() switch + { + "0" or "FALSE" or "OFF" or "NO" => SessionMode.Disabled, + "1" or "TRUE" or "ON" or "YES" => SessionMode.Required, + + // An unrecognized value is not treated as "off". Silently ignoring + // it would run outside the session the user was trying to request. + _ => throw new SessionException( + $"{ModeVariable} is set to '{value}', which is not one of " + + "1, 0, true, false, on, off, yes, or no."), + }; + } + + /// + /// Decides where to run. + /// + /// + /// + /// A machine that cannot host sessions runs directly, exactly as it did + /// before this feature existed. That is a capability of the machine, not a + /// failure to hide. + /// + /// + /// There is deliberately no fallback once a session is chosen. A backend + /// that breaks on a supported machine must surface, not quietly relocate + /// the user's work onto the host with a different profile and different + /// isolation. + /// + /// + public static SessionRoutingDecision Decide( + SessionMode mode, + string? packageFamilyName, + MxcReadinessReport readiness) + { + ArgumentNullException.ThrowIfNull(readiness); + + if (mode == SessionMode.Disabled) + { + return new SessionRoutingDecision( + SessionRouting.Direct, + $"{ModeVariable} is set to 0."); + } + + if (packageFamilyName is null) + { + return Unavailable( + mode, + "OpenClaw is not running from its installed package, so it has " + + "no identity to provision an isolated session with."); + } + + if (!readiness.RuntimeAvailable) + { + return Unavailable( + mode, + "The isolated-session runtime is unavailable: " + + (readiness.RuntimeUnavailableReason ?? "no reason was reported.")); + } + + if (readiness.BackendProbe is { IsolationSessionAvailable: false }) + { + return Unavailable( + mode, + "This machine's isolated-session backend reported that it is " + + "not available."); + } + + if (readiness.BackendProbe is null && + readiness.HostSupport != MxcHostSupport.Supported) + { + string detail = readiness.HostSupport == MxcHostSupport.Unsupported + ? "This Windows build does not support isolated agent sessions." + : "Isolated-session support could not be determined on this machine."; + return Unavailable( + mode, + readiness.BackendProbeFailureReason is null + ? detail + : $"{detail} The backend probe failed: " + + readiness.BackendProbeFailureReason); + } + + return new SessionRoutingDecision( + SessionRouting.Session, + "The isolated-session backend is available."); + } + + private static SessionRoutingDecision Unavailable(SessionMode mode, string reason) => + mode == SessionMode.Required + ? throw new SessionException( + $"{ModeVariable} requires an isolated session, but one cannot " + + $"be used. {reason}") + : new SessionRoutingDecision(SessionRouting.Direct, reason); +} diff --git a/src/OpenClaw.Launcher/Session/SessionStateStore.cs b/src/OpenClaw.Launcher/Session/SessionStateStore.cs new file mode 100644 index 00000000..4cbbfa3d --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionStateStore.cs @@ -0,0 +1,287 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// The durable record of the session this installation owns. +/// +/// +/// +/// The opaque backend id is stored verbatim. It carries the provisioning +/// application identity and backend routing prefix, so it cannot be rebuilt +/// from parts and must never be normalized on the way in or out. +/// +/// +/// Ownership lives here and nowhere else. The backend's deprovision is +/// idempotent and unrelated agent accounts can already exist on a machine, so +/// neither the presence of an account nor the absence of an error proves this +/// installation created anything. +/// +/// +internal sealed record SessionRecord +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("sandboxId")] + public string SandboxId { get; init; } = string.Empty; + + [JsonPropertyName("applicationId")] + public string ApplicationId { get; init; } = string.Empty; + + [JsonPropertyName("agentUserName")] + public string? AgentUserName { get; init; } + + [JsonPropertyName("agentUserSid")] + public string? AgentUserSid { get; init; } + + [JsonPropertyName("workspacePath")] + public string? WorkspacePath { get; init; } + + [JsonPropertyName("generation")] + public string Generation { get; init; } = string.Empty; + + [JsonPropertyName("wireVersion")] + public string? WireVersion { get; init; } + + [JsonPropertyName("createdUtc")] + public DateTimeOffset CreatedUtc { get; init; } +} + +/// +/// Why a stored session record could not be used. +/// +internal enum SessionStateFault +{ + /// No record has been written yet. + Missing, + + /// The file exists but is not readable as a record. + Unreadable, + + /// The record was written by an incompatible newer version. + UnsupportedSchema, + + /// The record is missing values it cannot be used without. + Incomplete, + + /// The record belongs to a different package identity. + ForeignIdentity, +} + +/// +/// The outcome of reading the stored session record. +/// +internal sealed record SessionStateResult( + SessionRecord? Record, + SessionStateFault? Fault, + string? Detail) +{ + public bool HasRecord => Record is not null; + + public static SessionStateResult Found(SessionRecord record) => + new(record, null, null); + + public static SessionStateResult Failed(SessionStateFault fault, string detail) => + new(null, fault, detail); +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(SessionRecord))] +internal sealed partial class SessionStateJsonContext : JsonSerializerContext +{ +} + +/// +/// Reads and atomically writes the owned-session record. +/// +internal sealed class SessionStateStore +{ + public const int CurrentSchemaVersion = 2; + + private readonly string _filePath; + + public SessionStateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = Path.GetFullPath(filePath); + } + + public string FilePath => _filePath; + + /// + /// Reads the record, distinguishing "no session" from "unusable session". + /// + /// + /// A damaged record never degrades to . + /// Provisioning a replacement over a record we cannot read would abandon a + /// live backend session and the user's guest profile with it. + /// + public SessionStateResult Read(string expectedApplicationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedApplicationId); + + string text; + try + { + text = File.ReadAllText(_filePath); + } + catch (FileNotFoundException) + { + return SessionStateResult.Failed( + SessionStateFault.Missing, + "No session has been recorded."); + } + catch (DirectoryNotFoundException) + { + return SessionStateResult.Failed( + SessionStateFault.Missing, + "No session has been recorded."); + } + catch (IOException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + $"The session record could not be read: {exception.Message}"); + } + catch (UnauthorizedAccessException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + $"The session record could not be read: {exception.Message}"); + } + + SessionRecord? record; + try + { + record = JsonSerializer.Deserialize( + text, + SessionStateJsonContext.Default.SessionRecord); + } + catch (JsonException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + $"The session record is not valid JSON: {exception.Message}"); + } + + if (record is null) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + "The session record is empty."); + } + + if (record.SchemaVersion > CurrentSchemaVersion) + { + return SessionStateResult.Failed( + SessionStateFault.UnsupportedSchema, + $"The session record uses schema version {record.SchemaVersion}, but " + + $"this build understands version {CurrentSchemaVersion}. A newer " + + "OpenClaw installation may have written it."); + } + + if (record.SchemaVersion < 1) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + "The session record does not declare a schema version."); + } + + if (record.SchemaVersion < CurrentSchemaVersion) + { + return SessionStateResult.Failed( + SessionStateFault.UnsupportedSchema, + $"The session record uses schema version {record.SchemaVersion}, but " + + $"this pre-release build requires version {CurrentSchemaVersion}. Run " + + "`clawctl setup --fresh --force` to recreate it."); + } + + if (string.IsNullOrWhiteSpace(record.ApplicationId)) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + "The session record does not contain an application identity."); + } + + if (string.IsNullOrWhiteSpace(record.Generation)) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + "The session record does not contain an operation generation."); + } + + // Identity is checked before the sandbox id so a record from another + // installation is never reported as this installation's corruption. + if (!string.Equals( + record.ApplicationId, + expectedApplicationId, + StringComparison.OrdinalIgnoreCase)) + { + return SessionStateResult.Failed( + SessionStateFault.ForeignIdentity, + $"The session record belongs to '{record.ApplicationId}', but this " + + $"installation is '{expectedApplicationId}'."); + } + + try + { + _ = MxcSandboxId.Parse(record.SandboxId); + } + catch (MxcException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + $"The session record does not contain a usable sandbox identifier: " + + $"{exception.Message}"); + } + + return SessionStateResult.Found(record); + } + + /// + /// Replaces the record atomically. + /// + /// + /// The write lands on a temporary file in the same directory and is then + /// moved over the target, so a crash mid-write cannot leave a truncated + /// record that would later read as a damaged session. + /// + public void Write(SessionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(record.ApplicationId); + ArgumentException.ThrowIfNullOrWhiteSpace(record.Generation); + _ = MxcSandboxId.Parse(record.SandboxId); + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string text = JsonSerializer.Serialize( + record with { SchemaVersion = CurrentSchemaVersion }, + SessionStateJsonContext.Default.SessionRecord); + + string temporaryPath = _filePath + ".tmp"; + File.WriteAllText(temporaryPath, text); + File.Move(temporaryPath, _filePath, overwrite: true); + } + + /// + /// Removes the record. Deleting nothing is success. + /// + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/Session/SetupStateStore.cs b/src/OpenClaw.Launcher/Session/SetupStateStore.cs new file mode 100644 index 00000000..356ea927 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SetupStateStore.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Session; + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum SetupPhase +{ + Ready, + Preparing, + TearingDown +} + +/// +/// The durable marker that this installation completed explicit setup. +/// +/// +/// The marker is separate from the session record because older management +/// commands can create a session without completing the new setup workflow. +/// A valid session therefore does not, by itself, authorize openclaw. +/// +internal sealed record SetupRecord +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("applicationId")] + public string ApplicationId { get; init; } = string.Empty; + + [JsonPropertyName("completedUtc")] + public DateTimeOffset CompletedUtc { get; init; } + + [JsonPropertyName("phase")] + public SetupPhase Phase { get; init; } = SetupPhase.Ready; + + [JsonPropertyName("sandboxId")] + public string? SandboxId { get; init; } + + [JsonPropertyName("startupEnabled")] + public bool StartupEnabled { get; init; } = true; +} + +/// Why the explicit setup marker could not be used. +internal enum SetupStateFault +{ + /// Setup has not completed for this installation. + Missing, + + /// The marker exists but cannot be read as a record. + Unreadable, + + /// The marker was written by an incompatible newer version. + UnsupportedSchema, + + /// The marker is missing values it cannot be used without. + Incomplete, + + /// The marker belongs to a different package identity. + ForeignIdentity, +} + +internal sealed record SetupStateResult( + SetupRecord? Record, + SetupStateFault? Fault, + string? Detail) +{ + public static SetupStateResult Found(SetupRecord record) => + new(record, null, null); + + public static SetupStateResult Failed(SetupStateFault fault, string detail) => + new(null, fault, detail); +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(SetupRecord))] +internal sealed partial class SetupStateJsonContext : JsonSerializerContext; + +/// +/// Reads and atomically writes the explicit setup marker. +/// +internal sealed class SetupStateStore +{ + public const int CurrentSchemaVersion = 2; + + private readonly string _filePath; + + public SetupStateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = Path.GetFullPath(filePath); + } + + public string FilePath => _filePath; + + public SetupStateResult Read(string expectedApplicationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedApplicationId); + + string text; + try + { + text = File.ReadAllText(_filePath); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return SetupStateResult.Failed( + SetupStateFault.Missing, + "Explicit setup has not completed."); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return SetupStateResult.Failed( + SetupStateFault.Unreadable, + $"The setup record could not be read: {exception.Message}"); + } + + SetupRecord? record; + try + { + record = JsonSerializer.Deserialize( + text, + SetupStateJsonContext.Default.SetupRecord); + } + catch (JsonException exception) + { + return SetupStateResult.Failed( + SetupStateFault.Unreadable, + $"The setup record is not valid JSON: {exception.Message}"); + } + + if (record is null) + { + return SetupStateResult.Failed( + SetupStateFault.Unreadable, + "The setup record is empty."); + } + + if (record.SchemaVersion > CurrentSchemaVersion) + { + return SetupStateResult.Failed( + SetupStateFault.UnsupportedSchema, + $"The setup record uses schema version {record.SchemaVersion}, " + + $"but this build understands version {CurrentSchemaVersion}. A " + + "newer OpenClaw installation may have written it."); + } + + if (record.SchemaVersion < 1 || + string.IsNullOrWhiteSpace(record.ApplicationId) || + !Enum.IsDefined(record.Phase)) + { + return SetupStateResult.Failed( + SetupStateFault.Incomplete, + "The setup record is missing its schema version or application identity."); + } + + if (!string.Equals( + record.ApplicationId, + expectedApplicationId, + StringComparison.OrdinalIgnoreCase)) + { + return SetupStateResult.Failed( + SetupStateFault.ForeignIdentity, + $"The setup record belongs to '{record.ApplicationId}', but this " + + $"installation is '{expectedApplicationId}'."); + } + + return SetupStateResult.Found(record); + } + + public void Write(SetupRecord record) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(record.ApplicationId); + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string text = JsonSerializer.Serialize( + record with { SchemaVersion = CurrentSchemaVersion }, + SetupStateJsonContext.Default.SetupRecord); + + string temporaryPath = _filePath + ".tmp"; + File.WriteAllText(temporaryPath, text); + File.Move(temporaryPath, _filePath, overwrite: true); + } + + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs new file mode 100644 index 00000000..390394d0 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs @@ -0,0 +1,124 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Records every lifecycle call so tests can assert ordering and ownership. +/// +internal sealed class FakeMxcSessionClient : IMxcSessionClient +{ + private int _provisionCount; + + public List Calls { get; } = []; + + public List ProvisionedAppIds { get; } = []; + + public string SandboxIdPrefix { get; set; } = "iso:sandbox"; + + public MxcProvisionMetadata? Metadata { get; set; } = new( + "agent_1", + "S-1-5-21-0-0-0-1001", + @"C:\Users\agent_1\Shared"); + + public Exception? ProvisionFailure { get; set; } + + public Exception? StartFailure { get; set; } + + public Exception? StopFailure { get; set; } + + public Exception? DeprovisionFailure { get; set; } + + public Exception? ExecuteFailure { get; set; } + + public MxcExecutionResult ExecutionResult { get; set; } = + new(0, string.Empty, string.Empty); + + public List ExecutedCommandLines { get; } = []; + + public Func>? ExecuteBehavior { get; set; } + + public Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) + { + Calls.Add("provision"); + ProvisionedAppIds.Add(request.AppId); + if (ProvisionFailure is not null) + { + return Task.FromException(ProvisionFailure); + } + + _provisionCount++; + return Task.FromResult(new MxcProvisionResult( + MxcSandboxId.Parse($"{SandboxIdPrefix}{_provisionCount}"), + Metadata, + null)); + } + + public Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"start:{sandboxId.Value}"); + return StartFailure is not null + ? Task.FromException(StartFailure) + : Task.CompletedTask; + } + + public Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"execute:{sandboxId.Value}"); + ExecutedCommandLines.Add(request.CommandLine); + return ExecuteFailure is not null + ? Task.FromException(ExecuteFailure) + : ExecuteBehavior is not null ? ExecuteBehavior(request) : Task.FromResult(ExecutionResult); + } + + public int AttachedExitCode { get; set; } + + public List AttachedCommandLines { get; } = []; + + public Func>? AttachedBehavior { get; set; } + + public Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"execute-attached:{sandboxId.Value}"); + AttachedCommandLines.Add(request.CommandLine); + return AttachedBehavior is not null + ? AttachedBehavior(cancellationToken) + : ExecuteFailure is not null + ? Task.FromException(ExecuteFailure) + : Task.FromResult(AttachedExitCode); + } + + public Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"stop:{sandboxId.Value}"); + return StopFailure is not null + ? Task.FromException(StopFailure) + : Task.CompletedTask; + } + + public Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"deprovision:{sandboxId.Value}"); + return DeprovisionFailure is not null + ? Task.FromException(DeprovisionFailure) + : Task.CompletedTask; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs new file mode 100644 index 00000000..b1946fd9 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs @@ -0,0 +1,72 @@ + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class HostPathsTests +{ + [Fact] + public void UnpackagedStateRootLivesUnderLocalApplicationData() + { + HostPaths paths = HostPaths.Create(@"C:\Users\test\AppData\Local", null); + + Assert.Equal( + @"C:\Users\test\AppData\Local\OpenClawGatewayMSIX", + paths.StateRoot); + Assert.Null(paths.PackageFamilyName); + } + + [Fact] + public void PackagedStateRootLivesInsidePackageLocalState() + { + HostPaths paths = HostPaths.Create( + @"C:\Users\test\AppData\Local", + "OpenClaw.Gateway_abc123"); + + Assert.Equal( + @"C:\Users\test\AppData\Local\Packages\OpenClaw.Gateway_abc123\LocalState\OpenClawGatewayMSIX", + paths.StateRoot); + Assert.Equal("OpenClaw.Gateway_abc123", paths.PackageFamilyName); + } + + [Fact] + public void DifferentPackageIdentitiesDoNotShareState() + { + HostPaths first = HostPaths.Create(@"C:\local", "Public_abc"); + HostPaths second = HostPaths.Create(@"C:\local", "Internal_xyz"); + + Assert.NotEqual(first.StateRoot, second.StateRoot); + Assert.NotEqual(first.SessionStatePath, second.SessionStatePath); + } + + [Fact] + public void DerivedPathsSitUnderTheStateRoot() + { + HostPaths paths = HostPaths.Create(@"C:\local", null); + + Assert.StartsWith(paths.StateRoot, paths.LogPath, StringComparison.Ordinal); + Assert.StartsWith(paths.StateRoot, paths.SessionStatePath, StringComparison.Ordinal); + Assert.NotEqual(paths.LogPath, paths.SessionStatePath); + } + + [Fact] + public void MissingLocalApplicationDataIsReported() + { + Assert.Throws( + () => HostPaths.Create(string.Empty, null)); + } + + [Fact] + public void ApplicationIdCarriesThePackageFamilyNamePrefix() + { + Assert.Equal( + "PFN:OpenClaw.Gateway_abc123", + PackageIdentity.ToApplicationId("OpenClaw.Gateway_abc123")); + } + + [Fact] + public void PackageFamilyNameIsAbsentWhenRunningUnpackaged() + { + // The test host is never packaged, so this also proves that unpackaged + // is reported rather than thrown. + Assert.Null(PackageIdentity.TryGetPackageFamilyName()); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs new file mode 100644 index 00000000..788bb95a --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs @@ -0,0 +1,399 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionCoordinatorTests : IDisposable +{ + private const string ApplicationId = "PFN:OpenClaw.Gateway_abc123"; + + private readonly string _root = TestDirectory.Create(); + private readonly FakeMxcSessionClient _backend = new(); + private readonly List _log = []; + + private string StatePath => Path.Combine(_root, "session.json"); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private SessionStateStore Store() => new(StatePath); + + private SessionCoordinator Create( + ISessionLock? sessionLock = null, + SessionStateStore? store = null, + TimeSpan? lockTimeout = null) => + new( + _backend, + store ?? Store(), + sessionLock ?? new AlwaysFreeLock(), + ApplicationId, + _log.Add, + new FixedTimeProvider(new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero)), + lockTimeout); + + [Fact] + public async Task FirstUseProvisionsAndStartsExactlyOnce() + { + SessionCoordinator coordinator = Create(); + + SessionRecord record = await coordinator.EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(["provision", "start:iso:sandbox1"], _backend.Calls); + Assert.Equal("iso:sandbox1", record.SandboxId); + } + + [Fact] + public async Task FirstUseProvisionsWithThisInstallationsIdentity() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal([ApplicationId], _backend.ProvisionedAppIds); + } + + [Fact] + public async Task ProvisionMetadataIsPersisted() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + SessionRecord record = Store().Read(ApplicationId).Record!; + Assert.Equal("agent_1", record.AgentUserName); + Assert.Equal("S-1-5-21-0-0-0-1001", record.AgentUserSid); + Assert.Equal(@"C:\Users\agent_1\Shared", record.WorkspacePath); + Assert.Equal( + new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero), + record.CreatedUtc); + } + + [Fact] + public async Task OwnershipIsRecordedBeforeTheSessionIsStarted() + { + // A crash between provision and start must not leave a sandbox that + // nothing claims; the backend cannot be asked which sandboxes are ours. + _backend.StartFailure = new MxcException(MxcErrorCode.BackendError, "start failed"); + + await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.True(Store().Read(ApplicationId).HasRecord); + } + + [Fact] + public async Task FailedProvisionRecordsNothing() + { + _backend.ProvisionFailure = new MxcException( + MxcErrorCode.BackendError, + "provision failed"); + + await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + } + + [Fact] + public async Task SecondUseReusesTheRecordedSessionWithoutProvisioning() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + SessionRecord record = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + Assert.Equal("iso:sandbox1", record.SandboxId); + } + + [Fact] + public async Task SeparateCoordinatorsConvergeOnOneSession() + { + // Distinct instances stand in for distinct processes sharing the store. + SessionRecord first = await Create().EnsureStartedAsync(CancellationToken.None); + SessionRecord second = await Create().EnsureStartedAsync(CancellationToken.None); + SessionRecord third = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(first.SandboxId, second.SandboxId); + Assert.Equal(first.SandboxId, third.SandboxId); + Assert.Single(_backend.Calls, call => call == "provision"); + } + + [Fact] + public async Task ReuseStartsTheSessionBecauseItMayHaveBeenStopped() + { + // A stopped session fails execution with backend_error rather than + // restarting itself, and liveness cannot be queried. + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Contains("start:iso:sandbox1", _backend.Calls); + } + + [Fact] + public async Task UnreadableRecordIsNotReplacedBySilentProvisioning() + { + File.WriteAllText(StatePath, "{ not json"); + + SessionStateException exception = await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(SessionStateFault.Unreadable, exception.Fault); + Assert.DoesNotContain("provision", _backend.Calls); + } + + [Fact] + public async Task ForeignRecordIsNotAdopted() + { + Store().Write(new SessionRecord + { + SandboxId = "iso:other", + ApplicationId = "PFN:Internal.Gateway_xyz", + Generation = "other-generation", + }); + + SessionStateException exception = await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(SessionStateFault.ForeignIdentity, exception.Fault); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task BusyLockIsReportedRatherThanWaitingForever() + { + SessionCoordinator coordinator = Create( + new NeverFreeLock(), + lockTimeout: TimeSpan.FromMilliseconds(1)); + + await Assert.ThrowsAsync( + () => coordinator.EnsureStartedAsync(CancellationToken.None)); + + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task LockIsReleasedAfterAFailedOperation() + { + var sessionLock = new AlwaysFreeLock(); + _backend.ProvisionFailure = new MxcException(MxcErrorCode.BackendError, "no"); + + await Assert.ThrowsAsync( + () => Create(sessionLock).EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(0, sessionLock.HeldCount); + } + + [Fact] + public async Task LockIsNotHeldAfterASuccessfulOperation() + { + var sessionLock = new AlwaysFreeLock(); + + await Create(sessionLock).EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(0, sessionLock.HeldCount); + } + + [Fact] + public void StatusWithoutASessionReportsNone() + { + SessionStatus status = Create().GetRecordedStatus(); + + Assert.Equal(SessionAvailability.None, status.Availability); + Assert.Null(status.Record); + } + + [Fact] + public async Task StatusDoesNotProvisionOrContactTheBackend() + { + Create().GetRecordedStatus(); + + Assert.Empty(_backend.Calls); + Assert.False(File.Exists(StatePath)); + await Task.CompletedTask.ConfigureAwait(true); + } + + [Fact] + public async Task StatusReportsTheRecordedSession() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + SessionStatus status = Create().GetRecordedStatus(); + + Assert.Equal(SessionAvailability.Recorded, status.Availability); + Assert.Equal("iso:sandbox1", status.Record!.SandboxId); + Assert.Empty(_backend.Calls); + } + + [Fact] + public void StatusDistinguishesUnusableFromAbsent() + { + File.WriteAllText(StatePath, "{ not json"); + + SessionStatus status = Create().GetRecordedStatus(); + + Assert.Equal(SessionAvailability.Unusable, status.Availability); + Assert.Equal(SessionStateFault.Unreadable, status.Fault); + Assert.NotNull(status.Detail); + } + + [Fact] + public async Task StopRetainsTheRecordAndTheProfile() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + bool stopped = await Create().StopAsync(CancellationToken.None); + + Assert.True(stopped); + Assert.Equal(["stop:iso:sandbox1"], _backend.Calls); + Assert.True(Store().Read(ApplicationId).HasRecord); + } + + [Fact] + public async Task StopWithoutASessionDoesNothing() + { + bool stopped = await Create().StopAsync(CancellationToken.None); + + Assert.False(stopped); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task StopDoesNotDeprovision() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + await Create().StopAsync(CancellationToken.None); + + Assert.DoesNotContain("deprovision:iso:sandbox1", _backend.Calls); + } + + [Fact] + public async Task StoppedSessionIsStartedAgainOnNextUse() + { + await Create().EnsureStartedAsync(CancellationToken.None); + await Create().StopAsync(CancellationToken.None); + _backend.Calls.Clear(); + + await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task RemoveStopsBeforeDeprovisioning() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + SessionRemovalResult result = await Create().RemoveAsync(CancellationToken.None); + + Assert.True(result.Removed); + Assert.Null(result.StopFailure); + Assert.Equal(["stop:iso:sandbox1", "deprovision:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task RemoveForgetsTheSessionOnlyAfterDeprovisionSucceeds() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + await Create().RemoveAsync(CancellationToken.None); + + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + } + + [Fact] + public async Task FailedDeprovisionRetainsOwnership() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.DeprovisionFailure = new MxcException( + MxcErrorCode.BackendError, + "deprovision failed"); + + await Assert.ThrowsAsync( + () => Create().RemoveAsync(CancellationToken.None)); + + Assert.True(Store().Read(ApplicationId).HasRecord); + } + + [Fact] + public async Task FailedStopStillDeprovisionsAndIsReported() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.StopFailure = new MxcException(MxcErrorCode.BackendError, "stop failed"); + _backend.Calls.Clear(); + + SessionRemovalResult result = await Create().RemoveAsync(CancellationToken.None); + + Assert.True(result.Removed); + Assert.Equal("stop failed", result.StopFailure); + Assert.Contains("deprovision:iso:sandbox1", _backend.Calls); + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + } + + [Fact] + public async Task RemoveWithoutASessionDoesNothing() + { + SessionRemovalResult result = await Create().RemoveAsync(CancellationToken.None); + + Assert.False(result.Removed); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task RemoveDoesNotTouchAnotherInstallationsSession() + { + Store().Write(new SessionRecord + { + SandboxId = "iso:other", + ApplicationId = "PFN:Internal.Gateway_xyz", + Generation = "other-generation", + }); + + await Assert.ThrowsAsync( + () => Create().RemoveAsync(CancellationToken.None)); + + Assert.Empty(_backend.Calls); + Assert.True(File.Exists(StatePath)); + } + + [Fact] + public async Task RemoveRefusesARecordItCannotRead() + { + File.WriteAllText(StatePath, "{ not json"); + + await Assert.ThrowsAsync( + () => Create().RemoveAsync(CancellationToken.None)); + + Assert.Empty(_backend.Calls); + await Task.CompletedTask.ConfigureAwait(true); + } + + [Fact] + public void ForgetDiscardsAnUnreadableRecordWithoutContactingTheBackend() + { + File.WriteAllText(StatePath, "{ not json"); + + Create().ForgetRecordedState(); + + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task NewSessionCanBeCreatedAfterRemoval() + { + await Create().EnsureStartedAsync(CancellationToken.None); + await Create().RemoveAsync(CancellationToken.None); + + SessionRecord record = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal("iso:sandbox2", record.SandboxId); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs new file mode 100644 index 00000000..9eda17d4 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs @@ -0,0 +1,159 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionLockTests +{ + private static NamedSessionLock CreateLock(string scope) => + new($"{scope}-{Guid.NewGuid():N}"); + + [Fact] + public void LockIsAcquiredWhenFree() + { + NamedSessionLock sessionLock = CreateLock("free"); + + using ISessionLockHandle? handle = sessionLock.TryAcquire(TimeSpan.Zero); + + Assert.NotNull(handle); + } + + [Fact] + public void SecondAcquisitionFailsWhileTheFirstIsHeld() + { + NamedSessionLock sessionLock = CreateLock("contended"); + using ISessionLockHandle? first = sessionLock.TryAcquire(TimeSpan.Zero); + Assert.NotNull(first); + + // A separate thread stands in for a separate process: a named mutex is + // reentrant for its owning thread, so the same thread would succeed. + ISessionLockHandle? second = null; + var thread = new Thread(() => second = sessionLock.TryAcquire(TimeSpan.Zero)); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(10))); + + Assert.Null(second); + } + + [Fact] + public void LockIsAvailableAgainAfterRelease() + { + NamedSessionLock sessionLock = CreateLock("release"); + sessionLock.TryAcquire(TimeSpan.Zero)!.Dispose(); + + ISessionLockHandle? second = null; + var thread = new Thread(() => second = sessionLock.TryAcquire(TimeSpan.FromSeconds(5))); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(10))); + + Assert.NotNull(second); + second!.Dispose(); + } + + [Fact] + public void DifferentScopesDoNotContend() + { + NamedSessionLock first = CreateLock("scope-a"); + NamedSessionLock second = CreateLock("scope-b"); + + using ISessionLockHandle? firstHandle = first.TryAcquire(TimeSpan.Zero); + ISessionLockHandle? secondHandle = null; + var thread = new Thread(() => secondHandle = second.TryAcquire(TimeSpan.Zero)); + thread.Start(); + thread.Join(TimeSpan.FromSeconds(10)); + + Assert.NotNull(firstHandle); + Assert.NotNull(secondHandle); + secondHandle!.Dispose(); + } + + [Fact] + public void WaitingCallerAcquiresOnceTheHolderReleases() + { + NamedSessionLock sessionLock = CreateLock("handoff"); + ISessionLockHandle? holder = sessionLock.TryAcquire(TimeSpan.Zero); + Assert.NotNull(holder); + + using var waiterStarted = new ManualResetEventSlim(); + ISessionLockHandle? waiterHandle = null; + var waiter = new Thread(() => + { + waiterStarted.Set(); + waiterHandle = sessionLock.TryAcquire(TimeSpan.FromSeconds(30)); + }); + waiter.Start(); + + Assert.True(waiterStarted.Wait(TimeSpan.FromSeconds(10))); + holder!.Dispose(); + + Assert.True(waiter.Join(TimeSpan.FromSeconds(30))); + Assert.NotNull(waiterHandle); + waiterHandle!.Dispose(); + } + + [Fact] + public void AbandonedLockIsRecoveredRatherThanDeadlocking() + { + NamedSessionLock sessionLock = CreateLock("abandoned"); + + // Abandon the actual kernel mutex, not a transferable lease (which + // deliberately keeps ownership on a surviving dedicated thread). + using var kernelMutex = new Mutex(false, sessionLock.Name); + var abandoner = new Thread(() => kernelMutex.WaitOne()); + abandoner.Start(); + Assert.True(abandoner.Join(TimeSpan.FromSeconds(10))); + + using ISessionLockHandle? handle = sessionLock.TryAcquire(TimeSpan.FromSeconds(10)); + + Assert.NotNull(handle); + } + + [Fact] + public void AsyncContinuationCanReleaseLeaseFromAnotherThread() + { + NamedSessionLock sessionLock = CreateLock("cross-thread"); + ISessionLockHandle handle = sessionLock.TryAcquire(TimeSpan.Zero)!; + var continuation = new Thread(handle.Dispose); + continuation.Start(); + Assert.True(continuation.Join(TimeSpan.FromSeconds(10))); + using ISessionLockHandle? next = sessionLock.TryAcquire(TimeSpan.Zero); + Assert.NotNull(next); + } + + [Fact] + public void DisposingTwiceIsSafe() + { + NamedSessionLock sessionLock = CreateLock("double-dispose"); + ISessionLockHandle handle = sessionLock.TryAcquire(TimeSpan.Zero)!; + + handle.Dispose(); + handle.Dispose(); + + using ISessionLockHandle? reacquired = sessionLock.TryAcquire(TimeSpan.FromSeconds(5)); + Assert.NotNull(reacquired); + } + + [Fact] + public void NameIsScopedToTheCurrentUserSession() + { + var sessionLock = new NamedSessionLock("PFN:OpenClaw.Gateway_abc123"); + + Assert.StartsWith("Local\\", sessionLock.Name, StringComparison.Ordinal); + Assert.DoesNotContain(':', sessionLock.Name[6..]); + Assert.DoesNotContain('\\', sessionLock.Name[6..]); + } + + [Fact] + public void EmptyScopeIsRejected() + { + Assert.Throws(() => new NamedSessionLock(" ")); + } + + [Fact] + public void NegativeTimeoutIsRejected() + { + NamedSessionLock sessionLock = CreateLock("negative"); + + Assert.Throws( + () => sessionLock.TryAcquire(TimeSpan.FromSeconds(-1))); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs new file mode 100644 index 00000000..bd30c6f5 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs @@ -0,0 +1,241 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionRoutingPolicyTests +{ + private const string PackageFamilyName = "OpenClaw.Gateway_abc123"; + + private static MxcReadinessReport Ready( + bool? backendAvailable = true, + string? runtimeUnavailableReason = null, + MxcHostSupport hostSupport = MxcHostSupport.Supported, + string? probeFailureReason = null) => + new( + runtimeUnavailableReason is null ? @"C:\Package\mxc\x64" : null, + null, + runtimeUnavailableReason, + hostSupport, + null, + backendAvailable is null + ? MxcSupportEvidence.HostBuild + : MxcSupportEvidence.BackendProbe, + backendAvailable is null + ? null + : new MxcBackendProbe(backendAvailable.Value, "base-container", []), + probeFailureReason); + + private static Func Environment(string? value) => + name => name == SessionRoutingPolicy.ModeVariable ? value : null; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UnsetModeIsAutomatic(string? value) + { + Assert.Equal( + SessionMode.Automatic, + SessionRoutingPolicy.ReadMode(Environment(value))); + } + + [Theory] + [InlineData("0")] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("off")] + [InlineData("no")] + public void FalsyValuesDisableSessions(string value) + { + Assert.Equal( + SessionMode.Disabled, + SessionRoutingPolicy.ReadMode(Environment(value))); + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("On")] + [InlineData("yes")] + public void TruthyValuesRequireSessions(string value) + { + Assert.Equal( + SessionMode.Required, + SessionRoutingPolicy.ReadMode(Environment(value))); + } + + [Fact] + public void UnrecognizedValueIsRejectedRatherThanTreatedAsOff() + { + // Ignoring it would run outside the session the user asked for. + SessionException exception = Assert.Throws( + () => SessionRoutingPolicy.ReadMode(Environment("maybe"))); + + Assert.Contains("maybe", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SupportedMachineUsesTheSessionByDefault() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready()); + + Assert.Equal(SessionRouting.Session, decision.Routing); + } + + [Fact] + public void ProbeOverridesAnUnsupportedBuildVerdict() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: true, hostSupport: MxcHostSupport.Unsupported)); + + Assert.Equal(SessionRouting.Session, decision.Routing); + } + + [Fact] + public void ProbeSayingUnavailableRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: false)); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + Assert.Contains("not available", decision.Reason, StringComparison.Ordinal); + } + + [Fact] + public void UnsupportedBuildWithoutAProbeRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unsupported)); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + } + + [Fact] + public void UndeterminableSupportRunsDirectlyRatherThanGuessing() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unknown)); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + } + + [Fact] + public void ProbeFailureReasonIsCarriedIntoTheExplanation() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready( + backendAvailable: null, + hostSupport: MxcHostSupport.Unknown, + probeFailureReason: "the executor crashed")); + + Assert.Contains("the executor crashed", decision.Reason, StringComparison.Ordinal); + } + + [Fact] + public void MissingRuntimeRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(runtimeUnavailableReason: "the runtime directory is absent")); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + Assert.Contains( + "the runtime directory is absent", + decision.Reason, + StringComparison.Ordinal); + } + + [Fact] + public void UnpackagedBuildRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + null, + Ready()); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + Assert.Contains("installed package", decision.Reason, StringComparison.Ordinal); + } + + [Fact] + public void DisabledModeRunsDirectlyEvenWhenSupported() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Disabled, + PackageFamilyName, + Ready()); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + } + + [Fact] + public void RequiredModeSucceedsWhenSupported() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Required, + PackageFamilyName, + Ready()); + + Assert.Equal(SessionRouting.Session, decision.Routing); + } + + [Theory] + [InlineData("unpackaged")] + [InlineData("no-runtime")] + [InlineData("backend-unavailable")] + [InlineData("unsupported-build")] + public void RequiredModeFailsLoudlyRatherThanFallingBack(string scenario) + { + (string? packageFamilyName, MxcReadinessReport readiness) = scenario switch + { + "unpackaged" => (null, Ready()), + "no-runtime" => (PackageFamilyName, Ready(runtimeUnavailableReason: "absent")), + "backend-unavailable" => (PackageFamilyName, Ready(backendAvailable: false)), + _ => ( + PackageFamilyName, + Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unsupported)), + }; + + SessionException exception = Assert.Throws( + () => SessionRoutingPolicy.Decide( + SessionMode.Required, + packageFamilyName, + readiness)); + + Assert.Contains( + SessionRoutingPolicy.ModeVariable, + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void EveryDecisionCarriesAReason() + { + SessionRoutingDecision session = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready()); + SessionRoutingDecision direct = SessionRoutingPolicy.Decide( + SessionMode.Disabled, + PackageFamilyName, + Ready()); + + Assert.False(string.IsNullOrWhiteSpace(session.Reason)); + Assert.False(string.IsNullOrWhiteSpace(direct.Reason)); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs new file mode 100644 index 00000000..a6b85135 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs @@ -0,0 +1,278 @@ +using System.Text.Json; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionStateStoreTests : IDisposable +{ + private const string ApplicationId = "PFN:OpenClaw.Gateway_abc123"; + private const string SandboxId = "iso:AAAAbbbbCCCC"; + + private readonly string _root = TestDirectory.Create(); + + private string StatePath => Path.Combine(_root, "session.json"); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private static SessionRecord Record() => new() + { + SandboxId = SandboxId, + ApplicationId = ApplicationId, + AgentUserName = "agent_1", + AgentUserSid = "S-1-5-21-0-0-0-1001", + WorkspacePath = @"C:\Users\agent_1\Shared", + Generation = "test-generation", + WireVersion = "0.6.0-alpha", + CreatedUtc = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero), + }; + + [Fact] + public void MissingRecordIsReportedAsMissing() + { + var store = new SessionStateStore(StatePath); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.False(result.HasRecord); + Assert.Equal(SessionStateFault.Missing, result.Fault); + } + + [Fact] + public void MissingDirectoryIsMissingRatherThanUnreadable() + { + var store = new SessionStateStore( + Path.Combine(_root, "no-such-dir", "session.json")); + + Assert.Equal(SessionStateFault.Missing, store.Read(ApplicationId).Fault); + } + + [Fact] + public void WrittenRecordRoundTripsEveryPersistedField() + { + var store = new SessionStateStore(StatePath); + SessionRecord written = Record(); + + store.Write(written); + SessionStateResult result = store.Read(ApplicationId); + + SessionRecord read = Assert.IsType(result.Record); + Assert.Equal(SessionStateStore.CurrentSchemaVersion, read.SchemaVersion); + Assert.Equal(written.SandboxId, read.SandboxId); + Assert.Equal(written.ApplicationId, read.ApplicationId); + Assert.Equal(written.AgentUserName, read.AgentUserName); + Assert.Equal(written.AgentUserSid, read.AgentUserSid); + Assert.Equal(written.WorkspacePath, read.WorkspacePath); + Assert.Equal(written.Generation, read.Generation); + Assert.Equal(written.WireVersion, read.WireVersion); + Assert.Equal(written.CreatedUtc, read.CreatedUtc); + } + + [Fact] + public void OpaqueSandboxIdIsPreservedVerbatim() + { + const string opaque = "iso:eyJhIjoiYiJ9-_=="; + var store = new SessionStateStore(StatePath); + + store.Write(Record() with { SandboxId = opaque }); + + Assert.Equal(opaque, store.Read(ApplicationId).Record!.SandboxId); + } + + [Fact] + public void WriteStampsTheCurrentSchemaVersion() + { + var store = new SessionStateStore(StatePath); + + store.Write(Record() with { SchemaVersion = 0 }); + + Assert.Equal( + SessionStateStore.CurrentSchemaVersion, + store.Read(ApplicationId).Record!.SchemaVersion); + } + + [Fact] + public void WriteCreatesMissingDirectories() + { + var store = new SessionStateStore( + Path.Combine(_root, "deep", "deeper", "session.json")); + + store.Write(Record()); + + Assert.True(store.Read(ApplicationId).HasRecord); + } + + [Fact] + public void WriteLeavesNoTemporaryFileBehind() + { + var store = new SessionStateStore(StatePath); + + store.Write(Record()); + + Assert.Empty(Directory.GetFiles(_root, "*.tmp")); + } + + [Fact] + public void WriteReplacesAnExistingRecord() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + store.Write(Record() with { SandboxId = "iso:second", AgentUserName = "agent_2" }); + + SessionRecord read = store.Read(ApplicationId).Record!; + Assert.Equal("iso:second", read.SandboxId); + Assert.Equal("agent_2", read.AgentUserName); + } + + [Fact] + public void CorruptJsonIsUnreadableRatherThanMissing() + { + File.WriteAllText(StatePath, "{ not json"); + var store = new SessionStateStore(StatePath); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.Equal(SessionStateFault.Unreadable, result.Fault); + Assert.False(result.HasRecord); + } + + [Fact] + public void EmptyJsonDocumentIsUnreadable() + { + File.WriteAllText(StatePath, "null"); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Unreadable, store.Read(ApplicationId).Fault); + } + + [Fact] + public void NewerSchemaIsRejectedInsteadOfPartiallyRead() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + string text = File.ReadAllText(StatePath) + .Replace("\"schemaVersion\": 2", "\"schemaVersion\": 99", StringComparison.Ordinal); + File.WriteAllText(StatePath, text); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.Equal(SessionStateFault.UnsupportedSchema, result.Fault); + Assert.Contains("99", result.Detail!, StringComparison.Ordinal); + } + + [Fact] + public void RecordWithoutSchemaVersionIsIncomplete() + { + File.WriteAllText( + StatePath, + $$"""{"sandboxId":"{{SandboxId}}","applicationId":"{{ApplicationId}}"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Fact] + public void RecordWithoutApplicationIdIsIncomplete() + { + File.WriteAllText( + StatePath, + $$"""{"schemaVersion":2,"sandboxId":"{{SandboxId}}","generation":"test-generation"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Fact] + public void RecordWithoutGenerationIsIncomplete() + { + File.WriteAllText( + StatePath, + $$"""{"schemaVersion":2,"sandboxId":"{{SandboxId}}","applicationId":"{{ApplicationId}}"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Theory] + [InlineData("")] + [InlineData("no-prefix")] + public void RecordWithoutAUsableSandboxIdIsIncomplete(string sandboxId) + { + File.WriteAllText( + StatePath, + $$"""{"schemaVersion":2,"sandboxId":"{{sandboxId}}","applicationId":"{{ApplicationId}}","generation":"test-generation"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Fact] + public void RecordFromAnotherInstallationIsForeignRatherThanMissing() + { + var store = new SessionStateStore(StatePath); + store.Write(Record() with { ApplicationId = "PFN:Internal.Gateway_xyz" }); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.Equal(SessionStateFault.ForeignIdentity, result.Fault); + Assert.Contains("Internal.Gateway_xyz", result.Detail!, StringComparison.Ordinal); + } + + [Fact] + public void ApplicationIdComparisonIgnoresCase() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + Assert.True(store.Read(ApplicationId.ToUpperInvariant()).HasRecord); + } + + [Fact] + public void WriteRejectsAnUnusableSandboxIdBeforeTouchingDisk() + { + var store = new SessionStateStore(StatePath); + + Assert.ThrowsAny( + () => store.Write(Record() with { SandboxId = "no-prefix" })); + Assert.False(File.Exists(StatePath)); + } + + [Fact] + public void ClearRemovesTheRecord() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + store.Clear(); + + Assert.Equal(SessionStateFault.Missing, store.Read(ApplicationId).Fault); + } + + [Fact] + public void ClearWithoutARecordSucceeds() + { + var store = new SessionStateStore( + Path.Combine(_root, "absent", "session.json")); + + store.Clear(); + } + + [Fact] + public void PersistedFileIsReadableJson() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(StatePath)); + + Assert.Equal( + SandboxId, + document.RootElement.GetProperty("sandboxId").GetString()); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs new file mode 100644 index 00000000..31a4dbf4 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs @@ -0,0 +1,29 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +internal sealed class AlwaysFreeLock : ISessionLock +{ + public int HeldCount { get; private set; } + + public ISessionLockHandle? TryAcquire(TimeSpan timeout) + { + HeldCount++; + return new Handle(this); + } + + private sealed class Handle(AlwaysFreeLock owner) : ISessionLockHandle + { + public void Dispose() => owner.HeldCount--; + } +} + +internal sealed class NeverFreeLock : ISessionLock +{ + public ISessionLockHandle? TryAcquire(TimeSpan timeout) => null; +} + +internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => now; +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs new file mode 100644 index 00000000..a727235e --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs @@ -0,0 +1,89 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SetupStateStoreTests : IDisposable +{ + private const string ApplicationId = "PFN:OpenClaw.Gateway_test"; + private readonly string _root = TestDirectory.Create(); + + private string StatePath => Path.Combine(_root, "setup.json"); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void MissingMarkerIsDistinctFromAnUnreadableOne() + { + SetupStateStore store = new(StatePath); + + Assert.Equal(SetupStateFault.Missing, store.Read(ApplicationId).Fault); + + File.WriteAllText(StatePath, "{ not json"); + + Assert.Equal(SetupStateFault.Unreadable, store.Read(ApplicationId).Fault); + } + + [Fact] + public void MarkerRoundTripsWithItsApplicationIdentity() + { + SetupStateStore store = new(StatePath); + DateTimeOffset completed = new(2026, 9, 11, 18, 0, 0, TimeSpan.Zero); + + store.Write(new SetupRecord + { + ApplicationId = ApplicationId, + CompletedUtc = completed + }); + + SetupRecord? record = store.Read(ApplicationId).Record; + + Assert.NotNull(record); + Assert.Equal(ApplicationId, record.ApplicationId); + Assert.Equal(completed, record.CompletedUtc); + Assert.Equal(SetupStateStore.CurrentSchemaVersion, record.SchemaVersion); + } + + [Fact] + public void AMarkerForAnotherPackageCannotBeAdopted() + { + SetupStateStore store = new(StatePath); + store.Write(new SetupRecord { ApplicationId = "PFN:Other.Package_test" }); + + SetupStateResult result = store.Read(ApplicationId); + + Assert.Equal(SetupStateFault.ForeignIdentity, result.Fault); + Assert.Null(result.Record); + } + + [Fact] + public void ANewerMarkerIsRefusedRatherThanMisread() + { + File.WriteAllText( + StatePath, + """{"schemaVersion":99,"applicationId":"PFN:OpenClaw.Gateway_test"}"""); + + Assert.Equal( + SetupStateFault.UnsupportedSchema, + new SetupStateStore(StatePath).Read(ApplicationId).Fault); + } + + [Fact] + public void ClearingTheMarkerIsIdempotent() + { + SetupStateStore store = new(StatePath); + + store.Clear(); + Assert.Equal(SetupStateFault.Missing, store.Read(ApplicationId).Fault); + + store.Write(new SetupRecord { ApplicationId = ApplicationId }); + store.Clear(); + + Assert.Equal(SetupStateFault.Missing, store.Read(ApplicationId).Fault); + } +} From 060d147e1364b23732a1b84d21a656c3e047ac0f Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:08:22 -0700 Subject: [PATCH 02/10] Add session coordination and routing policy Provision or reuse owned sessions through the MXC abstraction and route required executions safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/OpenClaw.Launcher.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index e2d557c4..b5ef09c8 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -50,6 +50,10 @@ PrivateAssets="all" /> + + + + Designer From 98b5bf2ca3f56e4a4c982aabef30922136a6137a Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:08:27 -0700 Subject: [PATCH 03/10] Add staged session helper execution Dispatch correlated argument vectors through the packaged guest helper with safe cleanup and runtime composition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Session/SessionExecutor.cs | 293 +++++++++++ .../Session/SessionHelperStager.cs | 128 +++++ .../Session/SessionRuntime.cs | 196 ++++++++ .../Session/SessionWorkspaceOperation.cs | 128 +++++ src/OpenClaw.Launcher/Session/TrustedPath.cs | 475 ++++++++++++++++++ .../Session/SessionExecutorTests.cs | 457 +++++++++++++++++ .../Session/SessionGuestCommandLineTests.cs | 183 +++++++ .../Session/SessionHelperStagerTests.cs | 84 ++++ .../Session/SessionRuntimeTests.cs | 88 ++++ 9 files changed, 2032 insertions(+) create mode 100644 src/OpenClaw.Launcher/Session/SessionExecutor.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionHelperStager.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionRuntime.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs create mode 100644 src/OpenClaw.Launcher/Session/TrustedPath.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs new file mode 100644 index 00000000..89b27e96 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -0,0 +1,293 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Session; + +/// +/// What to run inside the session. +/// +internal sealed record SessionExecutionRequest( + string HelperPath, + string NodePath, + string ApplicationDirectory, + IReadOnlyList Arguments, + string WorkingDirectory); + +/// +/// An arbitrary foreground command to run inside the session. +/// +/// +/// This is the general form of : the +/// diagnostic commands need to run a shell or the guest helper's own +/// collection mode, neither of which goes through Node. The argument vector +/// still travels as JSON, so nothing here reaches the backend command line. +/// +internal sealed record SessionCommandRequest( + string HelperPath, + string Executable, + IReadOnlyList Arguments, + string WorkingDirectory) +{ + /// + /// Values merged over the shared runtime environment for this command only. + /// + /// + /// Kept per request rather than added to the shared environment because + /// these describe how one command was invoked, and OpenClaw itself must not + /// see a variable that only the shell's shim needs. + /// + public IReadOnlyDictionary? AdditionalEnvironment { get; init; } +} + +/// +/// Runs OpenClaw inside the owned session. +/// +/// +/// The backend's execution API takes a command line, not an argument vector, +/// and the pinned runtime flattens it through cmd.exe. Only the guest +/// helper's own path and its request file appear there; the user's arguments +/// travel as JSON in the shared workspace and are never exposed to that +/// flattening. +/// +internal sealed class SessionExecutor +{ + private readonly IMxcSessionClient _backend; + private readonly Action _log; + private readonly Func> _buildEnvironment; + private readonly Func _createRequestId; + private readonly Func _isCurrentRecord; + + public SessionExecutor(IMxcSessionClient backend, Action log, + Func>? buildEnvironment = null, + Func? createRequestId = null, + Func? isCurrentRecord = null) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(log); + _backend = backend; + _log = log; + _buildEnvironment = buildEnvironment ?? (() => OpenClawRuntimeEnvironment.Build()); + _createRequestId = createRequestId ?? (() => Guid.NewGuid().ToString("N")); + _isCurrentRecord = isCurrentRecord ?? (_ => true); + } + + public async Task ExecuteAsync( + SessionRecord record, + SessionExecutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(request); + + return await ExecuteCommandAsync( + record, + new SessionCommandRequest( + request.HelperPath, + request.NodePath, + BuildNodeArguments(request), + request.WorkingDirectory), + "Running OpenClaw in the isolated session.", + "OpenClaw", + cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs one arbitrary foreground command inside the session and returns its + /// exit code. + /// + /// + /// The caller supplies the message reported before dispatch and the subject + /// name used in failures, so a shell that never started does not report + /// itself as OpenClaw. + /// + public async Task ExecuteCommandAsync( + SessionRecord record, + SessionCommandRequest request, + string startingMessage, + string subject, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(request); + + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("launch", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + + var launchRequest = new SessionLaunchRequest + { + RequestId = requestId, + Executable = request.Executable, + Arguments = request.Arguments, + WorkingDirectory = request.WorkingDirectory, + Environment = MergeEnvironment( + _buildEnvironment(), request.AdditionalEnvironment), + }; + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionLaunchProtocol.SerializeRequest(launchRequest), + cancellationToken).ConfigureAwait(false); + + string commandLine = BuildGuestCommandLine(request.HelperPath, requestPath); + _log(startingMessage); + + int executorExitCode = await _backend.ExecuteAttachedAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest(commandLine), + null, + cancellationToken).ConfigureAwait(false); + + operation.EnsureCurrent(); + return ReadOutcome(operation, resultPath, executorExitCode, requestId, subject); + } + finally + { + // Only this invocation's files are removed. Concurrent invocations + // own differently named requests in the same shared workspace. + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + + internal static IReadOnlyDictionary MergeEnvironment( + IReadOnlyDictionary baseEnvironment, + IReadOnlyDictionary? additional) + { + ArgumentNullException.ThrowIfNull(baseEnvironment); + + if (additional is null || additional.Count == 0) + { + return baseEnvironment; + } + + var merged = new Dictionary( + baseEnvironment, StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair entry in additional) + { + merged[entry.Key] = entry.Value; + } + + return merged; + } + + private static List BuildNodeArguments( + SessionExecutionRequest request) + { + string entryPoint = Path.Combine(request.ApplicationDirectory, "openclaw.mjs"); + var arguments = new List(request.Arguments.Count + 1) { entryPoint }; + arguments.AddRange(request.Arguments); + return arguments; + } + + /// + /// Builds the only command line the backend ever sees. + /// + /// + /// + /// The pinned runtime dispatches this string through cmd.exe /c. + /// When that string contains more than two quote characters, the command + /// processor strips the first and the last one and keeps the rest, so a + /// naturally quoted "exe" --request "path" arrives with its + /// executable path unquoted and fails at the first space. An outer pair is + /// therefore added deliberately: it is the pair that gets sacrificed, and + /// the inner quoting survives intact. + /// + /// + /// Both values are paths this package controls, but they are still verified + /// rather than trusted, because cmd.exe also expands %VAR% + /// and lets an embedded quote truncate the rest of the line. + /// + /// + internal static string BuildGuestCommandLine( + string helperPath, + string requestPath, + string option = "--request") + { + Verify(helperPath, nameof(helperPath)); + Verify(requestPath, nameof(requestPath)); + return $"\"\"{helperPath}\" {option} \"{requestPath}\"\""; + + static void Verify(string value, string name) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new SessionException($"The {name} is empty."); + } + + if (value.Contains('"', StringComparison.Ordinal) || + value.Contains('%', StringComparison.Ordinal)) + { + throw new SessionException( + $"The {name} contains a quote or percent sign, which the " + + "isolated-session command line cannot carry safely: " + + value); + } + } + } + + /// + /// Establishes what actually happened from the helper's control result. + /// + /// + /// The executor's exit code alone is ambiguous. The helper's failure code + /// is a value OpenClaw itself can return, and an attached execution + /// captures nothing, so a dispatch failure is indistinguishable from an + /// application exit without this file. + /// + private static int ReadOutcome( + SessionWorkspaceOperation operation, + string resultPath, + int executorExitCode, + string requestId, + string subject) + { + string resultText; + try + { + resultText = operation.ReadTextAsync(resultPath, CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or IOException) + { + throw new SessionException( + "The isolated session did not report a launch result " + + $"(executor exit code {executorExitCode}). {subject} may not " + + "have started."); + } + + SessionLaunchResult result; + try + { + result = SessionLaunchProtocol.ReadResult(resultText); + } + catch (SessionLaunchException exception) + { + throw new SessionException( + $"The isolated session reported an unreadable launch result: " + + $"{exception.Message}", + exception); + } + + if (!result.Launched) + { + throw new SessionException( + $"{subject} could not be started inside the isolated session: " + + (result.Error ?? "no reason was reported.")); + } + + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a launch result for a " + + "different request."); + } + + return result.ExitCode ?? executorExitCode; + } + +} diff --git a/src/OpenClaw.Launcher/Session/SessionHelperStager.cs b/src/OpenClaw.Launcher/Session/SessionHelperStager.cs new file mode 100644 index 00000000..5d4e558f --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionHelperStager.cs @@ -0,0 +1,128 @@ +using System.Security.Cryptography; + +namespace OpenClaw.Launcher.Session; + +/// +/// Stages the packaged guest helper into the session's shared workspace. +/// +/// +/// IsolationSession agent identities cannot execute binaries directly from +/// another package's WindowsApps directory. The shared workspace is visible +/// to both identities, so setup copies the immutable packaged helper there +/// before any guest command is dispatched. +/// +internal static class SessionHelperStager +{ + private const string StagingDirectoryName = ".openclaw-session-host"; + + public static string Stage(string packagedHelperPath, string workspacePath) + { + string source = RequirePackagedHelper(packagedHelperPath); + var stagingRecord = new SessionRecord + { + SandboxId = "iso:helper-staging", + ApplicationId = "helper-staging", + WorkspacePath = workspacePath, + Generation = typeof(SessionHelperStager).Assembly + .GetName() + .Version? + .ToString() ?? "unknown" + }; + using var operation = new SessionWorkspaceOperation(stagingRecord, _ => true); + string destination = ResolveStagedPath(operation.WorkspacePath); + var sourceInfo = new FileInfo(source); + + if (File.Exists(destination) && + FilesMatch(source, destination, sourceInfo.Length)) + { + return destination; + } + + string? destinationDirectory = Path.GetDirectoryName(destination); + if (string.IsNullOrWhiteSpace(destinationDirectory)) + { + throw new SessionException( + $"The staged helper path has no parent directory: {destination}"); + } + + operation.EnsureDirectory(destinationDirectory); + string temporaryPath = destination + $".{Guid.NewGuid():N}.tmp"; + try + { + File.Copy(source, temporaryPath, overwrite: false); + File.Move(temporaryPath, destination, overwrite: true); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + throw new SessionException( + "The packaged session helper could not be staged into the " + + $"shared workspace: {exception.Message}", + exception); + } + finally + { + TryDelete(temporaryPath); + } + + return destination; + } + + public static string RequireStaged( + string packagedHelperPath, + string workspacePath) + { + _ = RequirePackagedHelper(packagedHelperPath); + string path = ResolveStagedPath(workspacePath); + return File.Exists(path) + ? path + : throw new SessionException( + "The isolated-session helper has not been staged for this " + + "package version. Run `clawctl setup` again."); + } + + internal static string ResolveStagedPath(string workspacePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + + string version = typeof(SessionHelperStager).Assembly + .GetName() + .Version? + .ToString() ?? "unknown"; + return Path.GetFullPath( + Path.Combine( + workspacePath, + StagingDirectoryName, + version, + SessionRuntime.HelperFileName)); + } + + internal static string RequirePackagedHelper(string packagedHelperPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagedHelperPath); + + string path = Path.GetFullPath(packagedHelperPath); + return File.Exists(path) + ? path + : throw new SessionException( + $"The packaged session helper is missing: {path}"); + } + + private static bool FilesMatch(string source, string destination, long sourceLength) => + new FileInfo(destination).Length == sourceLength && + CryptographicOperations.FixedTimeEquals( + SHA256.HashData(File.ReadAllBytes(source)), + SHA256.HashData(File.ReadAllBytes(destination))); + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs new file mode 100644 index 00000000..ab6e8655 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -0,0 +1,196 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Assembles the session stack from the running installation. +/// +/// +/// Composition lives here rather than in Program so both the agent and +/// control entry points build the same coordinator, over the same state root +/// and the same lifecycle lock, for the same package identity. +/// +internal sealed class SessionRuntime +{ + /// + /// Directory under the application base holding the guest helper, staged + /// per architecture exactly like the MXC runtime. + /// + public const string HelperDirectoryName = "session-host"; + + public const string HelperFileName = "openclaw-session-host.exe"; + + private SessionRuntime( + SessionCoordinator coordinator, + SessionExecutor executor, + IMxcSessionClient backend, + string helperPath, + string applicationId, + SetupStateStore setupState) + { + Coordinator = coordinator; + Executor = executor; + Backend = backend; + HelperPath = helperPath; + ApplicationId = applicationId; + SetupState = setupState; + LifecycleLock = new NamedSessionLock(applicationId + "_Installation"); + } + + public SessionCoordinator Coordinator { get; } + + public SessionExecutor Executor { get; } + + /// + /// The one backend instance this process uses, so the gateway and ordinary + /// invocations cannot end up talking to differently configured clients. + /// + public IMxcSessionClient Backend { get; } + + public string HelperPath { get; } + + public string ApplicationId { get; } + + public SetupStateStore SetupState { get; } + + public ISessionLock LifecycleLock { get; } + + public ISessionLockHandle AcquireLifecycleLock() => + LifecycleLock.TryAcquire(SessionCoordinator.DefaultLockTimeout) + ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); + + public async Task StartForExecutionAsync(CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLifecycleLock(); + RequireSetup(); + return await Coordinator.StartRecordedAsync(cancellationToken).ConfigureAwait(false); + } + + public string StageHelper(SessionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return SessionHelperStager.Stage( + HelperPath, + RequireWorkspace(record)); + } + + public string RequireStagedHelper(SessionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return SessionHelperStager.RequireStaged( + HelperPath, + RequireWorkspace(record)); + } + + public static SessionRuntime Create(Action log) => + Create( + HostPaths.Create(), + MxcRuntimeLocator.Locate, + AppContext.BaseDirectory, + log); + + internal static SessionRuntime Create( + HostPaths paths, + Func locateRuntime, + string baseDirectory, + Action log, + IMxcSessionClient? backend = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(log); + + if (paths.PackageFamilyName is null) + { + throw new SessionException( + "OpenClaw is not running from its installed package, so it " + + "has no identity to provision an isolated session with."); + } + + string applicationId = + PackageIdentity.ToApplicationId(paths.PackageFamilyName); + IMxcSessionClient client = backend ?? new LazyMxcSessionClient( + () => new MxcCliSessionClient(locateRuntime())); + + var coordinator = new SessionCoordinator( + client, + new SessionStateStore(paths.SessionStatePath), + new NamedSessionLock(applicationId), + applicationId, + log); + + return new SessionRuntime( + coordinator, + new SessionExecutor(client, log, isCurrentRecord: IsCurrentSessionRecord), + client, + ResolveHelperPath(baseDirectory), + applicationId, + new SetupStateStore(paths.SetupStatePath)); + + bool IsCurrentSessionRecord(SessionRecord record) + { + SessionStatus status = coordinator.GetRecordedStatus(); + return status.Record is not null && + string.Equals(status.Record.SandboxId, record.SandboxId, StringComparison.Ordinal) && + string.Equals(status.Record.Generation, record.Generation, StringComparison.Ordinal); + } + } + + /// + /// Requires both the explicit setup marker and the owned session record. + /// + /// + /// This is read-only. Starting the session remains a separate operation so + /// a race with teardown cannot turn a stale marker into a new provision. + /// + public SessionRecord RequireSetup() + { + SetupStateResult setup = SetupState.Read(ApplicationId); + if (setup.Record is null) + { + throw new SessionException( + setup.Fault == SetupStateFault.Missing + ? "OpenClaw has not been set up. Run `clawctl setup` first." + : setup.Detail!); + } + + if (setup.Record.Phase != SetupPhase.Ready) + { + throw new SessionException( + setup.Record.Phase == SetupPhase.TearingDown + ? "OpenClaw teardown is incomplete. Run `clawctl teardown` again." + : "OpenClaw setup is incomplete. Run `clawctl setup` again."); + } + + SessionStatus session = Coordinator.GetRecordedStatus(); + if (session.Record is null) + { + throw new SessionException( + "OpenClaw setup is incomplete because its isolated session is " + + "not recorded. Run `clawctl setup` again."); + } + + if (setup.Record.SandboxId is { } sandboxId && + !string.Equals(sandboxId, session.Record.SandboxId, StringComparison.Ordinal)) + { + throw new SessionException( + "The setup marker names a different session. Run `clawctl setup` again."); + } + + return session.Record; + } + + internal static string ResolveHelperPath(string baseDirectory) => + Path.GetFullPath( + Path.Combine( + baseDirectory, + HelperDirectoryName, + MxcRuntimeLocator.CurrentArchitectureName(), + HelperFileName)); + + private static string RequireWorkspace(SessionRecord record) => + string.IsNullOrWhiteSpace(record.WorkspacePath) + ? throw new SessionException( + "The recorded session has no shared workspace, so the session " + + "helper cannot be staged.") + : record.WorkspacePath; +} diff --git a/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs b/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs new file mode 100644 index 00000000..27cdc451 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs @@ -0,0 +1,128 @@ +using System.Text; + +namespace OpenClaw.Launcher.Session; + +/// +/// Owns one host/guest exchange in the shared session workspace. +/// +/// +/// The workspace is guest-writable, so callers must not authorize an operation +/// from paths alone. This contract pins the recorded sandbox generation, +/// validates the opened workspace object, rechecks that the same generation is +/// still current before each effect, and opens request/result files through +/// handle-bound TrustedPath helpers. +/// +internal sealed class SessionWorkspaceOperation : IDisposable +{ + private readonly SessionRecord _record; + private readonly Func _isCurrent; + private readonly TrustedPath.ValidatedDirectory _workspace; + + public SessionWorkspaceOperation( + SessionRecord record, + Func isCurrent) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(isCurrent); + + if (string.IsNullOrWhiteSpace(record.WorkspacePath)) + { + throw new SessionException( + "The recorded session has no shared workspace, so an operation " + + "cannot be delivered to it."); + } + + if (string.IsNullOrWhiteSpace(record.Generation)) + { + throw new SessionException( + "The recorded session has no operation generation. Run `clawctl setup` again."); + } + + _record = record; + _isCurrent = isCurrent; + _workspace = TrustedPath.TryOpenValidatedDirectory(record.WorkspacePath, expectedIdentity: null) + ?? throw new SessionException( + $"The recorded shared workspace could not be opened safely: {record.WorkspacePath}"); + EnsureCurrent(); + } + + public string WorkspacePath => _workspace.FinalPath; + + public string FilePath(string prefix, string requestId, string suffix = ".json") + { + VerifyName(prefix, nameof(prefix)); + VerifyName(requestId, nameof(requestId)); + if (suffix.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + suffix.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new SessionException($"The operation suffix is not a file suffix: {suffix}"); + } + + return Path.Combine( + WorkspacePath, + $"{prefix}-{_record.Generation}-{requestId}{suffix}"); + } + + public async Task WriteTextNewAsync( + string path, + string text, + CancellationToken cancellationToken) + { + EnsureCurrent(); + using FileStream stream = TrustedPath.CreateNew(_workspace, path); + byte[] bytes = Encoding.UTF8.GetBytes(text); + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + public async Task ReadTextAsync( + string path, + CancellationToken cancellationToken) + { + EnsureCurrent(); + using FileStream stream = TrustedPath.OpenRead(WorkspacePath, path); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + } + + public void EnsureDirectory(string path) + { + EnsureCurrent(); + TrustedPath.EnsureDirectory(_workspace, path); + } + + public void Delete(string path) + { + try + { + _ = TrustedPath.TryDeleteOwnedEntry( + _workspace, + path, + deleteReparsePointLeaf: true); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + } + } + + public void EnsureCurrent() + { + if (!_isCurrent(_record)) + { + throw new SessionException( + "The recorded isolated-session generation changed before the operation completed. Retry the command."); + } + } + + public void Dispose() => _workspace.Dispose(); + + private static void VerifyName(string value, string name) + { + if (string.IsNullOrWhiteSpace(value) || + value.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + value.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new SessionException($"The operation {name} is not a single safe path segment."); + } + } +} diff --git a/src/OpenClaw.Launcher/Session/TrustedPath.cs b/src/OpenClaw.Launcher/Session/TrustedPath.cs new file mode 100644 index 00000000..1a6f7151 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/TrustedPath.cs @@ -0,0 +1,475 @@ +using Microsoft.Win32.SafeHandles; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher.Session; + +/// Rejects path redirection below a caller-established root. +internal static partial class TrustedPath +{ + internal readonly record struct FileIdentity( + uint VolumeSerialNumber, + uint FileIndexHigh, + uint FileIndexLow); + + internal sealed class ValidatedDirectory : IDisposable + { + internal ValidatedDirectory( + SafeFileHandle handle, + FileIdentity identity, + string finalPath) + { + Handle = handle; + Identity = identity; + FinalPath = finalPath; + } + + internal SafeFileHandle Handle { get; } + + internal FileIdentity Identity { get; } + + internal string FinalPath { get; } + + public void Dispose() => Handle.Dispose(); + } + + public static void EnsureNoReparsePoints(string trustedRoot, string candidatePath) => + EnsureNoReparsePoints(trustedRoot, candidatePath, File.GetAttributes); + + internal static void EnsureNoReparsePoints( + string trustedRoot, + string candidatePath, + Func getAttributes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(trustedRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(candidatePath); + ArgumentNullException.ThrowIfNull(getAttributes); + + string root = Path.GetFullPath(trustedRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string candidate = Path.GetFullPath(candidatePath); + string prefix = root + Path.DirectorySeparatorChar; + if (!candidate.Equals(root, StringComparison.OrdinalIgnoreCase) && + !candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException( + $"The path resolves outside its trusted root: {candidate}"); + } + + Check(root); + string relative = Path.GetRelativePath(root, candidate); + if (relative == ".") + { + return; + } + + string current = root; + foreach (string segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + Check(current); + } + + void Check(string path) + { + FileAttributes attributes; + try + { + attributes = getAttributes(path); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + $"The path contains a reparse point and is unsafe for host access: {path}"); + } + } + } + /// + /// Opens a file only when the object opened by the host is still below the + /// trusted root and is not a reparse point. + /// + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The FileStream constructor takes ownership of the SafeFileHandle.")] + public static FileStream OpenRead(string trustedRoot, string candidatePath) + { + EnsureNoReparsePoints(trustedRoot, candidatePath); + + SafeFileHandle handle = CreateFile( + candidatePath, + GenericRead, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + throw new IOException( + $"The trusted file could not be opened: {candidatePath}", + new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error())); + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + $"The opened path is a reparse point: {candidatePath}"); + } + + string root = Path.GetFullPath(trustedRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string opened = GetFinalPath(handle) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string prefix = root + Path.DirectorySeparatorChar; + if (!opened.Equals(root, StringComparison.OrdinalIgnoreCase) && + !opened.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException( + $"The opened file resolves outside its trusted root: {opened}"); + } + + return new FileStream(handle, FileAccess.Read, bufferSize: 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The FileStream constructor takes ownership of the SafeFileHandle.")] + internal static FileStream CreateNew(ValidatedDirectory root, string candidatePath) + { + ArgumentNullException.ThrowIfNull(root); + ValidateParent(root, candidatePath); + + SafeFileHandle handle = CreateFile( + candidatePath, + GenericWrite | FileReadAttributes, + 0, + IntPtr.Zero, + CreateNewDisposition, + FileFlagOpenReparsePoint | FileFlagOverlapped, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + throw new IOException( + $"The trusted file could not be created: {candidatePath}", + new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error())); + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0 || + !IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) + { + throw new SessionException( + $"The created file resolves outside its trusted root: {candidatePath}"); + } + + return new FileStream(handle, FileAccess.Write, bufferSize: 4096, isAsync: true); + } + catch + { + handle.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Each directory handle is owned by a using declaration before the next statement can observe it.")] + internal static void EnsureDirectory(ValidatedDirectory root, string directoryPath) + { + ArgumentNullException.ThrowIfNull(root); + + string target = Path.GetFullPath(directoryPath); + string relative = Path.GetRelativePath(root.FinalPath, target); + if (relative == "." || relative.StartsWith("..", StringComparison.Ordinal) || + Path.IsPathRooted(relative)) + { + throw new SessionException( + $"The directory resolves outside its trusted root: {target}"); + } + + string current = root.FinalPath; + foreach (string segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + Directory.CreateDirectory(current); + using ValidatedDirectory directory = TryOpenValidatedDirectory( + current, + expectedIdentity: null) ?? throw new SessionException( + $"The directory resolves outside its trusted root: {current}"); + if (!IsBelowOrEqualRoot(root.FinalPath, directory.FinalPath)) + { + throw new SessionException( + $"The directory resolves outside its trusted root: {current}"); + } + } + } + + internal static FileIdentity? TryGetDirectoryIdentity(string path) + { + using ValidatedDirectory? directory = TryOpenValidatedDirectory(path, expectedIdentity: null); + return directory?.Identity; + } + + internal static ValidatedDirectory? TryOpenValidatedDirectory( + string path, + FileIdentity? expectedIdentity) + { + SafeFileHandle handle = CreateFile( + path, + Delete | FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + return null; + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + handle.Dispose(); + return null; + } + + FileIdentity identity = GetIdentity(handle); + if (expectedIdentity is not null && identity != expectedIdentity) + { + handle.Dispose(); + return null; + } + + return new ValidatedDirectory(handle, identity, NormalizePath(GetFinalPath(handle))); + } + catch + { + handle.Dispose(); + throw; + } + } + + internal static bool TryDeleteOwnedEntry( + ValidatedDirectory root, + string candidatePath, + bool deleteReparsePointLeaf = false) + { + ValidateParent(root, candidatePath); + + SafeFileHandle handle = CreateFile( + candidatePath, + Delete | FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + return false; + } + + using (handle) + { + FileAttributes attributes = File.GetAttributes(handle); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + return deleteReparsePointLeaf && MarkForDeletion(handle); + } + + if (!IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) + { + return false; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + foreach (string child in Directory.EnumerateFileSystemEntries(candidatePath)) + { + TryDeleteOwnedEntry(root, child, deleteReparsePointLeaf); + } + } + + return MarkForDeletion(handle); + } + } + + private static void ValidateParent(ValidatedDirectory root, string candidatePath) + { + string? parent = Path.GetDirectoryName(Path.GetFullPath(candidatePath)); + if (string.IsNullOrWhiteSpace(parent)) + { + throw new SessionException( + $"The trusted file path has no parent directory: {candidatePath}"); + } + + using ValidatedDirectory directory = TryOpenValidatedDirectory(parent, expectedIdentity: null) + ?? throw new SessionException( + $"The trusted file parent resolves outside its trusted root: {parent}"); + if (!IsBelowOrEqualRoot(root.FinalPath, directory.FinalPath)) + { + throw new SessionException( + $"The trusted file parent resolves outside its trusted root: {parent}"); + } + } + + private static bool MarkForDeletion(SafeFileHandle handle) + { + FileDispositionInformation disposition = new() { DeleteFile = true }; + return SetFileInformationByHandle( + handle, + FileDispositionInfo, + ref disposition, + (uint)Marshal.SizeOf()); + } + + private static string GetFinalPath(SafeFileHandle handle) + { + char[] path = new char[260]; + uint length = GetFinalPathNameByHandle(handle, path, (uint)path.Length, 0); + if (length == 0) + { + throw new IOException("The opened file's final path could not be determined."); + } + + if (length >= path.Length) + { + path = new char[checked((int)length + 1)]; + length = GetFinalPathNameByHandle(handle, path, (uint)path.Length, 0); + if (length == 0 || length >= path.Length) + { + throw new IOException("The opened file's final path is too long."); + } + } + + const string extendedPathPrefix = @"\\?\"; + string value = new(path, 0, checked((int)length)); + return value.StartsWith(extendedPathPrefix, StringComparison.Ordinal) + ? value[extendedPathPrefix.Length..] + : value; + } + + private static FileIdentity GetIdentity(SafeFileHandle handle) + { + if (!GetFileInformationByHandle(handle, out ByHandleFileInformation information)) + { + throw new IOException("The opened path's identity could not be determined."); + } + + return new FileIdentity( + information.VolumeSerialNumber, + information.FileIndexHigh, + information.FileIndexLow); + } + + private static string NormalizePath(string path) => + path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + private static bool IsBelowRoot(string root, string candidate) + { + string prefix = root + Path.DirectorySeparatorChar; + return candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsBelowOrEqualRoot(string root, string candidate) => + candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || + IsBelowRoot(root, candidate); + + private const uint Delete = 0x00010000; + private const uint GenericRead = 0x80000000; + private const uint GenericWrite = 0x40000000; + private const uint FileReadAttributes = 0x00000080; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint CreateNewDisposition = 1; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagOpenReparsePoint = 0x00200000; + private const uint FileFlagOverlapped = 0x40000000; + private const int FileDispositionInfo = 4; + + [StructLayout(LayoutKind.Sequential)] + private struct FileDispositionInformation + { + [MarshalAs(UnmanagedType.Bool)] + public bool DeleteFile; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public uint CreationTimeLow; + public uint CreationTimeHigh; + public uint LastAccessTimeLow; + public uint LastAccessTimeHigh; + public uint LastWriteTimeLow; + public uint LastWriteTimeHigh; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + [Out] char[] path, + uint length, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation information); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle( + SafeFileHandle file, + int fileInformationClass, + ref FileDispositionInformation fileInformation, + uint bufferSize); +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs new file mode 100644 index 00000000..a273b08b --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -0,0 +1,457 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionExecutorTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + private readonly FakeMxcSessionClient _backend = new(); + private readonly List _log = []; + + private string Workspace => _root; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private SessionRecord Record(string? workspace = null) => new() + { + SchemaVersion = 1, + SandboxId = "iso:sandbox1", + ApplicationId = "PFN:OpenClaw.Gateway_abc123", + WorkspacePath = workspace ?? Workspace, + Generation = "test-generation", + }; + + private SessionExecutionRequest Request(params string[] arguments) => + new( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Program Files\nodejs\node.exe", + @"C:\Package\app", + arguments, + @"C:\work"); + + private SessionExecutor Create() => new(_backend, _log.Add); + + /// + /// Stands in for the guest helper: reads the delivered request and writes + /// the control result the real helper would. + /// + private void RespondAsHelper( + Func respond) + { + _backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + SessionLaunchResult result = respond(request); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(result)); + return Task.FromResult(result.ExitCode ?? 0); + }; + } + + private void RespondLaunched(int exitCode) => + RespondAsHelper(request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = exitCode, + }); + + [Fact] + public async Task ApplicationExitCodeIsReturned() + { + RespondLaunched(42); + + int exitCode = await Create().ExecuteAsync( + Record(), + Request("--version"), + CancellationToken.None); + + Assert.Equal(42, exitCode); + } + + [Fact] + public async Task ArgumentsTravelAsDataNotOnTheCommandLine() + { + // The pinned runtime flattens the command line through cmd.exe, where + // %USERPROFILE% expands and a quote truncates a later argument. + string[] hostile = ["%USERPROFILE%", "q\"x", "a&b", "trailing\\", "", "ünïcode"]; + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request(hostile), CancellationToken.None); + + Assert.Equal(hostile, delivered!.Arguments!.Skip(1)); + string commandLine = Assert.Single(_backend.AttachedCommandLines); + foreach (string argument in hostile.Where(value => value.Length > 0)) + { + Assert.DoesNotContain(argument, commandLine, StringComparison.Ordinal); + } + } + + [Fact] + public async Task EntryPointIsTheFirstNodeArgument() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request("doctor"), CancellationToken.None); + + Assert.Equal( + [@"C:\Package\app\openclaw.mjs", "doctor"], + delivered!.Arguments); + Assert.Equal(@"C:\Program Files\nodejs\node.exe", delivered.Executable); + } + + [Fact] + public async Task WorkingDirectoryIsCarriedExplicitly() + { + // Execution does not inherit the caller's directory; it defaults to + // C:\Windows\System32 unless the request sets it. + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal(@"C:\work", delivered!.WorkingDirectory); + } + + [Fact] + public async Task ExternalSupervisionVariablesAreDelivered() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal("external", delivered!.Environment!["OPENCLAW_SUPERVISOR_MODE"]); + Assert.Equal("external", delivered.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); + Assert.Equal("1", delivered.Environment["OPENCLAW_NO_AUTO_UPDATE"]); + } + + [Fact] + public async Task CommandLineCarriesOnlyTheHelperAndItsRequest() + { + RespondLaunched(0); + + await Create().ExecuteAsync(Record(), Request("chat"), CancellationToken.None); + + string commandLine = Assert.Single(_backend.AttachedCommandLines); + + // The properties that matter are what the command line carries, not how + // it is shaped: the helper is named, and neither the user's arguments + // nor the Node path ever reach a string the command processor expands. + Assert.Contains( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + commandLine, + StringComparison.Ordinal); + Assert.Contains("--request", commandLine, StringComparison.Ordinal); + Assert.DoesNotContain("chat", commandLine, StringComparison.Ordinal); + Assert.DoesNotContain("node.exe", commandLine, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecutionIsAttachedRatherThanBuffered() + { + RespondLaunched(0); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal(["execute-attached:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task RequestAndResultFilesAreRemovedAfterwards() + { + RespondLaunched(0); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task FilesAreRemovedEvenWhenExecutionFails() + { + _backend.AttachedBehavior = _ => + Task.FromException(new MxcException(MxcErrorCode.BackendError, "no")); + + await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task ConcurrentInvocationsUseDistinctRequestFiles() + { + var seen = new List(); + _backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + seen.Add(requestPath); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + })); + return Task.FromResult(0); + }; + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal(2, seen.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public async Task HelperFailureIsDistinguishedFromAnApplicationExit() + { + // Exit code 64 is both the helper's failure code and a value OpenClaw + // could return, so only the control result can tell them apart. + RespondAsHelper(request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = false, + Error = "The executable was not found.", + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("The executable was not found.", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ApplicationExitOf64IsNotMistakenForHelperFailure() + { + RespondLaunched(SessionLaunchProtocol.HelperFailureExitCode); + + int exitCode = await Create().ExecuteAsync( + Record(), + Request(), + CancellationToken.None); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + } + + [Fact] + public async Task MissingControlResultIsReportedRatherThanTrusted() + { + _backend.AttachedBehavior = _ => Task.FromResult(1); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("did not report a launch result", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task UnreadableControlResultIsReported() + { + _backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json").Single(); + File.WriteAllText(SessionLaunchProtocol.ResultPathFor(requestPath), "{ not json"); + return Task.FromResult(0); + }; + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("unreadable launch result", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ResultForAnotherRequestIsRejected() + { + RespondAsHelper(_ => new SessionLaunchResult + { + RequestId = "0123456789abcdef0123456789abcdef", + Launched = true, + ExitCode = 0, + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("different request", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SessionWithoutAWorkspaceIsReported() + { + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync( + Record(workspace: string.Empty) with { WorkspacePath = null }, + Request(), + CancellationToken.None)); + + Assert.Contains("shared workspace", exception.Message, StringComparison.Ordinal); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task StaleSessionGenerationIsRejectedBeforeWritingARequest() + { + SessionExecutor executor = new( + _backend, + _log.Add, + isCurrentRecord: _ => false); + + SessionException exception = await Assert.ThrowsAsync( + () => executor.ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("generation changed", exception.Message, StringComparison.Ordinal); + Assert.Empty(_backend.Calls); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Theory] + [InlineData("C:\\has%percent\\helper.exe")] + [InlineData("C:\\has\"quote\\helper.exe")] + public void UnsafeHelperPathIsRefusedRatherThanCorrupted(string helperPath) + { + SessionException exception = Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine(helperPath, @"C:\ws\r.json")); + + Assert.Contains("quote or percent sign", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnsafeRequestPathIsRefused() + { + Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine( + @"C:\p\helper.exe", + @"C:\ws\%TEMP%.json")); + } + + // Quoting is proven by dispatching the command line through the command + // processor the backend actually uses, in SessionGuestCommandLineTests. + // A string-equality assertion here previously passed against a command line + // that the command processor then broke apart at the first space. + + [Fact] + public async Task CancellationPropagates() + { + using var cancellation = new CancellationTokenSource(); + _backend.AttachedBehavior = token => + { + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + return Task.FromResult(0); + }; + + await Assert.ThrowsAnyAsync( + () => Create().ExecuteAsync(Record(), Request(), cancellation.Token)); + + Assert.Empty(Directory.GetFiles(Workspace)); + } +} + +public sealed class OpenClawRuntimeEnvironmentTests +{ + [Fact] + public void ExternalSupervisionIsDeclared() + { + IReadOnlyDictionary environment = + OpenClawRuntimeEnvironment.Build(); + + Assert.Equal("external", environment["OPENCLAW_SUPERVISOR_MODE"]); + Assert.Equal("external", environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); + Assert.Equal("1", environment["OPENCLAW_NO_AUTO_UPDATE"]); + } + + [Fact] + public void ForegroundLaunchUsesTheSameVariables() + { + // Both launch paths must agree; a path that supervises or updates + // itself would be a silent behavior difference. + string application = TestDirectory.Create(); + try + { + File.WriteAllText(Path.Combine(application, "openclaw.mjs"), "// test"); + System.Diagnostics.ProcessStartInfo startInfo = + GatewayLauncher.CreateStartInfo( + @"C:\node.exe", + application, + []); + + foreach ((string name, string value) in OpenClawRuntimeEnvironment.Build()) + { + Assert.Equal(value, startInfo.Environment[name]); + } + } + finally + { + Directory.Delete(application, recursive: true); + } + } + + [Fact] + public void ApplyToOverwritesAnInheritedValue() + { + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["OPENCLAW_SUPERVISOR_MODE"] = "self", + }; + + OpenClawRuntimeEnvironment.ApplyTo(environment); + + Assert.Equal("external", environment["OPENCLAW_SUPERVISOR_MODE"]); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs new file mode 100644 index 00000000..61cff383 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs @@ -0,0 +1,183 @@ +using System.Diagnostics; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Runs the command line this package hands the backend through the command +/// processor the backend actually dispatches with. +/// +/// +/// +/// The defect this covers reached a real installation: every path was quoted, +/// the string looked correct, and the launch still failed with +/// 'C:\Program' is not recognized. Asserting on the composed string +/// would have passed, because the string was never the problem; the command +/// processor's treatment of it was. These tests therefore execute it. +/// +/// +/// The packaged helper lives under C:\Program Files\WindowsApps\..., so +/// a space in the executable path is the normal case rather than an edge one. +/// +/// +public sealed class SessionGuestCommandLineTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + /// + /// Writes a script that reports the argument vector it actually received. + /// + private string CreateProbe(string directoryName) + { + string directory = Path.Combine(_root, directoryName); + Directory.CreateDirectory(directory); + string probe = Path.Combine(directory, "probe.cmd"); + File.WriteAllText( + probe, + "@echo off\r\n" + + "echo STARTED\r\n" + + "echo OPTION=%~1\r\n" + + "echo REQUEST=%~2\r\n", + System.Text.Encoding.ASCII); + return probe; + } + + /// + /// Dispatches exactly as the pinned runtime does: the command line is + /// appended to cmd.exe /c verbatim, with no further quoting. + /// + private static (int ExitCode, string Output, string Error) Dispatch(string commandLine) + { + ProcessStartInfo startInfo = new() + { + FileName = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe"), + + // Arguments, not ArgumentList: the point is to reproduce a raw + // command line rather than let .NET re-quote it. + Arguments = "/c " + commandLine, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using Process process = Process.Start(startInfo)!; + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(TimeSpan.FromSeconds(30)); + return (process.ExitCode, output, error); + } + + [Fact] + public void TheHelperRunsWhenItsDirectoryContainsASpace() + { + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, string error) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + // The measured failure was exit 1 with 'C:\Program' is not recognized. + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains("STARTED", output, StringComparison.Ordinal); + } + + [Fact] + public void TheRequestPathArrivesWholeWhenItContainsASpace() + { + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch request.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + Assert.Equal(0, exitCode); + + // A truncated path here would send the helper looking for a request + // file that does not exist, which surfaces much later as "the session + // did not report a launch result". + Assert.Contains($"REQUEST={request}", output, StringComparison.Ordinal); + } + + [Fact] + public void TheOptionIsNotAbsorbedIntoTheExecutablePath() + { + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch.json"); + File.WriteAllText(request, "{}"); + + (_, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + Assert.Contains("OPTION=--request", output, StringComparison.Ordinal); + } + + [Theory] + [InlineData("--request")] + [InlineData("--inspect")] + [InlineData("--stop")] + public void EveryHelperModeSurvivesDispatch(string option) + { + // The gateway drives the same helper through inspect and stop, so a + // fix applied only to the launch path would leave those broken. + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request, option)); + + Assert.Equal(0, exitCode); + Assert.Contains($"OPTION={option}", output, StringComparison.Ordinal); + } + + [Fact] + public void APathWithoutSpacesStillRunsAndKeepsItsArguments() + { + // The outer pair must not break the ordinary case it was added for the + // sake of the harder one. + string probe = CreateProbe("plain"); + string request = Path.Combine(_root, "plain", "launch.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + Assert.Equal(0, exitCode); + Assert.Contains($"REQUEST={request}", output, StringComparison.Ordinal); + } + + [Theory] + [InlineData("has\"quote")] + [InlineData("has%percent%")] + public void APathTheCommandProcessorCannotCarryIsRefused(string fragment) + { + // Refusing is the point: cmd.exe expands %VAR% and lets a quote + // truncate the rest of the line, and neither can be quoted around. + Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine( + Path.Combine(_root, fragment, "probe.exe"), + Path.Combine(_root, "launch.json"))); + + Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine( + Path.Combine(_root, "probe.exe"), + Path.Combine(_root, fragment, "launch.json"))); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs new file mode 100644 index 00000000..40ecbb4d --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs @@ -0,0 +1,84 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionHelperStagerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void StageCopiesThePackagedHelperToAFullVersionedWorkspacePath() + { + string source = Path.Combine(_root, "package", "openclaw-session-host.exe"); + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(Path.GetDirectoryName(source)!); + Directory.CreateDirectory(workspace); + File.WriteAllText(source, "helper bytes"); + + string staged = SessionHelperStager.Stage(source, workspace); + + Assert.True(Path.IsPathFullyQualified(staged)); + Assert.StartsWith( + Path.GetFullPath(workspace), + staged, + StringComparison.Ordinal); + Assert.Equal("helper bytes", File.ReadAllText(staged)); + Assert.Equal( + staged, + SessionHelperStager.RequireStaged(source, workspace)); + } + + [Fact] + public void StageReplacesSameLengthDifferentHelper() + { + string source = Path.Combine(_root, "package", "openclaw-session-host.exe"); + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(Path.GetDirectoryName(source)!); + Directory.CreateDirectory(workspace); + File.WriteAllText(source, "helper-v2"); + string stagedPath = SessionHelperStager.ResolveStagedPath(workspace); + Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!); + File.WriteAllText(stagedPath, "helper-v1"); + + string staged = SessionHelperStager.Stage(source, workspace); + + Assert.Equal(stagedPath, staged); + Assert.Equal("helper-v2", File.ReadAllText(staged)); + } + + [Fact] + public void RequireStagedDirectsTheUserBackToSetup() + { + string source = Path.Combine(_root, "openclaw-session-host.exe"); + File.WriteAllText(source, "helper bytes"); + + SessionException failure = Assert.Throws( + () => SessionHelperStager.RequireStaged( + source, + Path.Combine(_root, "workspace"))); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void MissingPackagedHelperIsReportedBeforeStaging() + { + SessionException failure = Assert.Throws( + () => SessionHelperStager.Stage( + Path.Combine(_root, "missing.exe"), + Path.Combine(_root, "workspace"))); + + Assert.Contains( + "packaged session helper is missing", + failure.Message, + StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs new file mode 100644 index 00000000..a496ea7d --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs @@ -0,0 +1,88 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionRuntimeTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void StatusDoesNotRequireTheMxcRuntimeToBeInstalled() + { + bool runtimeLocated = false; + + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => + { + runtimeLocated = true; + throw new InvalidOperationException("no runtime here"); + }, + _root, + _ => { }); + + SessionStatus status = host.Coordinator.GetRecordedStatus(); + + Assert.Equal(SessionAvailability.None, status.Availability); + + // A read-only status query must never be turned into a runtime + // availability failure; the recorded state is what the user asked for. + Assert.False(runtimeLocated); + } + + [Fact] + public void UnpackagedExecutionIsRefusedWithAnExplicitReason() + { + SessionException failure = Assert.Throws( + () => SessionRuntime.Create( + HostPaths.ForRoot(_root, packageFamilyName: null), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { })); + + Assert.Contains("installed package", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void RequireSetupRefusesAnUnmarkedInstallationWithoutContactingMxc() + { + var backend = new FakeMxcSessionClient(); + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { }, + backend); + + SessionException failure = Assert.Throws( + host.RequireSetup); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + Assert.Empty(backend.Calls); + } + + [Fact] + public void TheGuestHelperIsResolvedPerArchitectureBesideTheMxcRuntime() + { + string helperPath = SessionRuntime.ResolveHelperPath(@"C:\package"); + + Assert.StartsWith( + Path.Combine(@"C:\package", SessionRuntime.HelperDirectoryName), + helperPath, + StringComparison.Ordinal); + Assert.EndsWith(SessionRuntime.HelperFileName, helperPath, StringComparison.Ordinal); + Assert.Contains( + MxcRuntimeLocator.CurrentArchitectureName(), + helperPath, + StringComparison.Ordinal); + } +} From 88e7ee825e70fdeaaa9376bf992d511f5d42e8a1 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:22:03 -0700 Subject: [PATCH 04/10] Install the agent runtime through the guest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Session/SetupStateStore.cs | 9 + src/OpenClaw.SessionHost/Program.cs | 8 + .../SessionProcessLauncher.cs | 13 + .../SessionRuntimeInstaller.cs | 260 ++++++++++++++++++ .../SessionRuntimeProtocol.cs | 167 +++++++++++ .../Session/SessionRuntimeInstallerTests.cs | 195 +++++++++++++ .../Session/SessionRuntimePathTests.cs | 97 +++++++ .../Session/SessionRuntimeProtocolTests.cs | 78 ++++++ 8 files changed, 827 insertions(+) create mode 100644 src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs create mode 100644 src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs diff --git a/src/OpenClaw.Launcher/Session/SetupStateStore.cs b/src/OpenClaw.Launcher/Session/SetupStateStore.cs index 356ea927..66ad87af 100644 --- a/src/OpenClaw.Launcher/Session/SetupStateStore.cs +++ b/src/OpenClaw.Launcher/Session/SetupStateStore.cs @@ -38,6 +38,15 @@ internal sealed record SetupRecord [JsonPropertyName("startupEnabled")] public bool StartupEnabled { get; init; } = true; + + [JsonPropertyName("agentNodePath")] + public string? AgentNodePath { get; init; } + + [JsonPropertyName("agentNodeVersion")] + public string? AgentNodeVersion { get; init; } + + [JsonPropertyName("agentNodeArchive")] + public string? AgentNodeArchive { get; init; } } /// Why the explicit setup marker could not be used. diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs index 6d3e8b9c..a7d74d26 100644 --- a/src/OpenClaw.SessionHost/Program.cs +++ b/src/OpenClaw.SessionHost/Program.cs @@ -23,6 +23,14 @@ internal static int Run( Func readFile, Action writeResult) { + if (args.Count == 2 && args[0] == "--install-runtime") + { + return SessionRuntimeInstaller.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 diff --git a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs index 89095dc4..a0e6771c 100644 --- a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs +++ b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs @@ -28,6 +28,19 @@ internal interface ISessionProcessLauncher /// internal sealed class SessionProcessLauncher : ISessionProcessLauncher { + internal static void PrependPath(ProcessStartInfo startInfo, string? runtimeDirectory) + { + if (string.IsNullOrEmpty(runtimeDirectory)) + { + return; + } + + string inheritedPath = startInfo.Environment["PATH"] ?? string.Empty; + startInfo.Environment["PATH"] = string.IsNullOrEmpty(inheritedPath) + ? runtimeDirectory + : $"{runtimeDirectory};{inheritedPath}"; + } + public int Run(SessionLaunchRequest request) { string workingDirectory = request.WorkingDirectory!; diff --git a/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs new file mode 100644 index 00000000..4ca2eb1e --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs @@ -0,0 +1,260 @@ +using System.IO.Compression; +using Microsoft.Win32; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The --install-runtime mode: installs the packaged Node.js runtime +/// into this account's own profile. +/// +/// +/// +/// The host extracts its own copy into the invoking user's package LocalState, +/// which this account cannot read. The archive is package content and is +/// readable by both, so the agent extracts its own rather than being handed +/// files another identity owns. +/// +/// +/// Running extraction here means every file is created by the account that +/// will run it. +/// +/// +internal static class SessionRuntimeInstaller +{ + public static int Run( + string requestPath, + Func readFile, + Action writeFile, + Func? getLocalApplicationData = null) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + + try + { + SessionRuntimeInstallRequest request = + SessionRuntimeProtocol.ReadRequest(readFile(requestPath)); + requestId = request.RequestId; + + string directory = Path.Combine( + (getLocalApplicationData ?? + (() => Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData)))(), + "OpenClawGatewayMSIX", + "agent-node"); + string archiveRoot = Path.GetFileNameWithoutExtension(request.ArchivePath!); + string executablePath = Path.Combine( + directory, + archiveRoot, + "node.exe"); + if (!File.Exists(executablePath)) + { + string stagingDirectory = Path.Combine( + directory, + $"{archiveRoot}.extract-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(directory); + ZipFile.ExtractToDirectory(request.ArchivePath!, stagingDirectory, false); + string stagedRoot = Path.Combine(stagingDirectory, archiveRoot); + string stagedExecutable = Path.Combine(stagedRoot, "node.exe"); + if (!File.Exists(stagedExecutable)) + { + throw new SessionLaunchException( + "The installed Node.js runtime did not contain node.exe."); + } + + string targetRoot = Path.Combine(directory, archiveRoot); + if (Directory.Exists(targetRoot)) + { + Directory.Delete(targetRoot, recursive: true); + } + Directory.Move(stagedRoot, targetRoot); + } + finally + { + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + } + } + + if (!File.Exists(executablePath)) + { + throw new SessionLaunchException( + "The installed Node.js runtime did not contain node.exe."); + } + + bool pathUpdated = request.UpdateUserPath && TryPrependUserPath(directory); + + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = requestId, + ExecutablePath = executablePath, + Version = GetArchiveVersion(request.ArchivePath!), + ArchiveName = Path.GetFileName(request.ArchivePath!), + UserPathUpdated = pathUpdated + })); + return 0; + } + + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException or InvalidOperationException or + InvalidDataException or BadImageFormatException or + System.ComponentModel.Win32Exception) + { + TryWriteFailure(writeFile, resultPath, requestId, exception.Message); + return SessionLaunchProtocol.HelperFailureExitCode; + } + } + + private static string GetArchiveVersion(string archivePath) + { + string archiveName = Path.GetFileNameWithoutExtension(archivePath); + const string prefix = "node-v"; + const string platformMarker = "-win-"; + int platformIndex = archiveName.LastIndexOf( + platformMarker, + StringComparison.OrdinalIgnoreCase); + if (!archiveName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + platformIndex <= prefix.Length) + { + throw new SessionLaunchException( + $"The packaged Node.js runtime archive has an unexpected name: {archiveName}"); + } + + string version = archiveName[prefix.Length..platformIndex]; + return Version.TryParse(version, out Version? parsed) + ? parsed.ToString() + : throw new SessionLaunchException( + $"The packaged Node.js runtime archive has an invalid version: {archiveName}"); + } + + /// + /// Puts the runtime directory at the front of this account's persistent + /// user PATH. + /// + /// + /// + /// Prepended, not appended: a machine-wide Node.js installation is common, + /// and the packaged runtime is the one this installation supports. + /// + /// + /// This covers processes that build their environment from the registry. + /// It is not what governs the processes this package starts - those inherit + /// an environment - so it is deliberately paired with the launch-time + /// prepend rather than relied on alone. + /// + /// + /// Only this account's own hive is touched, never the machine's. + /// + /// + internal static bool TryPrependUserPath(string directory) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + using RegistryKey? environment = Registry.CurrentUser.OpenSubKey( + "Environment", writable: true); + if (environment is null) + { + return false; + } + + // GetValue expands REG_EXPAND_SZ by default, which would bake the + // current expansion of every %VAR% in the value back into the registry. + object? raw = environment.GetValue( + "Path", null, RegistryValueOptions.DoNotExpandEnvironmentNames); + string current = raw as string ?? string.Empty; + RegistryValueKind kind = raw is null + ? RegistryValueKind.ExpandString + : environment.GetValueKind("Path"); + + string updated = BuildPath(current, directory); + if (string.Equals(updated, current, StringComparison.Ordinal)) + { + return false; + } + + environment.SetValue("Path", updated, kind); + return true; + } + + /// + /// Builds the new value: the directory first, every unrelated entry after, + /// and any previous runtime directory dropped. + /// + /// + /// A previous version's directory is removed rather than left in place, so + /// repeated setups across upgrades cannot accumulate stale runtimes ahead + /// of the current one. + /// + internal static string BuildPath(string current, string directory) + { + string? previousRoot = Path.GetDirectoryName(directory); + List entries = [directory]; + + foreach (string entry in current.Split( + Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string trimmed = entry.Trim(); + if (trimmed.Length == 0 || + string.Equals( + trimmed.TrimEnd(Path.DirectorySeparatorChar), + directory.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase) || + IsPreviousRuntime(trimmed, previousRoot)) + { + continue; + } + + entries.Add(trimmed); + } + + return string.Join(Path.PathSeparator, entries); + } + + private static bool IsPreviousRuntime(string entry, string? runtimeRoot) + { + if (string.IsNullOrEmpty(runtimeRoot)) + { + return false; + } + + string parent = Path.GetDirectoryName( + entry.TrimEnd(Path.DirectorySeparatorChar)) ?? string.Empty; + return string.Equals( + parent.TrimEnd(Path.DirectorySeparatorChar), + runtimeRoot.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + + private static void TryWriteFailure( + Action writeFile, + string resultPath, + string? requestId, + string message) + { + try + { + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = requestId, + Error = message + })); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + } + } +} diff --git a/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs new file mode 100644 index 00000000..359450f3 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// +/// Asks the guest to install the packaged Node.js runtime into its own profile. +/// +/// +/// +/// The package ships Node.js as an archive and the host extracts it into the +/// invoking user's package LocalState, which the agent identity cannot read. +/// The archive itself is package content, readable by both identities, so the +/// guest extracts its own copy rather than being handed files it does not own. +/// +/// +/// The host names the archive; the guest decides where its own profile is, +/// because only it can resolve that. This is the same split the collect mode +/// uses, for the same reason. +/// +/// +public sealed record SessionRuntimeInstallRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// + /// The packaged Node.js archive to install, as a fully qualified path. + /// + [JsonPropertyName("archivePath")] + public string? ArchivePath { get; init; } + + /// + /// Whether to prepend the installed directory to the agent's persistent + /// user PATH. + /// + /// + /// Separate from the install itself so a caller that only wants the files + /// does not silently change the account's environment. + /// + [JsonPropertyName("updateUserPath")] + public bool UpdateUserPath { get; init; } = true; +} + +/// Where the guest put the runtime, and what it is. +public sealed record SessionRuntimeInstallResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// The agent-side node.exe the launcher must use. + [JsonPropertyName("executablePath")] + public string? ExecutablePath { get; init; } + + /// The version the installed runtime reported about itself. + [JsonPropertyName("version")] + public string? Version { get; init; } + + /// + /// The archive the install came from, so a later launch can tell that the + /// package has since shipped a different one. + /// + [JsonPropertyName("archiveName")] + public string? ArchiveName { get; init; } + + /// Whether the agent's persistent user path was changed. + [JsonPropertyName("userPathUpdated")] + public bool UserPathUpdated { get; init; } + + /// Set when the install could not be completed. + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(SessionRuntimeInstallRequest))] +[JsonSerializable(typeof(SessionRuntimeInstallResult))] +internal sealed partial class SessionRuntimeJsonContext : JsonSerializerContext; + +public static class SessionRuntimeProtocol +{ + public static string SerializeRequest(SessionRuntimeInstallRequest request) => + JsonSerializer.Serialize( + request, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallRequest); + + public static string SerializeResult(SessionRuntimeInstallResult result) => + JsonSerializer.Serialize( + result, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallResult); + + public static SessionRuntimeInstallRequest ReadRequest(string json) + { + SessionRuntimeInstallRequest request = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallRequest, + "runtime install request"); + + RequireCurrentSchema(request.SchemaVersion, "install request", "helper"); + + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new SessionLaunchException( + "The runtime install request has no request id."); + } + + // A relative or traversing archive path would let a malformed request + // name something outside the package this helper was staged from. + if (string.IsNullOrWhiteSpace(request.ArchivePath) || + !Path.IsPathFullyQualified(request.ArchivePath) || + request.ArchivePath.Contains("..", StringComparison.Ordinal)) + { + throw new SessionLaunchException( + "The runtime install request has no fully qualified archive path."); + } + + return request; + } + + public static SessionRuntimeInstallResult ReadResult(string json) + { + SessionRuntimeInstallResult result = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallResult, + "runtime install result"); + + RequireCurrentSchema(result.SchemaVersion, "install result", "launcher"); + return result; + } + + private static void RequireCurrentSchema(int version, string subject, string reader) + { + if (version != SessionLaunchProtocol.CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Runtime {subject} schema version {version} is not supported; " + + $"this {reader} implements version " + + $"{SessionLaunchProtocol.CurrentSchemaVersion}."); + } + } + + private static T Deserialize( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + string subject) + where T : class + { + try + { + return JsonSerializer.Deserialize(json, typeInfo) + ?? throw new SessionLaunchException($"The {subject} was empty."); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The {subject} could not be read: {exception.Message}", + exception); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs new file mode 100644 index 00000000..d32f48e7 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs @@ -0,0 +1,195 @@ +using System.IO.Compression; +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Drives the real guest installer against real files. +/// +/// +/// The whole contract is that a failure arrives as a readable result file: the +/// host has no other way to say why an install failed, and an unhandled +/// exception in the guest reports itself as a lost request instead. +/// +public sealed class SessionRuntimeInstallerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private string RequestPath => Path.Combine(_root, "runtime.json"); + + private SessionRuntimeInstallResult Run(string archivePath) + { + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath, + + // The account running tests is the developer's own, and its PATH + // is not this test's to change. + UpdateUserPath = false + })); + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, File.ReadAllText, File.WriteAllText); + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + + return SessionRuntimeProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + } + + private SessionRuntimeInstallResult Install(string archivePath) + { + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath, + UpdateUserPath = false + })); + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root); + Assert.Equal(0, exitCode); + + return SessionRuntimeProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + } + + private string CreateArchive(string version) + { + string archivePath = Path.Combine(_root, $"node-v{version}-win-x64.zip"); + using ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + ZipArchiveEntry entry = archive.CreateEntry( + $"node-v{version}-win-x64/node.exe"); + using StreamWriter writer = new(entry.Open()); + writer.Write(version); + return archivePath; + } + + [Fact] + public void ReinstallingIntoAnExistingAgentProfileReportsTheCurrentArchiveVersion() + { + Install(CreateArchive("24.15.0")); + + SessionRuntimeInstallResult result = Install(CreateArchive("24.20.0")); + + Assert.Equal("24.20.0", result.Version); + Assert.Equal("node-v24.20.0-win-x64.zip", result.ArchiveName); + Assert.Equal( + Path.Combine( + _root, + "OpenClawGatewayMSIX", + "agent-node", + "node-v24.20.0-win-x64", + "node.exe"), + result.ExecutablePath); + Assert.Equal("24.20.0", File.ReadAllText(result.ExecutablePath!)); + } + + [Fact] + public void InstallPersistsTheDirectoryContainingNode() + { + string archivePath = CreateArchive("24.20.0"); + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath + })); + string? persistedDirectory = null; + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root, + directory => + { + persistedDirectory = directory; + return true; + }); + + Assert.Equal(0, exitCode); + Assert.Equal( + Path.Combine( + _root, + "OpenClawGatewayMSIX", + "agent-node", + "node-v24.20.0-win-x64"), + persistedDirectory); + } + + [Fact] + public void SameVersionInstallReusesExistingNodeEvenWhenItIsOpen() + { + string archivePath = CreateArchive("24.20.0"); + SessionRuntimeInstallResult first = Install(archivePath); + using FileStream heldOpen = File.Open( + first.ExecutablePath!, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + SessionRuntimeInstallResult second = Install(archivePath); + + Assert.Equal(first.ExecutablePath, second.ExecutablePath); + Assert.Equal("24.20.0", second.Version); + } + + // A truncated or corrupt archive is a real packaging failure. Left + // unhandled it crashes the guest, and the host then reports a lost request + // rather than the reason. + [Fact] + public void ACorruptArchiveIsReportedThroughTheResultFile() + { + string archive = Path.Combine(_root, "node-v24.15.0-win-x64.zip"); + File.WriteAllText(archive, "this is not a zip archive"); + + SessionRuntimeInstallResult result = Run(archive); + + Assert.NotNull(result.Error); + Assert.Null(result.ExecutablePath); + } + + [Fact] + public void AMissingArchiveIsReportedThroughTheResultFile() + { + SessionRuntimeInstallResult result = Run( + Path.Combine(_root, "node-v24.15.0-win-x64.zip")); + + Assert.NotNull(result.Error); + Assert.Null(result.ExecutablePath); + } + + // An archive whose name does not carry a version cannot be installed, and + // saying so beats extracting something unidentifiable. + [Fact] + public void AnUnrecognizedArchiveNameIsReportedThroughTheResultFile() + { + string archive = Path.Combine(_root, "node.zip"); + File.WriteAllText(archive, "irrelevant"); + + SessionRuntimeInstallResult result = Run(archive); + + Assert.NotNull(result.Error); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs new file mode 100644 index 00000000..6ecc0e55 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs @@ -0,0 +1,97 @@ +using OpenClaw.SessionHost; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Covers the agent's own PATH, which is what makes the packaged Node.js +/// runtime win over a machine-wide installation. +/// +public sealed class SessionRuntimePathTests +{ + private const string Runtime = + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node-v24.15.0-win-x64"; + + // The whole point is precedence. Appended, a machine-wide Node.js keeps + // winning for anything that resolves `node` by name. + [Fact] + public void TheRuntimeComesFirst() + { + string result = SessionRuntimeInstaller.BuildPath( + @"C:\Windows\system32;C:\Program Files\nodejs", Runtime); + + Assert.StartsWith(Runtime + ";", result, StringComparison.Ordinal); + Assert.Contains(@"C:\Program Files\nodejs", result, StringComparison.Ordinal); + } + + // Setup is idempotent, so re-running it must not grow the value every time. + [Fact] + public void RepeatedInstallsDoNotAccumulateEntries() + { + string once = SessionRuntimeInstaller.BuildPath(@"C:\Windows\system32", Runtime); + string twice = SessionRuntimeInstaller.BuildPath(once, Runtime); + + Assert.Equal(once, twice); + } + + // A package upgrade installs a new version beside the old one. Leaving the + // old directory on PATH would keep resolving the runtime the installation + // no longer supports. + [Fact] + public void APreviousRuntimeVersionIsRemoved() + { + const string previous = + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node-v22.1.0-win-x64"; + + string result = SessionRuntimeInstaller.BuildPath( + $@"{previous};C:\Windows\system32", Runtime); + + Assert.DoesNotContain(previous, result, StringComparison.Ordinal); + Assert.StartsWith(Runtime + ";", result, StringComparison.Ordinal); + Assert.Contains(@"C:\Windows\system32", result, StringComparison.Ordinal); + } + + // An unrelated directory that merely looks similar must survive: this runs + // against a real account's PATH, not a fixture. + [Fact] + public void UnrelatedEntriesAreLeftAlone() + { + string result = SessionRuntimeInstaller.BuildPath( + @"C:\tools\node-v24.15.0-win-x64;C:\Windows", Runtime); + + Assert.Contains(@"C:\tools\node-v24.15.0-win-x64", result, StringComparison.Ordinal); + Assert.Contains(@"C:\Windows", result, StringComparison.Ordinal); + } + + // An empty value is the normal first-run case for a fresh profile. + [Fact] + public void AnEmptyPathBecomesJustTheRuntime() + { + Assert.Equal(Runtime, SessionRuntimeInstaller.BuildPath(string.Empty, Runtime)); + } + + // Only the guest can resolve the child's PATH, because the host never sees + // the agent account's environment. + [Fact] + public void ALaunchPutsTheRuntimeAheadOfTheInheritedPath() + { + System.Diagnostics.ProcessStartInfo startInfo = new(); + startInfo.Environment["PATH"] = @"C:\Program Files\nodejs"; + + SessionProcessLauncher.PrependPath(startInfo, Runtime); + + Assert.Equal( + $@"{Runtime};C:\Program Files\nodejs", + startInfo.Environment["PATH"]); + } + + [Fact] + public void ALaunchWithoutARuntimeLeavesThePathAlone() + { + System.Diagnostics.ProcessStartInfo startInfo = new(); + startInfo.Environment["PATH"] = @"C:\Program Files\nodejs"; + + SessionProcessLauncher.PrependPath(startInfo, null); + + Assert.Equal(@"C:\Program Files\nodejs", startInfo.Environment["PATH"]); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs new file mode 100644 index 00000000..e4d01832 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs @@ -0,0 +1,78 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// The contract for installing the agent's own Node.js runtime. +/// +public sealed class SessionRuntimeProtocolTests +{ + private static SessionRuntimeInstallRequest Valid() => new() + { + RequestId = "r1", + ArchivePath = @"C:\Package\runtime\node-v24.15.0-win-x64.zip" + }; + + [Fact] + public void AValidRequestRoundTrips() + { + SessionRuntimeInstallRequest parsed = SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest(Valid())); + + Assert.Equal("r1", parsed.RequestId); + Assert.Equal(Valid().ArchivePath, parsed.ArchivePath); + Assert.True(parsed.UpdateUserPath); + } + + // The host names the archive; a relative or traversing value would let a + // malformed request point the guest outside the package. + [Theory] + [InlineData("")] + [InlineData(@"runtime\node.zip")] + [InlineData(@"C:\Package\..\elsewhere\node.zip")] + public void AnArchivePathThatIsNotFullyQualifiedIsRejected(string archivePath) + { + Assert.Throws( + () => SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest( + Valid() with { ArchivePath = archivePath }))); + } + + [Fact] + public void ARequestWithoutAnIdIsRejected() + { + Assert.Throws( + () => SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest(Valid() with { RequestId = null }))); + } + + // The helper and the launcher ship together, so a mismatch means a stale + // file or a mixed installation. + [Fact] + public void AMismatchedSchemaVersionIsRejected() + { + Assert.Throws( + () => SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest(Valid() with + { + SchemaVersion = SessionLaunchProtocol.CurrentSchemaVersion + 1 + }))); + } + + // A failed install has to arrive as a readable result, because the host + // reports it rather than guessing from an exit code. + [Fact] + public void AFailureResultRoundTrips() + { + SessionRuntimeInstallResult parsed = SessionRuntimeProtocol.ReadResult( + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = "r1", + Error = "the archive is corrupt" + })); + + Assert.Equal("the archive is corrupt", parsed.Error); + Assert.Null(parsed.ExecutablePath); + } +} + From 85206283b29f3098345879781dd32aceef8f3a39 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 14 Sep 2026 18:23:14 -0700 Subject: [PATCH 05/10] Activate public session setup and transparent routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 28 ++- src/OpenClaw.Launcher/HostStartup.cs | 2 + src/OpenClaw.Launcher/Program.cs | 158 ++++++++++-- .../Session/SessionExecutor.cs | 105 +++++++- .../Session/SessionRuntime.cs | 23 ++ .../SmokeProgram.cs | 9 +- .../ClawCtlCommandLineTests.cs | 9 +- .../ClawCtlParserDefaultsTests.cs | 10 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 233 +++++++++++++++++- .../Session/SessionExecutorTests.cs | 34 +++ 10 files changed, 573 insertions(+), 38 deletions(-) diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 10911998..86f098a1 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -4,6 +4,13 @@ namespace OpenClaw.Launcher; +internal sealed record ClawCtlHandlers +{ + public required Func> Setup { get; init; } + public required Func> Status { get; init; } + public required Func> Teardown { get; init; } +} + // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the // bundled CLI and reached through `openclaw`, which forwards its arguments @@ -11,6 +18,7 @@ namespace OpenClaw.Launcher; internal static class ClawCtlCommandLine { public const string SetupCommandName = "setup"; + public const string StatusCommandName = "status"; // Response-file expansion is off. A leading `@` means nothing to clawctl, // so it is reported as an unrecognized argument instead of silently reading @@ -24,10 +32,10 @@ internal static class ClawCtlCommandLine // Setup guidance is available without preparing the runtime. public static string RootDescription => - "Prepare the bundled Node.js runtime and verify the packaged OpenClaw application." + + "Set up and manage the packaged OpenClaw application." + Environment.NewLine + Environment.NewLine + - "Run `clawctl setup` to extract or repair the bundled runtime." + + "Run `clawctl setup` to prepare the bundled runtime and isolated session." + Environment.NewLine + Environment.NewLine + "Run `openclaw ` to invoke the OpenClaw CLI."; @@ -38,14 +46,24 @@ internal static class ClawCtlCommandLine // runSetup stays a delegate so the command tree owns parsing and help while // Program keeps the readiness operation and its test seams. - public static RootCommand Create(Func> runSetup) + public static RootCommand Create(ClawCtlHandlers handlers) { + ArgumentNullException.ThrowIfNull(handlers); Command setup = new(SetupCommandName, SetupDescription); - setup.SetAction((_, cancellationToken) => runSetup(cancellationToken)); + setup.SetAction((_, cancellationToken) => handlers.Setup(cancellationToken)); + Command status = new(StatusCommandName, "Show the recorded isolated session without changing it."); + status.SetAction((_, cancellationToken) => handlers.Status(cancellationToken)); + Option force = new("--force") { Description = "Skip confirmation and remove the owned session." }; + Command teardown = new("teardown", "Stop and remove the owned isolated session."); + teardown.Options.Add(force); + teardown.SetAction((parsed, cancellationToken) => + handlers.Teardown(parsed.GetValue(force), cancellationToken)); RootCommand root = new(RootDescription) { - setup + setup, + status, + teardown }; // Bare `clawctl` is a discovery request, not a usage error, so the root diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index 050f1d70..1a815fa0 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -23,6 +23,8 @@ internal sealed class HostStartup public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; } + public Func, Session.SessionRuntime>? CreateSessionRuntime { get; init; } + public static HostStartup CreateProduction() => new() { Entrypoint = HostEntrypointResolver.Resolve(), diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index be74d3a5..60979a74 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.Diagnostics.CodeAnalysis; +using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher; @@ -114,7 +115,8 @@ PlatformNotSupportedException or WriteDiagnostic, startup.ResolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( GetPackagedNodeArchivePath(options)))), - startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync) + startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, + startup.CreateSessionRuntime) .ConfigureAwait(false); } catch (Exception exception) @@ -153,22 +155,69 @@ internal static async Task RunAgentAsync( HostOptions options, Action log, Func> resolveNode, - LaunchOpenClawAsync launchOpenClaw) + LaunchOpenClawAsync launchOpenClaw, + Func, Session.SessionRuntime>? createSessionRuntime = null, + Func? isInteractive = null) { - NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) - .ConfigureAwait(false); - log( - $"Using Node.js {nodeRuntime.Version} from " + - $"{nodeRuntime.ExecutablePath}."); + Session.SessionMode mode = Session.SessionRoutingPolicy.ReadMode( + Environment.GetEnvironmentVariable); + if (mode == Session.SessionMode.Disabled) + { + return await RunDirectAsync().ConfigureAwait(false); + } + + Session.SessionRuntime runtime; + Session.SessionRecord record; + try + { + runtime = (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + record = await runtime.StartForExecutionAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (Session.SessionCapabilityUnavailableException) + when (mode == Session.SessionMode.Automatic) + { + log("Isolated session unavailable; running OpenClaw directly."); + return await RunDirectAsync().ConfigureAwait(false); + } + + string agentNodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); string applicationDirectory = GetPackagedApplicationDirectory(options); log("Using the OpenClaw application directly from the package."); - return await launchOpenClaw( - nodeRuntime.ExecutablePath, - applicationDirectory, - options.OpenClawArguments, - GatewayIsolationMode.Disabled, - CancellationToken.None, - log).ConfigureAwait(false); + return await runtime.Executor.ExecuteAsync( + record, + new Session.SessionExecutionRequest( + runtime.RequireStagedHelper(record), + agentNodePath, + applicationDirectory, + options.OpenClawArguments, + record.WorkspacePath!) + { + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), + Environment.GetEnvironmentVariable, + GatewayIsolationMode.Enabled) + }, + CancellationToken.None).ConfigureAwait(false); + + async Task RunDirectAsync() + { + NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) + .ConfigureAwait(false); + log( + $"Using Node.js {nodeRuntime.Version} from " + + $"{nodeRuntime.ExecutablePath}."); + string directApplicationDirectory = GetPackagedApplicationDirectory(options); + log("Using the OpenClaw application directly from the package."); + return await launchOpenClaw( + nodeRuntime.ExecutablePath, + directApplicationDirectory, + options.OpenClawArguments, + GatewayIsolationMode.Disabled, + CancellationToken.None, + log).ConfigureAwait(false); + } } // output and error are required parameters (not Console defaults) so tests @@ -180,15 +229,82 @@ internal static async Task RunControlAsync( Action log, TextWriter output, TextWriter error, - Func>? resolveNode = null) + Func>? resolveNode = null, + Func? createSessionRuntime = null) { + Session.SessionRuntime? sessionRuntime = null; + Session.SessionRuntime GetSessionRuntime() => + sessionRuntime ??= createSessionRuntime?.Invoke() ?? + Session.SessionRuntime.Create(log); + RootCommand command = ClawCtlCommandLine.Create( - cancellationToken => RunSetupAsync( - options, - log, - output, - resolveNode, - cancellationToken)); + new ClawCtlHandlers + { + Setup = async cancellationToken => + { + int result = await RunSetupAsync( + options, log, output, resolveNode, cancellationToken) + .ConfigureAwait(false); + if (result != 0) + { + return result; + } + + try + { + Session.SessionRuntime runtime = GetSessionRuntime(); + using Session.ISessionLockHandle handle = + runtime.AcquireLifecycleLock(); + runtime.SetupState.Write(new Session.SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = Session.SetupPhase.Preparing + }); + Session.SessionRecord record = + await runtime.Coordinator.EnsureStartedAsync(cancellationToken) + .ConfigureAwait(false); + string helperPath = runtime.StageHelper(record); + SessionRuntimeInstallResult agentRuntime = + await runtime.Executor.InstallRuntimeAsync( + record, + helperPath, + GetPackagedNodeArchivePath(options), + cancellationToken) + .ConfigureAwait(false); + runtime.CompleteSetup( + record, + agentRuntime, + startupEnabled: false); + await output.WriteLineAsync("OpenClaw isolated session is ready.") + .ConfigureAwait(false); + return 0; + } + catch (Session.SessionException exception) + { + log($"Isolated session setup is unavailable: {exception.Message}"); + await output.WriteLineAsync( + $"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + }, + Status = async cancellationToken => + { + Session.SessionStatus status = GetSessionRuntime() + .Coordinator.GetRecordedStatus(); + await output.WriteLineAsync(status.Availability.ToString()) + .ConfigureAwait(false); + return 0; + }, + Teardown = async (_, cancellationToken) => + { + await GetSessionRuntime().Coordinator.RemoveAsync(cancellationToken) + .ConfigureAwait(false); + await output.WriteLineAsync("OpenClaw isolated session was removed.") + .ConfigureAwait(false); + return 0; + } + }); InvocationConfiguration configuration = new() { diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index 89b27e96..469671a7 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -11,7 +11,13 @@ internal sealed record SessionExecutionRequest( string NodePath, string ApplicationDirectory, IReadOnlyList Arguments, - string WorkingDirectory); + string WorkingDirectory) +{ + /// + /// Environment determined by the host entrypoint for this invocation. + /// + public IReadOnlyDictionary? AdditionalEnvironment { get; init; } +} /// /// An arbitrary foreground command to run inside the session. @@ -85,7 +91,10 @@ public async Task ExecuteAsync( request.HelperPath, request.NodePath, BuildNodeArguments(request), - request.WorkingDirectory), + request.WorkingDirectory) + { + AdditionalEnvironment = request.AdditionalEnvironment + }, "Running OpenClaw in the isolated session.", "OpenClaw", cancellationToken).ConfigureAwait(false); @@ -153,6 +162,88 @@ await operation.WriteTextNewAsync( } } + /// Installs the package's Node.js runtime in the agent profile. + public async Task InstallRuntimeAsync( + SessionRecord record, + string helperPath, + string archivePath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(helperPath); + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("runtime", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = requestId, + ArchivePath = archivePath + }), + cancellationToken).ConfigureAwait(false); + + _log("Installing the packaged Node.js runtime in the isolated session."); + + MxcExecutionResult execution = await _backend.ExecuteAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + BuildGuestCommandLine(helperPath, requestPath, "--install-runtime")), + 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 runtime install " + + DescribeMissingResult(execution)); + } + + SessionRuntimeInstallResult result = + SessionRuntimeProtocol.ReadResult(resultText); + if (result.Error is { Length: > 0 } error) + { + throw new SessionException( + $"The packaged Node.js runtime could not be installed in the session: {error}"); + } + + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a runtime install result " + + "for a different request."); + } + + if (string.IsNullOrWhiteSpace(result.ExecutablePath) || + string.IsNullOrWhiteSpace(result.Version)) + { + throw new SessionException( + "The isolated session reported a runtime install without a " + + "Node.js executable path and version."); + } + + return result; + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + internal static IReadOnlyDictionary MergeEnvironment( IReadOnlyDictionary baseEnvironment, IReadOnlyDictionary? additional) @@ -290,4 +381,14 @@ private static int ReadOutcome( return result.ExitCode ?? executorExitCode; } + private static string DescribeMissingResult(MxcExecutionResult execution) + { + string detail = string.Join( + " ", + new[] { execution.StandardOutput, execution.StandardError } + .Where(static value => !string.IsNullOrWhiteSpace(value))); + return string.IsNullOrWhiteSpace(detail) + ? $"result (executor exit code {execution.ExitCode})." + : $"result (executor exit code {execution.ExitCode}): {detail}"; + } } diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index ab6e8655..08dce647 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -1,4 +1,5 @@ using OpenClaw.Launcher.Mxc; +using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher.Session; @@ -179,6 +180,28 @@ public SessionRecord RequireSetup() return session.Record; } + /// Records a completed guest runtime installation. + public void CompleteSetup( + SessionRecord session, + SessionRuntimeInstallResult runtime, + bool startupEnabled) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(runtime); + + SetupState.Write(new SetupRecord + { + ApplicationId = ApplicationId, + SandboxId = session.SandboxId, + Phase = SetupPhase.Ready, + StartupEnabled = startupEnabled, + CompletedUtc = DateTimeOffset.UtcNow, + AgentNodePath = runtime.ExecutablePath, + AgentNodeVersion = runtime.Version, + AgentNodeArchive = runtime.ArchiveName + }); + } + internal static string ResolveHelperPath(string baseDirectory) => Path.GetFullPath( Path.Combine( diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 964a3378..d4d51611 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -40,7 +40,7 @@ private static async Task Main() ("unknown command fails", UnknownCommandFailsAsync), ("response-file token is not expanded", ResponseFileTokenIsNotExpandedAsync), ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), - ("setup reports readiness and logs", SetupReportsReadinessAsync), + ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), ("missing application reports diagnostics", MissingApplicationReportsAsync), ("openclaw forwards arguments verbatim", AgentForwardsArgumentsAsync) ]; @@ -208,9 +208,12 @@ private static async Task SetupReportsReadinessAsync() int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); - AssertExitCode(0, exitCode, fixture); + AssertExitCode(1, exitCode, fixture); AssertContains(fixture.Output.ToString(), fixture.ApplicationDirectory, fixture); - AssertContains(fixture.Output.ToString(), Fixture.NodePath, fixture); + AssertContains( + fixture.Output.ToString(), + "not running from its installed package", + fixture); fixture.AssertLogRecordsStartupAndExit(); Assert( File.Exists(fixture.EntryPoint), diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index c184a734..89d27c83 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -69,10 +69,15 @@ public async Task HelpDescribesBundledRuntimePreparation() [Fact] public void OnlyTheReadinessCommandIsExposed() { - RootCommand root = ClawCtlCommandLine.Create(_ => Task.FromResult(0)); + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = _ => Task.FromResult(0), + Status = _ => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0) + }); Assert.Equal( - [ClawCtlCommandLine.SetupCommandName], + [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown"], root.Subcommands.Select(command => command.Name)); } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 8d4b7d83..688f8ced 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -69,9 +69,13 @@ public async Task ResponseFileTokenForAMissingFileIsAlsoJustAnArgument() [Fact] public void CompletionSuggestsCommandsWithoutResolvingNode() { - RootCommand root = ClawCtlCommandLine.Create( - _ => throw new InvalidOperationException( - "Completion started the readiness operation.")); + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = _ => throw new InvalidOperationException( + "Completion started the readiness operation."), + Status = _ => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0) + }); IEnumerable completions = root .Parse("se", ClawCtlCommandLine.CreateParserConfiguration()) diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index fd2b614a..4921feb4 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1,8 +1,14 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.Launcher.Tests.Session; +using OpenClaw.SessionProtocol; + namespace OpenClaw.Launcher.Tests; public sealed class ProgramTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); + private FakeMxcSessionClient? _lastSessionBackend; [Fact] public async Task AgentLaunchResolvesNodeAndRunsPackagedApplication() @@ -59,13 +65,16 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() Directory.CreateDirectory(applicationDirectory); string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); await File.WriteAllTextAsync(entryPoint, "console.log('fixture');"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + await File.WriteAllTextAsync(archivePath, "fixture"); DateTime lastWriteTime = File.GetLastWriteTimeUtc(entryPoint); - var options = new HostOptions(applicationDirectory, null, []); + var options = new HostOptions(applicationDirectory, archivePath, []); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); using var output = new StringWriter(); + SessionRuntime runtime = CreateSessionRuntime(); int exitCode = await Program.RunControlAsync( options, @@ -73,7 +82,8 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() _ => { }, output, TextWriter.Null, - _ => Task.FromResult(nodeRuntime)); + _ => Task.FromResult(nodeRuntime), + () => runtime); Assert.Equal(0, exitCode); Assert.True(File.Exists(entryPoint)); @@ -86,6 +96,176 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() applicationDirectory, output.ToString(), StringComparison.Ordinal); + SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; + Assert.Equal(SetupPhase.Ready, setup.Phase); + Assert.False(setup.StartupEnabled); + Assert.Equal("24.15.0", setup.AgentNodeVersion); + Assert.Contains( + _lastSessionBackend!.Calls, + call => call.StartsWith("execute:", StringComparison.Ordinal)); + Assert.DoesNotContain( + _lastSessionBackend.Calls, + call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AgentUsesTheRuntimeInstalledForTheSessionWithoutHostFallback() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + await File.WriteAllTextAsync(archivePath, "fixture"); + var options = new HostOptions(applicationDirectory, archivePath, ["gateway"]); + var hostNode = new NodeRuntime( + Path.Combine(_testDirectory, "host-node.exe"), + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + SessionRuntime runtime = CreateSessionRuntime(); + + int setupExitCode = await Program.RunControlAsync( + options, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + _ => Task.FromResult(hostNode), + () => runtime); + Assert.Equal(0, setupExitCode); + + string expectedAgentNode = runtime.SetupState + .Read(runtime.ApplicationId).Record!.AgentNodePath!; + bool directLaunchAttempted = false; + _lastSessionBackend!.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "launch-*.json").Single(); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + Assert.Equal(expectedAgentNode, request.Executable); + Assert.Equal( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + request.WorkingDirectory); + Assert.Equal( + "enabled", + request.Environment![OpenClawRuntimeEnvironment.GatewayIsolationVariable]); + return Task.FromResult(0); + }; + + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + options, + _ => { }, + _ => throw new InvalidOperationException("Host Node must not be resolved."), + (_, _, _, _, _, _) => + { + directLaunchAttempted = true; + return Task.FromResult(0); + }, + _ => runtime)); + + Assert.False(directLaunchAttempted); + } + + [Fact] + public async Task AgentRefusesForeignSessionRecordWithoutHostFallback() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + SessionRecord record = new SessionStateStore(_sessionStatePath!) + .Read(runtime.ApplicationId).Record!; + new SessionStateStore(_sessionStatePath!).Write(record with + { + SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") + }); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + + Assert.Contains("Some.Other.App", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + + [Fact] + public async Task AgentRefusesSetupSessionMismatchWithoutHostFallback() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; + runtime.SetupState.Write(setup with { SandboxId = "iso:different" }); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + + Assert.Contains("different session", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + + [Fact] + public async Task AgentRefusesUnreadableSessionRecordWithoutHostFallback() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + await File.WriteAllTextAsync(_sessionStatePath!, "{ not json"); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + + Assert.Contains("not valid JSON", failure.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + + [Fact] + public async Task AgentFallsBackToHostWhenSessionRuntimeIsUnavailable() + { + SessionRuntime runtime = await SetUpSessionAsync(); + _lastSessionBackend!.StartFailure = new MxcException( + MxcErrorCode.RuntimeUnavailable, + "The MXC runtime is not available."); + bool directLaunchAttempted = false; + string applicationDirectory = Path.Combine(_testDirectory, "app"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + + int exitCode = await Program.RunAgentAsync( + new HostOptions(applicationDirectory, archivePath, ["--version"]), + _ => { }, + _ => Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), + (_, _, _, _, _, _) => + { + directLaunchAttempted = true; + return Task.FromResult(17); + }, + _ => runtime); + + Assert.True(directLaunchAttempted); + Assert.Equal(17, exitCode); + } + + [Fact] + public async Task TeardownRequiresForceBeforeRemovingTheSession() + { + SessionRuntime runtime = CreateSessionRuntime(); + using var error = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, null, []), + ["teardown"], + _ => { }, + TextWriter.Null, + error, + createSessionRuntime: () => runtime); + + Assert.Equal(1, exitCode); + Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain( + _lastSessionBackend!.Calls, + call => call.StartsWith("deprovision:", StringComparison.Ordinal)); } [Fact] @@ -142,4 +322,53 @@ public void Dispose() Directory.Delete(_testDirectory, recursive: true); GC.SuppressFinalize(this); } + + private SessionRuntime CreateSessionRuntime() + { + string stateRoot = Path.Combine(_testDirectory, "state"); + string baseDirectory = Path.Combine(_testDirectory, "base"); + string workspace = Path.Combine(_testDirectory, "workspace"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(workspace); + string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + File.WriteAllText(helperPath, "fixture"); + var backend = new FakeMxcSessionClient + { + Metadata = new MxcProvisionMetadata( + "agent_1", + "S-1-5-21-0-0-0-1001", + workspace) + }; + backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles(workspace, "runtime-*.json").Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + ExecutablePath = Path.Combine( + workspace, + "AppData", + "Local", + "OpenClaw", + "NodeJS", + "node-v24.15.0-win-x64", + "node.exe"), + Version = "24.15.0", + ArchiveName = "node-v24.15.0-win-x64.zip" + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + _lastSessionBackend = backend; + return SessionRuntime.Create( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + () => throw new InvalidOperationException("The test supplies its backend."), + baseDirectory, + _ => { }, + backend); + } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index a273b08b..bded9d3d 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -39,6 +39,40 @@ private SessionExecutionRequest Request(params string[] arguments) => private SessionExecutor Create() => new(_backend, _log.Add); + [Fact] + public async Task IsolatedLaunchRetainsTheHostInteractiveEnvironment() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync( + Record(), + new SessionExecutionRequest( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Program Files\nodejs\node.exe", + @"C:\Package\app", + [], + Workspace) + { + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( + isInteractive: true, + _ => null) + }, + CancellationToken.None); + + Assert.Equal("3", delivered!.Environment!["FORCE_COLOR"]); + Assert.Equal("1", delivered.Environment["WT_SESSION"]); + } + /// /// Stands in for the guest helper: reads the delivered request and writes /// the control result the real helper would. From 5c30e0c1fc79fd5ca2468c5111c01839e36e66ea Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 15 Sep 2026 19:17:24 -0700 Subject: [PATCH 06/10] Fix isolated session lifecycle boundaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../OpenClawRuntimeEnvironment.cs | 12 +++++-- src/OpenClaw.Launcher/Program.cs | 24 +++++++++++-- src/OpenClaw.Launcher/Session/SessionLock.cs | 9 +++-- .../Session/SessionRuntime.cs | 34 ++++++++++++++++--- .../SessionProcessLauncher.cs | 1 + .../Session/SessionLockTests.cs | 8 ++--- .../Session/SessionRuntimeInstallerTests.cs | 2 +- 7 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs index 55affd7f..79f5fb2f 100644 --- a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs +++ b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs @@ -14,6 +14,7 @@ internal static class OpenClawRuntimeEnvironment public const string SupervisorModeVariable = "OPENCLAW_SUPERVISOR_MODE"; public const string ServiceRepairPolicyVariable = "OPENCLAW_SERVICE_REPAIR_POLICY"; public const string NoAutoUpdateVariable = "OPENCLAW_NO_AUTO_UPDATE"; + public const string GatewayIsolationVariable = "CLAWCTL_GATEWAY_ISOLATION"; public const string ExternalValue = "external"; public const string NoAutoUpdateValue = "1"; @@ -30,21 +31,26 @@ internal static class OpenClawRuntimeEnvironment /// /// The variables to apply, as an ordinary dictionary. /// - public static IReadOnlyDictionary Build() => + public static IReadOnlyDictionary Build( + GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled) => new Dictionary(StringComparer.OrdinalIgnoreCase) { [SupervisorModeVariable] = ExternalValue, [ServiceRepairPolicyVariable] = ExternalValue, [NoAutoUpdateVariable] = NoAutoUpdateValue, + [GatewayIsolationVariable] = gatewayIsolationMode.ToEnvironmentValue(), }; public static IReadOnlyDictionary Build( bool isInteractive, - Func readEnvironmentVariable) + Func readEnvironmentVariable, + GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled) { ArgumentNullException.ThrowIfNull(readEnvironmentVariable); - Dictionary result = new(Build(), StringComparer.OrdinalIgnoreCase); + Dictionary result = new( + Build(gatewayIsolationMode), + StringComparer.OrdinalIgnoreCase); string? forceColor = readEnvironmentVariable(ForceColorVariable); string? wtSession = readEnvironmentVariable(WindowsTerminalSessionVariable); string? noColor = readEnvironmentVariable(NoColorVariable); diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 60979a74..72a2a2fa 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -250,6 +250,15 @@ Session.SessionRuntime GetSessionRuntime() => return result; } + if (Session.SessionRoutingPolicy.ReadMode( + Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) + { + await output.WriteLineAsync( + "Isolated session setup was skipped because OPENCLAW_SESSION is disabled.") + .ConfigureAwait(false); + return 0; + } + try { Session.SessionRuntime runtime = GetSessionRuntime(); @@ -296,9 +305,20 @@ await output.WriteLineAsync(status.Availability.ToString()) .ConfigureAwait(false); return 0; }, - Teardown = async (_, cancellationToken) => + Teardown = async (force, cancellationToken) => { - await GetSessionRuntime().Coordinator.RemoveAsync(cancellationToken) + if (!force) + { + await error.WriteLineAsync( + "Teardown removes the isolated session and its data. Re-run with --force to continue.") + .ConfigureAwait(false); + return 1; + } + + Session.SessionRuntime runtime = GetSessionRuntime(); + using Session.ISessionLockHandle handle = + runtime.AcquireLifecycleLock(); + await runtime.Coordinator.RemoveAsync(cancellationToken) .ConfigureAwait(false); await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); diff --git a/src/OpenClaw.Launcher/Session/SessionLock.cs b/src/OpenClaw.Launcher/Session/SessionLock.cs index 8d993fbd..78e7bc34 100644 --- a/src/OpenClaw.Launcher/Session/SessionLock.cs +++ b/src/OpenClaw.Launcher/Session/SessionLock.cs @@ -25,7 +25,7 @@ internal interface ISessionLock } /// -/// Named-mutex lifecycle lock, scoped to one user and package identity. +/// Named-mutex lifecycle lock, scoped to one installation's LocalState path. /// internal sealed class NamedSessionLock : ISessionLock { @@ -35,10 +35,9 @@ public NamedSessionLock(string scope) { ArgumentException.ThrowIfNullOrWhiteSpace(scope); - // Local\ keeps the lock inside the session of the current user, which - // matches the one-session-per-user-and-package rule and avoids needing - // rights on a Global object. - _name = "Local\\OpenClawSessionLifecycle_" + Sanitize(scope); + // The LocalState path makes this installation-specific. Global\ then + // carries that same lock across console and remote desktop sessions. + _name = "Global\\OpenClawSessionLifecycle_" + Sanitize(scope); } public string Name => _name; diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 08dce647..eacb6490 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -27,7 +27,8 @@ private SessionRuntime( IMxcSessionClient backend, string helperPath, string applicationId, - SetupStateStore setupState) + SetupStateStore setupState, + string lifecycleLockScope) { Coordinator = coordinator; Executor = executor; @@ -35,7 +36,7 @@ private SessionRuntime( HelperPath = helperPath; ApplicationId = applicationId; SetupState = setupState; - LifecycleLock = new NamedSessionLock(applicationId + "_Installation"); + LifecycleLock = new NamedSessionLock(lifecycleLockScope); } public SessionCoordinator Coordinator { get; } @@ -115,7 +116,7 @@ internal static SessionRuntime Create( var coordinator = new SessionCoordinator( client, new SessionStateStore(paths.SessionStatePath), - new NamedSessionLock(applicationId), + new NamedSessionLock(paths.SessionStatePath), applicationId, log); @@ -125,7 +126,8 @@ internal static SessionRuntime Create( client, ResolveHelperPath(baseDirectory), applicationId, - new SetupStateStore(paths.SetupStatePath)); + new SetupStateStore(paths.SetupStatePath), + paths.SessionStatePath + "_Installation"); bool IsCurrentSessionRecord(SessionRecord record) { @@ -180,6 +182,30 @@ public SessionRecord RequireSetup() return session.Record; } + /// + /// Returns the Node.js executable extracted by the agent for the currently + /// packaged runtime. + /// + public string RequireAgentNodePath(string packagedArchivePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagedArchivePath); + + SetupStateResult setup = SetupState.Read(ApplicationId); + SetupRecord? record = setup.Record; + if (record?.AgentNodePath is not { Length: > 0 } executablePath || + !string.Equals( + record.AgentNodeArchive, + Path.GetFileName(packagedArchivePath), + StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session runtime does not match this package. " + + "Run `clawctl setup` again."); + } + + return executablePath; + } + /// Records a completed guest runtime installation. public void CompleteSetup( SessionRecord session, diff --git a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs index a0e6771c..e67f5471 100644 --- a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs +++ b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs @@ -72,6 +72,7 @@ public int Run(SessionLaunchRequest request) { startInfo.Environment[name] = value; } + PrependPath(startInfo, Path.GetDirectoryName(request.Executable)); using Process process = new() { StartInfo = startInfo }; try diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs index 9eda17d4..1dbe5789 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs @@ -133,13 +133,13 @@ public void DisposingTwiceIsSafe() } [Fact] - public void NameIsScopedToTheCurrentUserSession() + public void NameIsScopedAcrossWindowsSessions() { var sessionLock = new NamedSessionLock("PFN:OpenClaw.Gateway_abc123"); - Assert.StartsWith("Local\\", sessionLock.Name, StringComparison.Ordinal); - Assert.DoesNotContain(':', sessionLock.Name[6..]); - Assert.DoesNotContain('\\', sessionLock.Name[6..]); + Assert.StartsWith("Global\\", sessionLock.Name, StringComparison.Ordinal); + Assert.DoesNotContain(':', sessionLock.Name[7..]); + Assert.DoesNotContain('\\', sessionLock.Name[7..]); } [Fact] diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs index d32f48e7..26389a77 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs @@ -44,7 +44,7 @@ private SessionRuntimeInstallResult Run(string archivePath) })); int exitCode = SessionRuntimeInstaller.Run( - RequestPath, File.ReadAllText, File.WriteAllText); + RequestPath, File.ReadAllText, File.WriteAllText, () => _root); Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); return SessionRuntimeProtocol.ReadResult( From e5f5960f93d03850a2b1331824643573515639bd Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 09:57:50 -0700 Subject: [PATCH 07/10] Persist the agent Node runtime directory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs index 4ca2eb1e..4bb1bc26 100644 --- a/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs +++ b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs @@ -26,7 +26,8 @@ public static int Run( string requestPath, Func readFile, Action writeFile, - Func? getLocalApplicationData = null) + Func? getLocalApplicationData = null, + Func? tryPrependUserPath = null) { string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); string? requestId = null; @@ -87,7 +88,11 @@ public static int Run( "The installed Node.js runtime did not contain node.exe."); } - bool pathUpdated = request.UpdateUserPath && TryPrependUserPath(directory); + string runtimeDirectory = Path.GetDirectoryName(executablePath) + ?? throw new SessionLaunchException( + "The installed Node.js runtime has no executable directory."); + bool pathUpdated = request.UpdateUserPath && + (tryPrependUserPath ?? TryPrependUserPath)(runtimeDirectory); writeFile( resultPath, From f08251455635c4af2bb17af7069f20f9df46cba7 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 12:20:24 -0700 Subject: [PATCH 08/10] Reject a sandbox issued to another application Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Mxc/MxcSessionContracts.cs | 81 +++++++++++++++++++ .../Session/SessionStateStore.cs | 19 ++++- .../Session/SessionStateStoreTests.cs | 63 +++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs index 84f265e1..50a1fd33 100644 --- a/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs +++ b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs @@ -1,3 +1,6 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + namespace OpenClaw.Launcher.Mxc; internal sealed record MxcBackendProbe( @@ -65,6 +68,84 @@ public static MxcSandboxId Parse(string value) return new MxcSandboxId(value, value[..separator]); } + /// + /// Reports the application this identity was issued to, when the backend + /// encodes one. + /// + /// + /// + /// The value stays opaque to this package: it is replayed verbatim and is + /// never rebuilt from parts. This reads the owning application only so a + /// caller can refuse an identity that provably belongs to someone else + /// before asking the backend to act on it. A record naming this + /// installation does not establish that the identity inside it does. + /// + /// + /// False is returned whenever the payload cannot be read, including a + /// backend or format this package does not recognize. Absence of evidence + /// is not evidence of a foreign owner, and inventing a failure here would + /// strand every existing session the moment the backend changed its + /// encoding. + /// + /// + public bool TryGetOwningApplicationId([NotNullWhen(true)] out string? applicationId) + { + applicationId = null; + if (!IsIsolationSession) + { + return false; + } + + int separator = Value.IndexOf(':', StringComparison.Ordinal); + string payload = Value[(separator + 1)..]; + if (payload.Length == 0) + { + return false; + } + + // The payload is base64url without padding. + string normalized = payload.Replace('-', '+').Replace('_', '/'); + normalized = (normalized.Length % 4) switch + { + 2 => normalized + "==", + 3 => normalized + "=", + 0 => normalized, + _ => string.Empty + }; + if (normalized.Length == 0) + { + return false; + } + + byte[] decoded; + try + { + decoded = Convert.FromBase64String(normalized); + } + catch (FormatException) + { + return false; + } + + try + { + using JsonDocument document = JsonDocument.Parse(decoded); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("appId", out JsonElement appId) || + appId.ValueKind != JsonValueKind.String) + { + return false; + } + + applicationId = appId.GetString(); + return !string.IsNullOrWhiteSpace(applicationId); + } + catch (JsonException) + { + return false; + } + } + public override string ToString() => Value; } diff --git a/src/OpenClaw.Launcher/Session/SessionStateStore.cs b/src/OpenClaw.Launcher/Session/SessionStateStore.cs index 4cbbfa3d..b7b0f5a3 100644 --- a/src/OpenClaw.Launcher/Session/SessionStateStore.cs +++ b/src/OpenClaw.Launcher/Session/SessionStateStore.cs @@ -226,9 +226,10 @@ record = JsonSerializer.Deserialize( $"installation is '{expectedApplicationId}'."); } + MxcSandboxId sandboxId; try { - _ = MxcSandboxId.Parse(record.SandboxId); + sandboxId = MxcSandboxId.Parse(record.SandboxId); } catch (MxcException exception) { @@ -238,6 +239,22 @@ record = JsonSerializer.Deserialize( $"{exception.Message}"); } + // The record naming this installation does not establish that the + // identity inside it does. This file is writable by the signed-in user, + // so a substituted sandbox id would otherwise be replayed to the + // backend on the caller's authority. + if (sandboxId.TryGetOwningApplicationId(out string? owningApplicationId) && + !string.Equals( + owningApplicationId, + expectedApplicationId, + StringComparison.OrdinalIgnoreCase)) + { + return SessionStateResult.Failed( + SessionStateFault.ForeignIdentity, + $"The recorded sandbox was issued to '{owningApplicationId}', but " + + $"this installation is '{expectedApplicationId}'."); + } + return SessionStateResult.Found(record); } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs index a6b85135..a36ce974 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs @@ -275,4 +275,67 @@ public void PersistedFileIsReadableJson() SandboxId, document.RootElement.GetProperty("sandboxId").GetString()); } + + // The record file is writable by the signed-in user. Substituting only the + // sandbox id leaves every other identity field intact, so the record's own + // application check still passes and the forged id would otherwise be + // replayed to the backend on this installation's authority. + [Fact] + public void ASandboxIssuedToAnotherApplicationIsForeign() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + Rewrite(store, SandboxIdFor("PFN:Attacker.App_zzzzzzzzzzzzz")); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.False(result.HasRecord); + Assert.Equal(SessionStateFault.ForeignIdentity, result.Fault); + } + + [Fact] + public void ASandboxIssuedToThisApplicationIsUsable() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + Rewrite(store, SandboxIdFor(ApplicationId)); + + Assert.True(store.Read(ApplicationId).HasRecord); + } + + // The identity stays opaque. A payload this package cannot read is not + // evidence of a foreign owner, and rejecting it would strand every existing + // session the moment the backend changed its encoding. + [Theory] + [InlineData("iso:AAAAbbbbCCCC")] + [InlineData("iso:!!!not-base64!!!")] + [InlineData("iso:eyJ2ZXJzaW9uIjoxfQ")] + public void AnUnreadableSandboxPayloadIsNotTreatedAsForeign(string sandboxId) + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + Rewrite(store, sandboxId); + + Assert.True(store.Read(ApplicationId).HasRecord); + } + + private void Rewrite(SessionStateStore store, string sandboxId) + { + _ = store; + string text = File.ReadAllText(StatePath); + using JsonDocument document = JsonDocument.Parse(text); + Dictionary fields = document.RootElement + .EnumerateObject() + .ToDictionary(property => property.Name, property => property.Value.Clone()); + fields["sandboxId"] = JsonSerializer.SerializeToElement(sandboxId); + File.WriteAllText(StatePath, JsonSerializer.Serialize(fields)); + } + + private static string SandboxIdFor(string applicationId) => + "iso:" + Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes( + $"{{\"version\":1,\"agentUserName\":\"F4-F8\",\"appId\":\"{applicationId}\"}}")) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); } From 7aef6041adbeae0bcc0c3266efa559438736667a Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 13:49:35 -0700 Subject: [PATCH 09/10] Refuse invalid isolated session state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Session/SessionCoordinator.cs | 20 +++++++ .../Session/SessionRuntime.cs | 17 ++++-- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 54 ++++++++++++++++++- .../Session/SessionRuntimeTests.cs | 3 +- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Launcher/Session/SessionCoordinator.cs b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs index b2d16af1..3a395e7d 100644 --- a/src/OpenClaw.Launcher/Session/SessionCoordinator.cs +++ b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs @@ -23,6 +23,26 @@ public SessionException(string message, Exception innerException) } } +/// +/// The current installation or machine cannot host an isolated session. +/// +internal sealed class SessionCapabilityUnavailableException : SessionException +{ + public SessionCapabilityUnavailableException() + { + } + + public SessionCapabilityUnavailableException(string message) + : base(message) + { + } + + public SessionCapabilityUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} + /// /// The recorded session exists but cannot be used, and replacing it silently /// would risk abandoning a live backend session. diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index eacb6490..345d34a1 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -65,7 +65,14 @@ public async Task StartForExecutionAsync(CancellationToken cancel { using ISessionLockHandle handle = AcquireLifecycleLock(); RequireSetup(); - return await Coordinator.StartRecordedAsync(cancellationToken).ConfigureAwait(false); + try + { + return await Coordinator.StartRecordedAsync(cancellationToken).ConfigureAwait(false); + } + catch (MxcException exception) when (exception.Code == MxcErrorCode.RuntimeUnavailable) + { + throw new SessionCapabilityUnavailableException(exception.Message, exception); + } } public string StageHelper(SessionRecord record) @@ -103,7 +110,7 @@ internal static SessionRuntime Create( if (paths.PackageFamilyName is null) { - throw new SessionException( + throw new SessionCapabilityUnavailableException( "OpenClaw is not running from its installed package, so it " + "has no identity to provision an isolated session with."); } @@ -168,8 +175,10 @@ public SessionRecord RequireSetup() if (session.Record is null) { throw new SessionException( - "OpenClaw setup is incomplete because its isolated session is " + - "not recorded. Run `clawctl setup` again."); + session.Detail is null + ? "OpenClaw setup is incomplete because its isolated session is " + + "not recorded. Run `clawctl setup` again." + : $"{session.Detail} Run `clawctl setup` again."); } if (setup.Record.SandboxId is { } sandboxId && diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 4921feb4..3802c389 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -9,6 +9,7 @@ public sealed class ProgramTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); private FakeMxcSessionClient? _lastSessionBackend; + private string? _sessionStatePath; [Fact] public async Task AgentLaunchResolvesNodeAndRunsPackagedApplication() @@ -364,11 +365,62 @@ private SessionRuntime CreateSessionRuntime() return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); }; _lastSessionBackend = backend; + var paths = HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"); + _sessionStatePath = paths.SessionStatePath; return SessionRuntime.Create( - HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + paths, () => throw new InvalidOperationException("The test supplies its backend."), baseDirectory, _ => { }, backend); } + + private async Task SetUpSessionAsync() + { + SessionRuntime runtime = CreateSessionRuntime(); + string applicationDirectory = Path.Combine(_testDirectory, "app"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');").ConfigureAwait(false); + await File.WriteAllTextAsync(archivePath, "fixture").ConfigureAwait(false); + int exitCode = await Program.RunControlAsync( + new HostOptions(applicationDirectory, archivePath, []), + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + _ => Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), + () => runtime).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + return runtime; + } + + private static string SandboxIdFor(string applicationId) => + $"iso:{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( + $"{{\"appId\":\"{applicationId}\"}}")) + .TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + + private static Task RunAgentWithDirectLaunchProbeAsync( + SessionRuntime runtime, + List directLaunches) + { + ArgumentNullException.ThrowIfNull(directLaunches); + + return Program.RunAgentAsync( + new HostOptions(null, null, ["--version"]), + _ => { }, + _ => throw new InvalidOperationException("Host Node must not be resolved."), + (_, _, _, _, _) => + { + directLaunches.Add("direct"); + return Task.FromResult(0); + }, + _ => runtime); + } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs index a496ea7d..8a0f8279 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs @@ -42,7 +42,8 @@ public void StatusDoesNotRequireTheMxcRuntimeToBeInstalled() [Fact] public void UnpackagedExecutionIsRefusedWithAnExplicitReason() { - SessionException failure = Assert.Throws( + SessionCapabilityUnavailableException failure = + Assert.Throws( () => SessionRuntime.Create( HostPaths.ForRoot(_root, packageFamilyName: null), () => throw new InvalidOperationException("not reached"), From 47c7de26076bd91f9d1b8941e4658e479f067ea1 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 18:41:47 -0700 Subject: [PATCH 10/10] Update lifecycle launch test seam Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/Program.cs | 36 +- .../Session/SessionHelperStager.cs | 89 +-- .../Session/SessionRuntime.cs | 13 + .../Session/SessionWorkspaceOperation.cs | 28 +- src/OpenClaw.Launcher/Session/TrustedPath.cs | 507 +++++++++++++++--- .../SessionRuntimeInstaller.cs | 85 ++- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 43 +- .../Session/SessionHelperStagerTests.cs | 30 ++ .../Session/SessionRuntimeInstallerTests.cs | 53 +- 9 files changed, 755 insertions(+), 129 deletions(-) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 72a2a2fa..556c1833 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -157,7 +157,9 @@ internal static async Task RunAgentAsync( Func> resolveNode, LaunchOpenClawAsync launchOpenClaw, Func, Session.SessionRuntime>? createSessionRuntime = null, - Func? isInteractive = null) + Func? isInteractive = null, + Func>? probeReadiness = null, + Func? getPackageFamilyName = null) { Session.SessionMode mode = Session.SessionRoutingPolicy.ReadMode( Environment.GetEnvironmentVariable); @@ -166,11 +168,34 @@ internal static async Task RunAgentAsync( return await RunDirectAsync().ConfigureAwait(false); } - Session.SessionRuntime runtime; + Session.SessionRuntime? runtime = null; + if (createSessionRuntime is null || probeReadiness is not null) + { + Mxc.MxcReadinessReport readiness = await (probeReadiness ?? + Mxc.MxcReadiness.ProbeAsync)(CancellationToken.None).ConfigureAwait(false); + string? packageFamilyName = + (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(); + Session.SessionRoutingDecision routing = Session.SessionRoutingPolicy.Decide( + mode, + packageFamilyName, + readiness); + log(routing.Reason); + if (routing.Routing == Session.SessionRouting.Direct) + { + if (packageFamilyName is not null) + { + runtime = (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + runtime.ValidateSavedOwnershipForHostFallback(); + } + + return await RunDirectAsync().ConfigureAwait(false); + } + } + Session.SessionRecord record; try { - runtime = (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + runtime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); record = await runtime.StartForExecutionAsync(CancellationToken.None) .ConfigureAwait(false); } @@ -303,6 +328,11 @@ await output.WriteLineAsync( .Coordinator.GetRecordedStatus(); await output.WriteLineAsync(status.Availability.ToString()) .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(status.Detail)) + { + await output.WriteLineAsync(status.Detail) + .ConfigureAwait(false); + } return 0; }, Teardown = async (force, cancellationToken) => diff --git a/src/OpenClaw.Launcher/Session/SessionHelperStager.cs b/src/OpenClaw.Launcher/Session/SessionHelperStager.cs index 5d4e558f..062405fa 100644 --- a/src/OpenClaw.Launcher/Session/SessionHelperStager.cs +++ b/src/OpenClaw.Launcher/Session/SessionHelperStager.cs @@ -18,24 +18,16 @@ internal static class SessionHelperStager public static string Stage(string packagedHelperPath, string workspacePath) { string source = RequirePackagedHelper(packagedHelperPath); - var stagingRecord = new SessionRecord - { - SandboxId = "iso:helper-staging", - ApplicationId = "helper-staging", - WorkspacePath = workspacePath, - Generation = typeof(SessionHelperStager).Assembly - .GetName() - .Version? - .ToString() ?? "unknown" - }; - using var operation = new SessionWorkspaceOperation(stagingRecord, _ => true); + using SessionWorkspaceOperation operation = CreateOperation(workspacePath); string destination = ResolveStagedPath(operation.WorkspacePath); - var sourceInfo = new FileInfo(source); - if (File.Exists(destination) && - FilesMatch(source, destination, sourceInfo.Length)) + if (File.Exists(destination)) { - return destination; + using FileStream existing = operation.OpenRead(destination); + if (FilesMatch(source, existing)) + { + return destination; + } } string? destinationDirectory = Path.GetDirectoryName(destination); @@ -46,11 +38,12 @@ public static string Stage(string packagedHelperPath, string workspacePath) } operation.EnsureDirectory(destinationDirectory); - string temporaryPath = destination + $".{Guid.NewGuid():N}.tmp"; try { - File.Copy(source, temporaryPath, overwrite: false); - File.Move(temporaryPath, destination, overwrite: true); + operation.Delete(destination); + using FileStream input = File.OpenRead(source); + using Stream output = operation.CreateNew(destination); + input.CopyTo(output); } catch (Exception exception) when ( exception is IOException or UnauthorizedAccessException) @@ -60,10 +53,6 @@ public static string Stage(string packagedHelperPath, string workspacePath) $"shared workspace: {exception.Message}", exception); } - finally - { - TryDelete(temporaryPath); - } return destination; } @@ -72,13 +61,26 @@ public static string RequireStaged( string packagedHelperPath, string workspacePath) { - _ = RequirePackagedHelper(packagedHelperPath); - string path = ResolveStagedPath(workspacePath); - return File.Exists(path) - ? path - : throw new SessionException( + string source = RequirePackagedHelper(packagedHelperPath); + try + { + using SessionWorkspaceOperation operation = CreateOperation(workspacePath); + string path = ResolveStagedPath(operation.WorkspacePath); + using FileStream staged = operation.OpenRead(path); + return FilesMatch(source, staged) + ? path + : throw new SessionException( + "The isolated-session helper does not match this package version. " + + "Run `clawctl setup` again."); + } + catch (Exception exception) when ( + exception is FileNotFoundException or SessionException) + { + throw new SessionException( "The isolated-session helper has not been staged for this " + - "package version. Run `clawctl setup` again."); + "package version. Run `clawctl setup` again.", + exception); + } } internal static string ResolveStagedPath(string workspacePath) @@ -108,21 +110,24 @@ internal static string RequirePackagedHelper(string packagedHelperPath) $"The packaged session helper is missing: {path}"); } - private static bool FilesMatch(string source, string destination, long sourceLength) => - new FileInfo(destination).Length == sourceLength && - CryptographicOperations.FixedTimeEquals( - SHA256.HashData(File.ReadAllBytes(source)), - SHA256.HashData(File.ReadAllBytes(destination))); - - private static void TryDelete(string path) + private static SessionWorkspaceOperation CreateOperation(string workspacePath) { - try - { - File.Delete(path); - } - catch (Exception exception) when ( - exception is IOException or UnauthorizedAccessException) + var stagingRecord = new SessionRecord { - } + SandboxId = "iso:helper-staging", + ApplicationId = "helper-staging", + WorkspacePath = workspacePath, + Generation = typeof(SessionHelperStager).Assembly + .GetName() + .Version? + .ToString() ?? "unknown" + }; + return new SessionWorkspaceOperation(stagingRecord, _ => true); } + + private static bool FilesMatch(string source, Stream destination) => + new FileInfo(source).Length == destination.Length && + CryptographicOperations.FixedTimeEquals( + SHA256.HashData(File.ReadAllBytes(source)), + SHA256.HashData(destination)); } diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 345d34a1..b091f923 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -191,6 +191,19 @@ session.Detail is null return session.Record; } + public void ValidateSavedOwnershipForHostFallback() + { + SetupStateResult setup = SetupState.Read(ApplicationId); + SessionStatus session = Coordinator.GetRecordedStatus(); + if (setup.Fault == SetupStateFault.Missing && + session.Availability == SessionAvailability.None) + { + return; + } + + RequireSetup(); + } + /// /// Returns the Node.js executable extracted by the agent for the currently /// packaged runtime. diff --git a/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs b/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs index 27cdc451..40e29bef 100644 --- a/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs +++ b/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs @@ -43,7 +43,15 @@ public SessionWorkspaceOperation( _workspace = TrustedPath.TryOpenValidatedDirectory(record.WorkspacePath, expectedIdentity: null) ?? throw new SessionException( $"The recorded shared workspace could not be opened safely: {record.WorkspacePath}"); - EnsureCurrent(); + try + { + EnsureCurrent(); + } + catch + { + _workspace.Dispose(); + throw; + } } public string WorkspacePath => _workspace.FinalPath; @@ -69,7 +77,7 @@ public async Task WriteTextNewAsync( CancellationToken cancellationToken) { EnsureCurrent(); - using FileStream stream = TrustedPath.CreateNew(_workspace, path); + using Stream stream = TrustedPath.CreateNew(_workspace, path); byte[] bytes = Encoding.UTF8.GetBytes(text); await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); } @@ -78,12 +86,23 @@ public async Task ReadTextAsync( string path, CancellationToken cancellationToken) { - EnsureCurrent(); - using FileStream stream = TrustedPath.OpenRead(WorkspacePath, path); + using FileStream stream = OpenRead(path); using var reader = new StreamReader(stream, Encoding.UTF8); return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); } + public FileStream OpenRead(string path) + { + EnsureCurrent(); + return TrustedPath.OpenRead(WorkspacePath, path); + } + + public Stream CreateNew(string path) + { + EnsureCurrent(); + return TrustedPath.CreateNew(_workspace, path); + } + public void EnsureDirectory(string path) { EnsureCurrent(); @@ -92,6 +111,7 @@ public void EnsureDirectory(string path) public void Delete(string path) { + EnsureCurrent(); try { _ = TrustedPath.TryDeleteOwnedEntry( diff --git a/src/OpenClaw.Launcher/Session/TrustedPath.cs b/src/OpenClaw.Launcher/Session/TrustedPath.cs index 1a6f7151..9ce1b112 100644 --- a/src/OpenClaw.Launcher/Session/TrustedPath.cs +++ b/src/OpenClaw.Launcher/Session/TrustedPath.cs @@ -33,6 +33,77 @@ internal ValidatedDirectory( public void Dispose() => Handle.Dispose(); } + private sealed class ProtectedDirectoryChain : IDisposable + { + private readonly List _directories = []; + + public ValidatedDirectory Leaf => _directories[^1]; + + public void Add(ValidatedDirectory directory) => _directories.Add(directory); + + public void Dispose() + { + for (int index = _directories.Count - 1; index >= 0; index--) + { + _directories[index].Dispose(); + } + } + } + + private sealed class ProtectedStream( + FileStream stream, + ProtectedDirectoryChain directories) : Stream + { + public override bool CanRead => stream.CanRead; + + public override bool CanSeek => stream.CanSeek; + + public override bool CanWrite => stream.CanWrite; + + public override long Length => stream.Length; + + public override long Position + { + get => stream.Position; + set => stream.Position = value; + } + + public override void Flush() => stream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + stream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => + stream.Seek(offset, origin); + + public override void SetLength(long value) => stream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => + stream.Write(buffer, offset, count); + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) => + stream.WriteAsync(buffer, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + stream.Dispose(); + directories.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await stream.DisposeAsync().ConfigureAwait(false); + directories.Dispose(); + await base.DisposeAsync().ConfigureAwait(false); + } + } public static void EnsureNoReparsePoints(string trustedRoot, string candidatePath) => EnsureNoReparsePoints(trustedRoot, candidatePath, File.GetAttributes); @@ -114,10 +185,18 @@ public static FileStream OpenRead(string trustedRoot, string candidatePath) IntPtr.Zero); if (handle.IsInvalid) { + int error = Marshal.GetLastWin32Error(); handle.Dispose(); + if (error is ErrorFileNotFound or ErrorPathNotFound) + { + throw new FileNotFoundException( + $"The trusted file does not exist: {candidatePath}", + candidatePath); + } + throw new IOException( $"The trusted file could not be opened: {candidatePath}", - new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error())); + new System.ComponentModel.Win32Exception(error)); } try @@ -153,41 +232,66 @@ public static FileStream OpenRead(string trustedRoot, string candidatePath) "Reliability", "CA2000:Dispose objects before losing scope", Justification = "The FileStream constructor takes ownership of the SafeFileHandle.")] - internal static FileStream CreateNew(ValidatedDirectory root, string candidatePath) + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The returned protected stream owns both the file and directory-chain handles.")] + internal static Stream CreateNew(ValidatedDirectory root, string candidatePath) { ArgumentNullException.ThrowIfNull(root); - ValidateParent(root, candidatePath); - - SafeFileHandle handle = CreateFile( - candidatePath, - GenericWrite | FileReadAttributes, - 0, - IntPtr.Zero, - CreateNewDisposition, - FileFlagOpenReparsePoint | FileFlagOverlapped, - IntPtr.Zero); - if (handle.IsInvalid) + string? parent = Path.GetDirectoryName(Path.GetFullPath(candidatePath)); + if (string.IsNullOrWhiteSpace(parent)) { - handle.Dispose(); - throw new IOException( - $"The trusted file could not be created: {candidatePath}", - new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error())); + throw new SessionException( + $"The trusted file path has no parent directory: {candidatePath}"); } + ProtectedDirectoryChain protectedParent = ProtectDirectoryChain( + root, + parent, + createMissing: false); + try { - if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0 || - !IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) + SafeFileHandle handle = CreateRelative( + protectedParent.Leaf.Handle, + Path.GetFileName(candidatePath), + GenericWrite | FileReadAttributes, + shareAccess: 0, + FileCreate, + FileNonDirectoryFile | FileOpenReparsePoint); + + FileStream? stream = null; + try { - throw new SessionException( - $"The created file resolves outside its trusted root: {candidatePath}"); + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0 || + !IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) + { + throw new SessionException( + $"The created file resolves outside its trusted root: {candidatePath}"); + } + + stream = new FileStream( + handle, + FileAccess.Write, + bufferSize: 4096, + isAsync: true); + return new ProtectedStream(stream, protectedParent); } + catch + { + stream?.Dispose(); + if (stream is null) + { + handle.Dispose(); + } - return new FileStream(handle, FileAccess.Write, bufferSize: 4096, isAsync: true); + throw; + } } catch { - handle.Dispose(); + protectedParent.Dispose(); throw; } } @@ -209,23 +313,10 @@ internal static void EnsureDirectory(ValidatedDirectory root, string directoryPa $"The directory resolves outside its trusted root: {target}"); } - string current = root.FinalPath; - foreach (string segment in relative.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries)) - { - current = Path.Combine(current, segment); - Directory.CreateDirectory(current); - using ValidatedDirectory directory = TryOpenValidatedDirectory( - current, - expectedIdentity: null) ?? throw new SessionException( - $"The directory resolves outside its trusted root: {current}"); - if (!IsBelowOrEqualRoot(root.FinalPath, directory.FinalPath)) - { - throw new SessionException( - $"The directory resolves outside its trusted root: {current}"); - } - } + using ProtectedDirectoryChain protectedDirectories = ProtectDirectoryChain( + root, + target, + createMissing: true); } internal static FileIdentity? TryGetDirectoryIdentity(string path) @@ -240,8 +331,8 @@ internal static void EnsureDirectory(ValidatedDirectory root, string directoryPa { SafeFileHandle handle = CreateFile( path, - Delete | FileReadAttributes, - FileShareRead | FileShareWrite | FileShareDelete, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, IntPtr.Zero, OpenExisting, FileFlagBackupSemantics | FileFlagOpenReparsePoint, @@ -276,68 +367,291 @@ internal static void EnsureDirectory(ValidatedDirectory root, string directoryPa } } + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The relative handle is immediately owned by the using statement below.")] internal static bool TryDeleteOwnedEntry( ValidatedDirectory root, string candidatePath, bool deleteReparsePointLeaf = false) { - ValidateParent(root, candidatePath); + string? parent = Path.GetDirectoryName(Path.GetFullPath(candidatePath)); + if (string.IsNullOrWhiteSpace(parent)) + { + return false; + } - SafeFileHandle handle = CreateFile( - candidatePath, - Delete | FileReadAttributes, - FileShareRead | FileShareWrite | FileShareDelete, - IntPtr.Zero, - OpenExisting, - FileFlagBackupSemantics | FileFlagOpenReparsePoint, - IntPtr.Zero); - if (handle.IsInvalid) + using ProtectedDirectoryChain protectedParent = ProtectDirectoryChain( + root, + parent, + createMissing: false); + + string name = Path.GetFileName(candidatePath); + SafeFileHandle handle; + try + { + handle = CreateRelative( + protectedParent.Leaf.Handle, + name, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + FileOpen, + FileOpenReparsePoint); + } + catch (IOException) { - handle.Dispose(); return false; } + FileAttributes attributes; using (handle) { - FileAttributes attributes = File.GetAttributes(handle); + attributes = File.GetAttributes(handle); if ((attributes & FileAttributes.ReparsePoint) != 0) { - return deleteReparsePointLeaf && MarkForDeletion(handle); + if (!deleteReparsePointLeaf) + { + return false; + } } - - if (!IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) + else if (!IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) { return false; } - if ((attributes & FileAttributes.Directory) != 0) + if ((attributes & FileAttributes.Directory) != 0 && + (attributes & FileAttributes.ReparsePoint) == 0) { foreach (string child in Directory.EnumerateFileSystemEntries(candidatePath)) { TryDeleteOwnedEntry(root, child, deleteReparsePointLeaf); } } + } - return MarkForDeletion(handle); + try + { + using SafeFileHandle deleteHandle = CreateRelative( + protectedParent.Leaf.Handle, + name, + Delete | FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + FileOpen, + FileOpenReparsePoint); + return MarkForDeletion(deleteHandle); + } + catch (IOException) + { + return false; } } - private static void ValidateParent(ValidatedDirectory root, string candidatePath) + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Each validated directory is transferred to the returned chain.")] + private static ProtectedDirectoryChain ProtectDirectoryChain( + ValidatedDirectory root, + string directoryPath, + bool createMissing) { - string? parent = Path.GetDirectoryName(Path.GetFullPath(candidatePath)); - if (string.IsNullOrWhiteSpace(parent)) + string target = Path.GetFullPath(directoryPath); + string relative = Path.GetRelativePath(root.FinalPath, target); + if (relative.StartsWith("..", StringComparison.Ordinal) || + Path.IsPathRooted(relative)) { throw new SessionException( - $"The trusted file path has no parent directory: {candidatePath}"); + $"The directory resolves outside its trusted root: {target}"); } - using ValidatedDirectory directory = TryOpenValidatedDirectory(parent, expectedIdentity: null) - ?? throw new SessionException( - $"The trusted file parent resolves outside its trusted root: {parent}"); - if (!IsBelowOrEqualRoot(root.FinalPath, directory.FinalPath)) + ProtectedDirectoryChain chain = ProtectRoot(root); + try { - throw new SessionException( - $"The trusted file parent resolves outside its trusted root: {parent}"); + if (relative == ".") + { + return chain; + } + + string current = root.FinalPath; + foreach (string segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + ValidatedDirectory directory = OpenRelativeDirectory( + chain.Leaf.Handle, + segment, + createMissing); + if (!IsBelowOrEqualRoot(root.FinalPath, directory.FinalPath)) + { + directory.Dispose(); + throw new SessionException( + $"The directory resolves outside its trusted root: {current}"); + } + + chain.Add(directory); + } + + return chain; + } + catch + { + chain.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The returned chain owns the validated root directory.")] + private static ProtectedDirectoryChain ProtectRoot(ValidatedDirectory root) + { + var chain = new ProtectedDirectoryChain(); + ValidatedDirectory protectedRoot = TryOpenProtectedDirectory( + root.FinalPath, + root.Identity) ?? throw new SessionException( + $"The trusted root changed before the host operation: {root.FinalPath}"); + chain.Add(protectedRoot); + return chain; + } + + private static ValidatedDirectory? TryOpenProtectedDirectory( + string path, + FileIdentity? expectedIdentity) + { + SafeFileHandle handle = CreateFile( + path, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + return null; + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + handle.Dispose(); + return null; + } + + FileIdentity identity = GetIdentity(handle); + if (expectedIdentity is not null && identity != expectedIdentity) + { + handle.Dispose(); + return null; + } + + return new ValidatedDirectory(handle, identity, NormalizePath(GetFinalPath(handle))); + } + catch + { + handle.Dispose(); + throw; + } + } + + private static ValidatedDirectory OpenRelativeDirectory( + SafeFileHandle parent, + string name, + bool createMissing) + { + SafeFileHandle handle = CreateRelative( + parent, + name, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + createMissing ? FileOpenIf : FileOpen, + FileDirectoryFile | FileOpenReparsePoint); + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + $"The directory contains a reparse point: {name}"); + } + + return new ValidatedDirectory( + handle, + GetIdentity(handle), + NormalizePath(GetFinalPath(handle))); + } + catch + { + handle.Dispose(); + throw; + } + } + + private static SafeFileHandle CreateRelative( + SafeFileHandle parent, + string name, + uint desiredAccess, + uint shareAccess, + uint createDisposition, + uint createOptions) + { + IntPtr nameBuffer = Marshal.StringToHGlobalUni(name); + try + { + var unicodeName = new UnicodeString + { + Length = checked((ushort)(name.Length * sizeof(char))), + MaximumLength = checked((ushort)((name.Length + 1) * sizeof(char))), + Buffer = nameBuffer + }; + IntPtr unicodeNamePointer = Marshal.AllocHGlobal( + Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(unicodeName, unicodeNamePointer, fDeleteOld: false); + var attributes = new ObjectAttributes + { + Length = Marshal.SizeOf(), + RootDirectory = parent.DangerousGetHandle(), + ObjectName = unicodeNamePointer, + Attributes = ObjectCaseInsensitive + }; + uint status = NtCreateFile( + out SafeFileHandle handle, + desiredAccess, + ref attributes, + out _, + IntPtr.Zero, + FileAttributeNormal, + shareAccess, + createDisposition, + createOptions, + IntPtr.Zero, + 0); + GC.KeepAlive(parent); + if (unchecked((int)status) < 0) + { + handle.Dispose(); + int error = checked((int)RtlNtStatusToDosError(status)); + throw new IOException( + $"The trusted relative path could not be opened: {name}", + new System.ComponentModel.Win32Exception(error)); + } + + return handle; + } + finally + { + Marshal.FreeHGlobal(unicodeNamePointer); + } + } + finally + { + Marshal.FreeHGlobal(nameBuffer); } } @@ -410,11 +724,19 @@ private static bool IsBelowOrEqualRoot(string root, string candidate) => private const uint FileShareRead = 0x00000001; private const uint FileShareWrite = 0x00000002; private const uint FileShareDelete = 0x00000004; + private const int ErrorFileNotFound = 2; + private const int ErrorPathNotFound = 3; private const uint OpenExisting = 3; - private const uint CreateNewDisposition = 1; + private const uint FileAttributeNormal = 0x00000080; + private const uint FileOpen = 1; + private const uint FileCreate = 2; + private const uint FileOpenIf = 3; + private const uint FileDirectoryFile = 0x00000001; + private const uint FileNonDirectoryFile = 0x00000040; + private const uint FileOpenReparsePoint = 0x00200000; + private const uint ObjectCaseInsensitive = 0x00000040; private const uint FileFlagBackupSemantics = 0x02000000; private const uint FileFlagOpenReparsePoint = 0x00200000; - private const uint FileFlagOverlapped = 0x40000000; private const int FileDispositionInfo = 4; [StructLayout(LayoutKind.Sequential)] @@ -442,6 +764,32 @@ private struct ByHandleFileInformation public uint FileIndexLow; } + [StructLayout(LayoutKind.Sequential)] + private struct UnicodeString + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ObjectAttributes + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoStatusBlock + { + public IntPtr Status; + public nuint Information; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern SafeFileHandle CreateFile( string fileName, @@ -452,6 +800,23 @@ private static extern SafeFileHandle CreateFile( uint flagsAndAttributes, IntPtr templateFile); + [DllImport("ntdll.dll")] + private static extern uint NtCreateFile( + out SafeFileHandle fileHandle, + uint desiredAccess, + ref ObjectAttributes objectAttributes, + out IoStatusBlock ioStatusBlock, + IntPtr allocationSize, + uint fileAttributes, + uint shareAccess, + uint createDisposition, + uint createOptions, + IntPtr eaBuffer, + uint eaLength); + + [DllImport("ntdll.dll")] + private static extern uint RtlNtStatusToDosError(uint status); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern uint GetFinalPathNameByHandle( SafeFileHandle file, diff --git a/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs index 4bb1bc26..1a54a9dd 100644 --- a/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs +++ b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs @@ -1,4 +1,6 @@ using System.IO.Compression; +using System.Diagnostics; +using System.Runtime.InteropServices; using Microsoft.Win32; using OpenClaw.SessionProtocol; @@ -22,12 +24,16 @@ namespace OpenClaw.SessionHost; /// internal static class SessionRuntimeInstaller { + private static readonly Guid LocalApplicationDataFolderId = + new("F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); + public static int Run( string requestPath, Func readFile, Action writeFile, Func? getLocalApplicationData = null, - Func? tryPrependUserPath = null) + Func? tryPrependUserPath = null, + Func? getRuntimeVersion = null) { string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); string? requestId = null; @@ -40,8 +46,7 @@ public static int Run( string directory = Path.Combine( (getLocalApplicationData ?? - (() => Environment.GetFolderPath( - Environment.SpecialFolder.LocalApplicationData)))(), + GetLocalApplicationData)(), "OpenClawGatewayMSIX", "agent-node"); string archiveRoot = Path.GetFileNameWithoutExtension(request.ArchivePath!); @@ -49,7 +54,12 @@ public static int Run( directory, archiveRoot, "node.exe"); - if (!File.Exists(executablePath)) + string expectedVersion = GetArchiveVersion(request.ArchivePath!); + if (!File.Exists(executablePath) || + !string.Equals( + (getRuntimeVersion ?? ReadRuntimeVersion)(executablePath), + expectedVersion, + StringComparison.Ordinal)) { string stagingDirectory = Path.Combine( directory, @@ -100,7 +110,7 @@ public static int Run( { RequestId = requestId, ExecutablePath = executablePath, - Version = GetArchiveVersion(request.ArchivePath!), + Version = expectedVersion, ArchiveName = Path.GetFileName(request.ArchivePath!), UserPathUpdated = pathUpdated })); @@ -140,6 +150,64 @@ private static string GetArchiveVersion(string archivePath) $"The packaged Node.js runtime archive has an invalid version: {archiveName}"); } + private static string GetLocalApplicationData() + { + int result = SHGetKnownFolderPath( + LocalApplicationDataFolderId, + flags: 0, + token: IntPtr.Zero, + out IntPtr path); + if (result < 0) + { + throw new SessionLaunchException( + $"Windows could not resolve the local application data directory " + + $"(HRESULT 0x{result:X8})."); + } + + try + { + return Marshal.PtrToStringUni(path) + ?? throw new SessionLaunchException( + "Windows returned an empty local application data directory."); + } + finally + { + Marshal.FreeCoTaskMem(path); + } + } + + private static string? ReadRuntimeVersion(string executablePath) + { + try + { + using Process? process = Process.Start(new ProcessStartInfo + { + FileName = executablePath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "--version" } + }); + if (process is null || !process.WaitForExit(TimeSpan.FromSeconds(10))) + { + process?.Kill(entireProcessTree: true); + return null; + } + + string output = process.StandardOutput.ReadToEnd().Trim(); + return process.ExitCode == 0 + ? output.TrimStart('v') + : null; + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException or IOException or UnauthorizedAccessException) + { + return null; + } + } + /// /// Puts the runtime directory at the front of this account's persistent /// user PATH. @@ -262,4 +330,11 @@ private static void TryWriteFailure( { } } + + [DllImport("shell32.dll")] + private static extern int SHGetKnownFolderPath( + in Guid folderId, + uint flags, + IntPtr token, + out IntPtr path); } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 3802c389..2936d7a1 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -189,6 +189,36 @@ public async Task AgentRefusesForeignSessionRecordWithoutHostFallback() Assert.Empty(directLaunches); } + [Fact] + public async Task AutomaticDirectRoutingValidatesExistingOwnershipFirst() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + SessionRecord record = new SessionStateStore(_sessionStatePath!) + .Read(runtime.ApplicationId).Record!; + new SessionStateStore(_sessionStatePath!).Write(record with + { + SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") + }); + + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(null, null, ["--version"]), + _ => { }, + _ => throw new InvalidOperationException("Host Node must not be resolved."), + (_, _, _, _, _, _) => + { + directLaunches.Add("direct"); + return Task.FromResult(0); + }, + _ => runtime, + probeReadiness: _ => Task.FromResult(UnavailableReadiness()), + getPackageFamilyName: () => "OpenClaw.Gateway_test")); + + Assert.Contains("Some.Other.App", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + [Fact] public async Task AgentRefusesSetupSessionMismatchWithoutHostFallback() { @@ -406,6 +436,17 @@ private static string SandboxIdFor(string applicationId) => $"{{\"appId\":\"{applicationId}\"}}")) .TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + private static MxcReadinessReport UnavailableReadiness() => + new( + RuntimeDirectory: null, + Provenance: null, + RuntimeUnavailableReason: "The runtime is unavailable.", + HostSupport: MxcHostSupport.Supported, + HostBuild: null, + SupportEvidence: MxcSupportEvidence.BackendProbe, + BackendProbe: new MxcBackendProbe(false, "base-container", []), + BackendProbeFailureReason: null); + private static Task RunAgentWithDirectLaunchProbeAsync( SessionRuntime runtime, List directLaunches) @@ -416,7 +457,7 @@ private static Task RunAgentWithDirectLaunchProbeAsync( new HostOptions(null, null, ["--version"]), _ => { }, _ => throw new InvalidOperationException("Host Node must not be resolved."), - (_, _, _, _, _) => + (_, _, _, _, _, _) => { directLaunches.Add("direct"); return Task.FromResult(0); diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs index 40ecbb4d..4757727b 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs @@ -81,4 +81,34 @@ public void MissingPackagedHelperIsReportedBeforeStaging() failure.Message, StringComparison.Ordinal); } + + [Fact] + public void CreatedFileRetainsDirectoryAuthorityUntilTheWriteCompletes() + { + string workspace = Path.Combine(_root, "workspace"); + string stagingDirectory = Path.Combine(workspace, "staging"); + string relocatedDirectory = Path.Combine(_root, "outside", "staging"); + string destination = Path.Combine(stagingDirectory, "helper.exe"); + Directory.CreateDirectory(workspace); + Directory.CreateDirectory(Path.GetDirectoryName(relocatedDirectory)!); + var record = new SessionRecord + { + SandboxId = "iso:test", + ApplicationId = "test", + WorkspacePath = workspace, + Generation = "generation" + }; + using var operation = new SessionWorkspaceOperation(record, _ => true); + operation.EnsureDirectory(stagingDirectory); + + using (Stream output = operation.CreateNew(destination)) + { + _ = Assert.ThrowsAny( + () => Directory.Move(stagingDirectory, relocatedDirectory)); + output.Write("helper bytes"u8); + } + + Assert.False(File.Exists(Path.Combine(relocatedDirectory, "helper.exe"))); + Assert.Equal("helper bytes", File.ReadAllText(destination)); + } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs index 26389a77..7f6333c5 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs @@ -44,7 +44,11 @@ private SessionRuntimeInstallResult Run(string archivePath) })); int exitCode = SessionRuntimeInstaller.Run( - RequestPath, File.ReadAllText, File.WriteAllText, () => _root); + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root, + getRuntimeVersion: ReadFixtureVersion); Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); return SessionRuntimeProtocol.ReadResult( @@ -66,7 +70,8 @@ private SessionRuntimeInstallResult Install(string archivePath) RequestPath, File.ReadAllText, File.WriteAllText, - () => _root); + () => _root, + getRuntimeVersion: ReadFixtureVersion); Assert.Equal(0, exitCode); return SessionRuntimeProtocol.ReadResult( @@ -84,6 +89,9 @@ private string CreateArchive(string version) return archivePath; } + private static string? ReadFixtureVersion(string executablePath) => + File.Exists(executablePath) ? File.ReadAllText(executablePath) : null; + [Fact] public void ReinstallingIntoAnExistingAgentProfileReportsTheCurrentArchiveVersion() { @@ -126,7 +134,8 @@ public void InstallPersistsTheDirectoryContainingNode() { persistedDirectory = directory; return true; - }); + }, + ReadFixtureVersion); Assert.Equal(0, exitCode); Assert.Equal( @@ -155,6 +164,44 @@ public void SameVersionInstallReusesExistingNodeEvenWhenItIsOpen() Assert.Equal("24.20.0", second.Version); } + [Fact] + public void SameVersionInstallRepairsAnInvalidExistingNode() + { + string archivePath = CreateArchive("24.20.0"); + SessionRuntimeInstallResult first = Install(archivePath); + File.WriteAllText(first.ExecutablePath!, "broken"); + + SessionRuntimeInstallResult second = Install(archivePath); + + Assert.Equal(first.ExecutablePath, second.ExecutablePath); + Assert.Equal("24.20.0", File.ReadAllText(second.ExecutablePath!)); + } + + [Fact] + public void ProductionProbeRepairsAnUnlaunchableExistingNode() + { + string archivePath = CreateArchive("24.20.0"); + SessionRuntimeInstallResult first = Install(archivePath); + File.WriteAllText(first.ExecutablePath!, "not an executable"); + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath, + UpdateUserPath = false + })); + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root); + + Assert.Equal(0, exitCode); + Assert.Equal("24.20.0", File.ReadAllText(first.ExecutablePath!)); + } + // A truncated or corrupt archive is a real packaging failure. Left // unhandled it crashes the guest, and the host then reports a lost request // rather than the reason.