From 0dcb7838a753de0e4b42e208600a554589542cec Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:50:17 -0700 Subject: [PATCH 01/23] refactor(setup): add verified native artifact installer Download, verify, extract, and install native archives atomically. Clean stale staging only through no-follow app-owned path validation. Signed-off-by: Joel Fernandes --- .../LocalAiArtifactInstaller.cs | 841 ++++++++++++++++++ 1 file changed, 841 insertions(+) create mode 100644 src/OpenClaw.SetupEngine/LocalAiArtifactInstaller.cs diff --git a/src/OpenClaw.SetupEngine/LocalAiArtifactInstaller.cs b/src/OpenClaw.SetupEngine/LocalAiArtifactInstaller.cs new file mode 100644 index 000000000..2eb20b46d --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiArtifactInstaller.cs @@ -0,0 +1,841 @@ +using System.IO.Compression; +using System.Security.Cryptography; + +namespace OpenClaw.SetupEngine; + +internal sealed record LocalAiPinnedArchive( + string FileName, + Uri DownloadUri, + long SizeBytes, + string Sha256); + +internal sealed record LocalAiVerifiedArchive( + string FileName, + long SizeBytes, + string Sha256); + +internal enum LocalAiArtifactInstallPhase +{ + Downloading, + Verifying, + Extracting, + Promoting, + Complete, +} + +internal enum LocalAiArtifactProgressUnit +{ + None, + Bytes, + Entries, +} + +internal sealed record LocalAiArtifactInstallProgress( + LocalAiArtifactInstallPhase Phase, + string? ArchiveFileName, + int ArchiveNumber, + int ArchiveCount, + long Completed, + long? Total, + LocalAiArtifactProgressUnit Unit) +{ + public double? Fraction => Total is > 0 + ? Math.Clamp((double)Completed / Total.Value, 0, 1) + : null; +} + +/// +/// Describes the one directory a setup transaction owns after promotion. +/// Callers must revalidate this path with +/// before recursively removing it during rollback. +/// +internal sealed record LocalAiArtifactRollbackMetadata(string CreatedDirectory); + +internal sealed record LocalAiArtifactInstallResult( + LocalAiComponentIdentity Component, + string InstallDirectory, + string ModelsDirectory, + IReadOnlyList VerifiedArchives, + LocalAiArtifactRollbackMetadata Rollback); + +internal sealed class LocalAiArtifactInstallException : Exception +{ + public LocalAiArtifactInstallException(string message) + : base(message) + { + } + + public LocalAiArtifactInstallException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Downloads one or more pinned native archives, verifies each byte stream, +/// safely extracts them into one disposable staging directory, then atomically +/// promotes the complete directory without replacing an existing install. +/// Component-specific release, executable, and version validation belong to +/// later policy layers. +/// +internal sealed class LocalAiArtifactInstaller +{ + private const int DownloadBufferSize = 128 * 1024; + private const int DownloadProgressIntervalBytes = 4 * 1024 * 1024; + private const int UnixFileTypeMask = 0xF000; + private const int UnixRegularFile = 0x8000; + private const int UnixDirectory = 0x4000; + private const int UnixSymbolicLink = 0xA000; + + private readonly HttpClient _httpClient; + + public LocalAiArtifactInstaller(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public event EventHandler? ProgressChanged; + + public async Task InstallAsync( + string localDataDirectory, + LocalAiComponentIdentity component, + IReadOnlyList archives, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(component); + ArgumentNullException.ThrowIfNull(archives); + + var pinnedArchives = archives.ToArray(); + ValidateArchiveSet(pinnedArchives); + + if (!LocalAiPathPolicy.TryResolve( + localDataDirectory, + component, + out var paths, + out var pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + var resolvedArchives = ResolveArchivePaths(paths, pinnedArchives); + var runId = Guid.NewGuid().ToString("N"); + if (!LocalAiPathPolicy.TryGetStagingDirectory( + paths, + runId, + out var stagingDirectory, + out pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + var stagingCreated = false; + var promoted = false; + var verifiedArchives = new List(pinnedArchives.Length); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + EnsurePromotionTargetDoesNotExist(paths.InstallDirectory); + + Directory.CreateDirectory(paths.DownloadsDirectory); + Directory.CreateDirectory(paths.StagingDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(paths.InstallDirectory)!); + + RevalidatePaths( + localDataDirectory, + component, + paths, + resolvedArchives, + stagingDirectory); + + RemoveStaleStagingEntries(localDataDirectory, paths.StagingDirectory); + + foreach (var resolved in resolvedArchives) + RemoveStalePartial(localDataDirectory, resolved.PartialArchivePath); + + if (Directory.Exists(stagingDirectory) || File.Exists(stagingDirectory)) + { + throw new LocalAiArtifactInstallException( + "The Local AI staging run directory already exists."); + } + + Directory.CreateDirectory(stagingDirectory); + stagingCreated = true; + + for (var index = 0; index < resolvedArchives.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var resolved = resolvedArchives[index]; + var archiveNumber = index + 1; + var verifiedHash = await DownloadAndVerifyAsync( + resolved.Archive, + resolved.PartialArchivePath, + archiveNumber, + resolvedArchives.Length, + progress, + cancellationToken).ConfigureAwait(false); + + verifiedArchives.Add(new LocalAiVerifiedArchive( + resolved.Archive.FileName, + resolved.Archive.SizeBytes, + verifiedHash)); + + await ExtractArchiveAsync( + resolved.Archive, + resolved.PartialArchivePath, + stagingDirectory, + archiveNumber, + resolvedArchives.Length, + progress, + cancellationToken).ConfigureAwait(false); + + TryDeleteManagedFile(localDataDirectory, resolved.PartialArchivePath); + } + + cancellationToken.ThrowIfCancellationRequested(); + RevalidatePaths( + localDataDirectory, + component, + paths, + resolvedArchives, + stagingDirectory); + EnsurePromotionTargetDoesNotExist(paths.InstallDirectory); + + Report(progress, new( + LocalAiArtifactInstallPhase.Promoting, + ArchiveFileName: null, + ArchiveNumber: resolvedArchives.Length, + ArchiveCount: resolvedArchives.Length, + Completed: 0, + Total: 1, + LocalAiArtifactProgressUnit.None)); + + Directory.Move(stagingDirectory, paths.InstallDirectory); + promoted = true; + + var result = new LocalAiArtifactInstallResult( + component, + paths.InstallDirectory, + paths.ModelsDirectory, + verifiedArchives.AsReadOnly(), + new LocalAiArtifactRollbackMetadata(paths.InstallDirectory)); + + Report(progress, new( + LocalAiArtifactInstallPhase.Complete, + ArchiveFileName: null, + ArchiveNumber: resolvedArchives.Length, + ArchiveCount: resolvedArchives.Length, + Completed: 1, + Total: 1, + LocalAiArtifactProgressUnit.None)); + return result; + } + finally + { + foreach (var resolved in resolvedArchives) + TryDeleteManagedFile(localDataDirectory, resolved.PartialArchivePath); + if (stagingCreated && !promoted) + TryDeleteManagedDirectory(localDataDirectory, stagingDirectory); + } + } + + private async Task DownloadAndVerifyAsync( + LocalAiPinnedArchive archive, + string partialArchivePath, + int archiveNumber, + int archiveCount, + IProgress? progress, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, archive.DownloadUri); + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive '{archive.FileName}' download failed with HTTP status " + + $"{(int)response.StatusCode} ({response.StatusCode})."); + } + + if (response.Content.Headers.ContentLength is { } contentLength && + contentLength != archive.SizeBytes) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive '{archive.FileName}' declared {contentLength} bytes; " + + $"expected {archive.SizeBytes} bytes."); + } + + Report(progress, new( + LocalAiArtifactInstallPhase.Downloading, + archive.FileName, + archiveNumber, + archiveCount, + Completed: 0, + Total: archive.SizeBytes, + LocalAiArtifactProgressUnit.Bytes)); + + long downloaded = 0; + long lastReportedDownloadBytes = 0; + using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + await using (var source = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false)) + await using (var destination = new FileStream( + partialArchivePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + DownloadBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + var buffer = new byte[DownloadBufferSize]; + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + break; + + downloaded = checked(downloaded + read); + if (downloaded > archive.SizeBytes) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive '{archive.FileName}' exceeded its expected size of " + + $"{archive.SizeBytes} bytes."); + } + + await destination + .WriteAsync(buffer.AsMemory(0, read), cancellationToken) + .ConfigureAwait(false); + hasher.AppendData(buffer, 0, read); + + if (downloaded == archive.SizeBytes || + downloaded - lastReportedDownloadBytes >= DownloadProgressIntervalBytes) + { + Report(progress, new( + LocalAiArtifactInstallPhase.Downloading, + archive.FileName, + archiveNumber, + archiveCount, + downloaded, + archive.SizeBytes, + LocalAiArtifactProgressUnit.Bytes)); + lastReportedDownloadBytes = downloaded; + } + } + + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + if (downloaded != archive.SizeBytes) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive '{archive.FileName}' contained {downloaded} bytes; " + + $"expected {archive.SizeBytes} bytes."); + } + + Report(progress, new( + LocalAiArtifactInstallPhase.Verifying, + archive.FileName, + archiveNumber, + archiveCount, + downloaded, + archive.SizeBytes, + LocalAiArtifactProgressUnit.Bytes)); + + var actualHashBytes = hasher.GetHashAndReset(); + var expectedHashBytes = Convert.FromHexString(archive.Sha256); + if (!CryptographicOperations.FixedTimeEquals(actualHashBytes, expectedHashBytes)) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive '{archive.FileName}' failed SHA-256 verification."); + } + + return Convert.ToHexStringLower(actualHashBytes); + } + + private async Task ExtractArchiveAsync( + LocalAiPinnedArchive pinnedArchive, + string archivePath, + string stagingDirectory, + int archiveNumber, + int archiveCount, + IProgress? progress, + CancellationToken cancellationToken) + { + try + { + await using var archiveStream = new FileStream( + archivePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + DownloadBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false); + var totalEntries = archive.Entries.Count; + long completedEntries = 0; + + Report(progress, new( + LocalAiArtifactInstallPhase.Extracting, + pinnedArchive.FileName, + archiveNumber, + archiveCount, + completedEntries, + totalEntries, + LocalAiArtifactProgressUnit.Entries)); + + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateArchiveEntryName(entry.FullName); + var isDirectory = ValidateArchiveEntryType(entry); + + if (!LocalAiPathPolicy.TryResolveArchiveEntryDestination( + stagingDirectory, + entry.FullName, + out var destinationPath, + out var pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + if (isDirectory) + { + if (File.Exists(destinationPath)) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entry.FullName}' would replace an existing file."); + } + + Directory.CreateDirectory(destinationPath); + RevalidateArchiveDestination(stagingDirectory, entry.FullName, destinationPath); + } + else + { + if (File.Exists(destinationPath) || Directory.Exists(destinationPath)) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entry.FullName}' would overwrite an existing path."); + } + + var parentDirectory = Path.GetDirectoryName(destinationPath) + ?? throw new LocalAiArtifactInstallException( + "Local AI archive entry has no parent directory."); + Directory.CreateDirectory(parentDirectory); + RevalidateArchiveDestination(stagingDirectory, entry.FullName, destinationPath); + + await using var source = entry.Open(); + await using var destination = new FileStream( + destinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + DownloadBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await source + .CopyToAsync(destination, DownloadBufferSize, cancellationToken) + .ConfigureAwait(false); + } + + completedEntries++; + Report(progress, new( + LocalAiArtifactInstallPhase.Extracting, + pinnedArchive.FileName, + archiveNumber, + archiveCount, + completedEntries, + totalEntries, + LocalAiArtifactProgressUnit.Entries)); + } + } + catch (InvalidDataException ex) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive '{pinnedArchive.FileName}' is not a valid ZIP archive.", + ex); + } + } + + private static bool ValidateArchiveEntryType(ZipArchiveEntry entry) + { + var windowsAttributes = (FileAttributes)(entry.ExternalAttributes & 0xFFFF); + if (windowsAttributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entry.FullName}' is a reparse point."); + } + + var unixMode = (entry.ExternalAttributes >> 16) & 0xFFFF; + var unixFileType = unixMode & UnixFileTypeMask; + if (unixFileType == UnixSymbolicLink) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entry.FullName}' is a symbolic link."); + } + + if (unixFileType is not 0 and not UnixRegularFile and not UnixDirectory) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entry.FullName}' has an unsupported file type."); + } + + var hasDirectoryMarker = entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\'); + var declaresDirectory = windowsAttributes.HasFlag(FileAttributes.Directory) || + unixFileType == UnixDirectory; + var declaresRegularFile = unixFileType == UnixRegularFile; + + if (declaresDirectory && !hasDirectoryMarker) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entry.FullName}' has inconsistent directory metadata."); + } + + if (declaresRegularFile && hasDirectoryMarker) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entry.FullName}' has inconsistent file metadata."); + } + + return hasDirectoryMarker; + } + + private static void ValidateArchiveEntryName(string entryName) + { + if (string.IsNullOrWhiteSpace(entryName) || entryName.IndexOf('\0') >= 0) + throw new LocalAiArtifactInstallException("Local AI archive contains an empty or invalid entry name."); + + var normalized = entryName.Replace('\\', '/'); + var segments = normalized.Split('/'); + for (var index = 0; index < segments.Length; index++) + { + var segment = segments[index]; + var isTrailingDirectoryMarker = index == segments.Length - 1 && segment.Length == 0; + if (isTrailingDirectoryMarker) + continue; + + if (string.IsNullOrWhiteSpace(segment) || + segment is "." or ".." || + !string.Equals(segment, segment.Trim(), StringComparison.Ordinal) || + segment.EndsWith('.') || + segment.Contains(':') || + segment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + LocalAiPathPolicy.IsWindowsDeviceName(segment)) + { + throw new LocalAiArtifactInstallException( + $"Local AI archive entry '{entryName}' contains an unsafe path segment."); + } + } + } + + private static void RevalidateArchiveDestination( + string stagingDirectory, + string entryName, + string expectedDestination) + { + if (!LocalAiPathPolicy.TryResolveArchiveEntryDestination( + stagingDirectory, + entryName, + out var currentDestination, + out var pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + if (!string.Equals( + currentDestination, + expectedDestination, + StringComparison.OrdinalIgnoreCase)) + { + throw new LocalAiArtifactInstallException( + "The Local AI archive destination changed during extraction."); + } + } + + private static void ValidateArchiveSet(LocalAiPinnedArchive[] archives) + { + if (archives.Length == 0) + { + throw new ArgumentException( + "At least one pinned Local AI archive is required.", + nameof(archives)); + } + + var fileNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var archive in archives) + { + if (archive is null) + throw new ArgumentException("Pinned Local AI archives cannot contain null entries.", nameof(archives)); + ValidateArchive(archive); + if (!fileNames.Add(archive.FileName)) + { + throw new ArgumentException( + $"Pinned Local AI archive file name '{archive.FileName}' appears more than once.", + nameof(archives)); + } + } + } + + private static void ValidateArchive(LocalAiPinnedArchive archive) + { + if (archive.SizeBytes <= 0) + throw new ArgumentException("Local AI archive expected size must be positive.", nameof(archive)); + if (!archive.DownloadUri.IsAbsoluteUri || + !string.Equals( + archive.DownloadUri.Scheme, + Uri.UriSchemeHttps, + StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Local AI archive download URI must use HTTPS.", nameof(archive)); + } + + try + { + if (Convert.FromHexString(archive.Sha256).Length != 32 || + !string.Equals( + archive.Sha256, + archive.Sha256.ToLowerInvariant(), + StringComparison.Ordinal)) + { + throw new ArgumentException( + "Local AI archive SHA-256 must be 64 lowercase hexadecimal characters.", + nameof(archive)); + } + } + catch (FormatException ex) + { + throw new ArgumentException( + "Local AI archive SHA-256 must be 64 lowercase hexadecimal characters.", + nameof(archive), + ex); + } + } + + private static ResolvedArchive[] ResolveArchivePaths( + LocalAiSetupPaths paths, + LocalAiPinnedArchive[] archives) + { + var resolved = new ResolvedArchive[archives.Length]; + for (var index = 0; index < archives.Length; index++) + { + var archive = archives[index]; + if (!LocalAiPathPolicy.TryGetDownloadPath( + paths, + archive.FileName, + out var archivePath, + out var pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + if (!LocalAiPathPolicy.TryGetDownloadPath( + paths, + archive.FileName + ".partial", + out var partialArchivePath, + out pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + resolved[index] = new ResolvedArchive(archive, archivePath, partialArchivePath); + } + + return resolved; + } + + private static void RevalidatePaths( + string localDataDirectory, + LocalAiComponentIdentity component, + LocalAiSetupPaths expectedPaths, + ResolvedArchive[] expectedArchives, + string expectedStagingDirectory) + { + if (!LocalAiPathPolicy.TryResolve( + localDataDirectory, + component, + out var currentPaths, + out var pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + if (currentPaths != expectedPaths) + throw new LocalAiArtifactInstallException("The Local AI install path changed during installation."); + + foreach (var expected in expectedArchives) + { + if (!LocalAiPathPolicy.TryGetDownloadPath( + currentPaths, + expected.Archive.FileName, + out var currentArchivePath, + out pathError) || + !string.Equals(currentArchivePath, expected.ArchivePath, StringComparison.OrdinalIgnoreCase)) + { + throw new LocalAiArtifactInstallException( + pathError.Length == 0 + ? "A Local AI archive path changed during installation." + : pathError); + } + + if (!LocalAiPathPolicy.TryGetDownloadPath( + currentPaths, + expected.Archive.FileName + ".partial", + out var currentPartialPath, + out pathError) || + !string.Equals(currentPartialPath, expected.PartialArchivePath, StringComparison.OrdinalIgnoreCase)) + { + throw new LocalAiArtifactInstallException( + pathError.Length == 0 + ? "A Local AI partial archive path changed during installation." + : pathError); + } + } + + var runId = Path.GetFileName(expectedStagingDirectory); + if (!LocalAiPathPolicy.TryGetStagingDirectory( + currentPaths, + runId, + out var currentStagingDirectory, + out pathError) || + !string.Equals( + currentStagingDirectory, + expectedStagingDirectory, + StringComparison.OrdinalIgnoreCase)) + { + throw new LocalAiArtifactInstallException( + pathError.Length == 0 + ? "The Local AI staging path changed during installation." + : pathError); + } + } + + private static void EnsurePromotionTargetDoesNotExist(string installDirectory) + { + if (Directory.Exists(installDirectory) || File.Exists(installDirectory)) + { + throw new LocalAiArtifactInstallException( + $"Refusing to replace existing Local AI install path '{installDirectory}'."); + } + } + + private static void RemoveStalePartial(string localDataDirectory, string partialArchivePath) + { + if (Directory.Exists(partialArchivePath)) + { + throw new LocalAiArtifactInstallException( + "A Local AI partial download path is an existing directory."); + } + + if (!File.Exists(partialArchivePath)) + return; + if (!LocalAiPathPolicy.TryValidateManagedDeleteTarget( + localDataDirectory, + partialArchivePath, + out var deletePath, + out var pathError)) + { + throw new LocalAiArtifactInstallException(pathError); + } + + File.Delete(deletePath); + } + + private static void TryDeleteManagedFile(string localDataDirectory, string path) + { + try + { + if (!File.Exists(path)) + return; + if (LocalAiPathPolicy.TryValidateManagedDeleteTarget( + localDataDirectory, + path, + out var deletePath, + out _)) + { + File.Delete(deletePath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + System.Diagnostics.Trace.TraceWarning( + "Could not clean Local AI partial download '{0}': {1}", + path, + ex.Message); + } + } + + private static void TryDeleteManagedDirectory(string localDataDirectory, string path) + { + try + { + if (!Directory.Exists(path)) + return; + if (LocalAiPathPolicy.TryDeleteManagedTree( + localDataDirectory, + path, + allowRoot: false, + out _)) + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + System.Diagnostics.Trace.TraceWarning( + "Could not clean Local AI staging directory '{0}': {1}", + path, + ex.Message); + } + } + + private static void RemoveStaleStagingEntries( + string localDataDirectory, + string stagingDirectory) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(stagingDirectory)) + { + if (!LocalAiPathPolicy.TryDeleteManagedTree( + localDataDirectory, + entry, + allowRoot: false, + out string error)) + { + throw new LocalAiArtifactInstallException( + $"A stale Local AI staging entry could not be removed safely: {error}"); + } + } + } + + private void Report( + IProgress? progress, + LocalAiArtifactInstallProgress value) + { + try + { + progress?.Report(value); + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceWarning( + "Local AI progress observer failed: {0}", + ex.Message); + } + + try + { + ProgressChanged?.Invoke(this, value); + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceWarning( + "Local AI progress event observer failed: {0}", + ex.Message); + } + } + + private sealed record ResolvedArchive( + LocalAiPinnedArchive Archive, + string ArchivePath, + string PartialArchivePath); +} From dcb72b67f344a561d6ba9f2addad2a016109bdc8 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:50:18 -0700 Subject: [PATCH 02/23] refactor(local-ai): add durable runtime manifests Persist installed runtime and model metadata with atomic file updates. Give setup, startup, and cleanup one durable source of installation truth. Signed-off-by: Joel Fernandes --- .../LocalAi/LocalAiManifest.cs | 417 ++++++++++++++++++ .../LocalAi/LocalAiRuntimeModels.cs | 117 +++++ 2 files changed, 534 insertions(+) create mode 100644 src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs create mode 100644 src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs b/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs new file mode 100644 index 000000000..82b273874 --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs @@ -0,0 +1,417 @@ +using System.Collections.Immutable; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Connection.LocalAi; + +/// Canonical, companion-owned locations for local inference artifacts. +public sealed class LocalAiPaths +{ + public LocalAiPaths(string localDataDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(localDataDirectory); + LocalDataDirectory = Path.GetFullPath(localDataDirectory); + RootDirectory = Path.Combine(LocalDataDirectory, "LocalAI"); + ManifestPath = Path.Combine(RootDirectory, "state.json"); + EnginesDirectory = Path.Combine(RootDirectory, "engines"); + ModelsDirectory = Path.Combine(RootDirectory, "models"); + DownloadsDirectory = Path.Combine(RootDirectory, "downloads"); + StagingDirectory = Path.Combine(RootDirectory, "staging"); + LogsDirectory = Path.Combine(RootDirectory, "logs"); + StandardOutputLogPath = Path.Combine(LogsDirectory, "llama-server.stdout.log"); + StandardErrorLogPath = Path.Combine(LogsDirectory, "llama-server.stderr.log"); + } + + public string LocalDataDirectory { get; } + public string RootDirectory { get; } + public string ManifestPath { get; } + public string EnginesDirectory { get; } + public string ModelsDirectory { get; } + public string DownloadsDirectory { get; } + public string StagingDirectory { get; } + public string LogsDirectory { get; } + public string StandardOutputLogPath { get; } + public string StandardErrorLogPath { get; } + + public void EnsureDirectories() + { + Directory.CreateDirectory(RootDirectory); + Directory.CreateDirectory(EnginesDirectory); + Directory.CreateDirectory(ModelsDirectory); + Directory.CreateDirectory(DownloadsDirectory); + Directory.CreateDirectory(StagingDirectory); + Directory.CreateDirectory(LogsDirectory); + } + + /// + /// Resolves a manifest-owned relative path and rejects traversal or any existing + /// reparse point between the local AI root and the resolved target. + /// + public string ResolveContainedPath(string relativePath, string fieldName) + { + if (string.IsNullOrWhiteSpace(relativePath)) + throw new InvalidDataException($"{fieldName} must be a non-empty relative path."); + if (Path.IsPathFullyQualified(relativePath) || Path.IsPathRooted(relativePath)) + throw new InvalidDataException($"{fieldName} must be relative to the local AI data directory."); + + string resolved; + try + { + resolved = Path.GetFullPath(relativePath, RootDirectory); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + throw new InvalidDataException($"{fieldName} is not a valid managed path.", ex); + } + + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(RootDirectory)); + var rootWithSeparator = root + Path.DirectorySeparatorChar; + if (!resolved.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"{fieldName} escapes the local AI data directory."); + + RejectExistingReparsePoints(root, resolved, fieldName); + return resolved; + } + + private static void RejectExistingReparsePoints(string root, string resolvedPath, string fieldName) + { + RejectIfReparsePoint(root, fieldName); + var relative = Path.GetRelativePath(root, resolvedPath); + var current = root; + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (!File.Exists(current) && !Directory.Exists(current)) + break; + RejectIfReparsePoint(current, fieldName); + } + } + + private static void RejectIfReparsePoint(string path, string fieldName) + { + if (!File.Exists(path) && !Directory.Exists(path)) + return; + + try + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException($"{fieldName} contains an existing reparse point."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new InvalidDataException($"{fieldName} could not be safely validated.", ex); + } + } +} + +/// Immutable source receipt for an acquired runtime or model artifact. +public sealed record LocalAiAssetReceipt +{ + public required string FileName { get; init; } + public required string SourceUrl { get; init; } + public required long SizeBytes { get; init; } + public required string Sha256 { get; init; } +} + +public sealed record LocalAiInstallManifest +{ + public const int CurrentSchemaVersion = 2; + public const string SupportedEngine = "llama-server"; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public string Engine { get; init; } = SupportedEngine; + public required string EngineVersion { get; init; } + public required string Architecture { get; init; } + /// + /// Legacy schema-3 metadata. It remains readable for compatibility but is + /// not used for qualification and new manifests leave it absent. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? HardwareProfileId { get; init; } + public required string RuntimeId { get; init; } + public required string ModelCatalogId { get; init; } + public required string SelectedGpuId { get; init; } + public required string ExecutablePath { get; init; } + public required ImmutableArray RuntimeAssets { get; init; } + public required string ModelPath { get; init; } + public required string ModelId { get; init; } + public required string ModelAlias { get; init; } + public required LocalAiAssetReceipt ModelAsset { get; init; } + public required string Endpoint { get; init; } + /// + /// The non-Local-AI primary model that was active before setup selected the + /// managed llama.cpp model. Null means no prior primary model was configured. + /// + public string? GatewayFallbackModel { get; init; } + public required int ContextLength { get; init; } + public DateTimeOffset InstalledAtUtc { get; init; } = DateTimeOffset.UtcNow; +} + +public sealed record LocalAiResolvedInstall( + LocalAiInstallManifest Manifest, + string ExecutablePath, + string ModelPath, + Uri Endpoint); + +/// Validation for the non-managed gateway route retained in a manifest. +public static class LocalAiGatewayModelPolicy +{ + public static void ValidateFallbackModel(string? model) + { + if (model is null) + return; + int separator = model.IndexOf('/'); + if (model.Length is 0 or > 512 || + model.Any(character => char.IsControl(character) || char.IsWhiteSpace(character)) || + separator <= 0 || separator == model.Length - 1 || + model.IndexOf('/', separator + 1) >= 0 || + model.StartsWith("llamacpp/", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + "The saved gateway fallback model must be a non-Local-AI provider/model identifier."); + } + } +} + +/// Persists the installation manifest with same-directory atomic replacement. +public sealed class LocalAiManifestStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + private readonly LocalAiPaths _paths; + + public LocalAiManifestStore(LocalAiPaths paths) => + _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_paths.ManifestPath)) + return null; + + LocalAiInstallManifest? manifest; + try + { + await using var stream = new FileStream( + _paths.ManifestPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + manifest = await JsonSerializer.DeserializeAsync( + stream, + JsonOptions, + cancellationToken) + .ConfigureAwait(false); + } + catch (JsonException ex) + { + throw new InvalidDataException("The local AI installation manifest is invalid JSON or uses an unsupported format.", ex); + } + + return ResolveAndValidate( + manifest ?? throw new InvalidDataException("The local AI installation manifest is empty.")); + } + + public async Task SaveAsync(LocalAiInstallManifest manifest, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + _ = ResolveAndValidate(manifest); + Directory.CreateDirectory(_paths.RootDirectory); + _ = _paths.ResolveContainedPath(Path.GetFileName(_paths.ManifestPath), nameof(_paths.ManifestPath)); + + var temporaryPath = Path.Combine( + _paths.RootDirectory, + $".{Path.GetFileName(_paths.ManifestPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync(stream, manifest, JsonOptions, cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + _ = _paths.ResolveContainedPath(Path.GetFileName(temporaryPath), nameof(temporaryPath)); + File.Move(temporaryPath, _paths.ManifestPath, overwrite: true); + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch + { + // Best-effort cleanup must not mask the persistence result. + } + } + } + + public Task DeleteAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Delete(_paths.ManifestPath); + return Task.CompletedTask; + } + + public LocalAiResolvedInstall ResolveAndValidate(LocalAiInstallManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + if (manifest.SchemaVersion != LocalAiInstallManifest.CurrentSchemaVersion) + throw new InvalidDataException($"Unsupported local AI manifest schema version {manifest.SchemaVersion}."); + if (!string.Equals(manifest.Engine, LocalAiInstallManifest.SupportedEngine, StringComparison.Ordinal)) + throw new InvalidDataException("The local AI manifest engine must be llama-server."); + if (string.IsNullOrWhiteSpace(manifest.EngineVersion)) + throw new InvalidDataException("The local AI manifest engine version is required."); + if (manifest.Architecture is not ("x64" or "arm64")) + throw new InvalidDataException("The local AI manifest architecture must be x64 or arm64."); + ValidatePlanIdentifier(manifest.RuntimeId, nameof(manifest.RuntimeId)); + ValidatePlanIdentifier(manifest.ModelCatalogId, nameof(manifest.ModelCatalogId)); + if (string.IsNullOrWhiteSpace(manifest.SelectedGpuId) || + manifest.SelectedGpuId.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + throw new InvalidDataException("The local AI manifest selected GPU identifier is invalid."); + } + if (string.IsNullOrWhiteSpace(manifest.ModelId)) + throw new InvalidDataException("The local AI manifest model identifier is required."); + if (string.IsNullOrWhiteSpace(manifest.ModelAlias) || + manifest.ModelAlias.Any(character => char.IsControl(character) || char.IsWhiteSpace(character) || character is '/' or '\\')) + { + throw new InvalidDataException("The local AI manifest model alias must be a non-empty path-safe token."); + } + if (manifest.ContextLength <= 0) + throw new InvalidDataException("The local AI manifest context length must be positive."); + + if (manifest.RuntimeAssets.IsDefaultOrEmpty) + throw new InvalidDataException("The local AI manifest must record at least one runtime asset receipt."); + + var runtimeFileNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var runtimeAsset in manifest.RuntimeAssets) + { + ValidateAssetReceipt(runtimeAsset, nameof(manifest.RuntimeAssets)); + if (!runtimeFileNames.Add(runtimeAsset.FileName)) + throw new InvalidDataException("The local AI manifest runtime asset filenames must be unique."); + } + ValidateAssetReceipt(manifest.ModelAsset, nameof(manifest.ModelAsset)); + ValidateHuggingFaceModelProvenance(manifest); + + var executable = _paths.ResolveContainedPath(manifest.ExecutablePath, nameof(manifest.ExecutablePath)); + if (!string.Equals(Path.GetFileName(executable), "llama-server.exe", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The managed local AI executable must be llama-server.exe."); + + var model = _paths.ResolveContainedPath(manifest.ModelPath, nameof(manifest.ModelPath)); + if (!string.Equals(Path.GetExtension(model), ".gguf", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The managed local AI model must be a GGUF file."); + if (!string.Equals(Path.GetFileName(model), manifest.ModelAsset.FileName, StringComparison.Ordinal)) + throw new InvalidDataException("The managed model path must match its asset receipt filename."); + + LocalAiGatewayModelPolicy.ValidateFallbackModel(manifest.GatewayFallbackModel); + + if (!Uri.TryCreate(manifest.Endpoint, UriKind.Absolute, out var endpoint) || + endpoint.Scheme != Uri.UriSchemeHttp || + !IsLoopback(endpoint) || + endpoint.IsDefaultPort || + endpoint.Port is <= 0 or > 65535 || + !string.IsNullOrEmpty(endpoint.UserInfo) || + !string.IsNullOrEmpty(endpoint.Query) || + !string.IsNullOrEmpty(endpoint.Fragment)) + { + throw new InvalidDataException("The local AI endpoint must be an HTTP loopback address with an explicit port."); + } + + return new LocalAiResolvedInstall(manifest, executable, model, endpoint); + } + + private static void ValidatePlanIdentifier(string? identifier, string fieldName) + { + if (string.IsNullOrWhiteSpace(identifier) || + identifier.Any(character => + !char.IsAsciiLetterOrDigit(character) && character is not ('-' or '_' or '.'))) + { + throw new InvalidDataException($"The local AI manifest {fieldName} is invalid."); + } + } + + private static void ValidateAssetReceipt(LocalAiAssetReceipt? receipt, string fieldName) + { + if (receipt is null) + throw new InvalidDataException($"{fieldName} is required."); + if (string.IsNullOrWhiteSpace(receipt.FileName) || + !string.Equals(receipt.FileName, Path.GetFileName(receipt.FileName), StringComparison.Ordinal) || + receipt.FileName is "." or "..") + { + throw new InvalidDataException($"{fieldName}.FileName must be a single file name."); + } + if (!Uri.TryCreate(receipt.SourceUrl, UriKind.Absolute, out var source) || + source.Scheme != Uri.UriSchemeHttps || + string.IsNullOrWhiteSpace(source.Host) || + !string.IsNullOrEmpty(source.UserInfo) || + !string.IsNullOrEmpty(source.Fragment)) + { + throw new InvalidDataException($"{fieldName}.SourceUrl must be an HTTPS URL without credentials or a fragment."); + } + if (receipt.SizeBytes <= 0) + throw new InvalidDataException($"{fieldName}.SizeBytes must be positive."); + if (receipt.Sha256 is null || + receipt.Sha256.Length != 64 || + receipt.Sha256.Any(character => character is not (>= '0' and <= '9' or >= 'a' and <= 'f'))) + throw new InvalidDataException($"{fieldName}.Sha256 must be a lowercase SHA-256 digest."); + } + + private static void ValidateHuggingFaceModelProvenance(LocalAiInstallManifest manifest) + { + var revisionSeparator = manifest.ModelId.LastIndexOf('@'); + if (revisionSeparator <= 0 || revisionSeparator == manifest.ModelId.Length - 1) + { + throw new InvalidDataException( + "The local AI manifest model identifier must include an immutable Hugging Face revision."); + } + + var repositoryId = manifest.ModelId[..revisionSeparator]; + var revision = manifest.ModelId[(revisionSeparator + 1)..]; + var repositorySegments = repositoryId.Split('/'); + if (repositorySegments.Length != 2 || + repositorySegments.Any(segment => + string.IsNullOrWhiteSpace(segment) || + segment.Any(character => !char.IsLetterOrDigit(character) && character is not ('-' or '_' or '.')))) + { + throw new InvalidDataException("The local AI manifest model repository identifier is invalid."); + } + if (revision.Length != 40 || + revision.Any(character => character is not (>= '0' and <= '9' or >= 'a' and <= 'f'))) + { + throw new InvalidDataException( + "The local AI manifest model revision must be a lowercase 40-character commit digest."); + } + + var source = new Uri(manifest.ModelAsset.SourceUrl, UriKind.Absolute); + var expectedPath = $"/{repositoryId}/resolve/{revision}/{manifest.ModelAsset.FileName}"; + if (!string.Equals(source.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.Equals(source.Host, "huggingface.co", StringComparison.OrdinalIgnoreCase) || + !string.Equals(Uri.UnescapeDataString(source.AbsolutePath), expectedPath, StringComparison.Ordinal) || + source.Query is not ("" or "?download=true")) + { + throw new InvalidDataException( + "The local AI manifest model source must match its immutable Hugging Face repository, revision, and filename."); + } + } + + private static bool IsLoopback(Uri endpoint) => + string.Equals(endpoint.Host, "localhost", StringComparison.OrdinalIgnoreCase) || + (IPAddress.TryParse(endpoint.Host, out var address) && IPAddress.IsLoopback(address)); +} diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs b/src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs new file mode 100644 index 000000000..cb86d844f --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs @@ -0,0 +1,117 @@ +namespace OpenClaw.Connection.LocalAi; + +public enum LocalAiRuntimeState +{ + NotInstalled, + Stopped, + Starting, + Healthy, + Stopping, + Conflict, + Failed, +} + +public enum LocalAiOwnership +{ + None, + CompanionManaged, +} + +/// +/// Evidence-backed availability of the exact model recorded in the managed manifest. +/// Verified and Loaded states always carry the artifact digest and observed size. +/// +public enum LocalAiModelAvailabilityState +{ + Unknown, + NotInstalled, + Verified, + Loaded, +} + +public sealed record LocalAiModelEvidence +{ + public LocalAiModelEvidence( + LocalAiModelAvailabilityState state, + DateTimeOffset observedAtUtc, + string? sha256 = null, + long? sizeBytes = null, + string? serverModelId = null) + { + if (state is LocalAiModelAvailabilityState.Verified or LocalAiModelAvailabilityState.Loaded) + { + if (sha256?.Length != 64 || sha256.Any(character => character is not (>= '0' and <= '9' or >= 'a' and <= 'f'))) + throw new ArgumentException("Verified model evidence requires a lowercase SHA-256 digest.", nameof(sha256)); + if (sizeBytes is null or <= 0) + throw new ArgumentOutOfRangeException(nameof(sizeBytes), "Verified model evidence requires a positive observed size."); + } + else if (sha256 is not null || sizeBytes is not null || serverModelId is not null) + { + throw new ArgumentException("Unknown or missing model evidence cannot carry artifact or server claims."); + } + + if (state == LocalAiModelAvailabilityState.Loaded && string.IsNullOrWhiteSpace(serverModelId)) + throw new ArgumentException("Loaded model evidence requires the server-observed model identifier.", nameof(serverModelId)); + if (state != LocalAiModelAvailabilityState.Loaded && serverModelId is not null) + throw new ArgumentException("Only loaded model evidence can carry a server-observed model identifier.", nameof(serverModelId)); + + State = state; + ObservedAtUtc = observedAtUtc; + Sha256 = sha256; + SizeBytes = sizeBytes; + ServerModelId = serverModelId; + } + + public LocalAiModelAvailabilityState State { get; } + public DateTimeOffset ObservedAtUtc { get; } + public string? Sha256 { get; } + public long? SizeBytes { get; } + public string? ServerModelId { get; } + + public static LocalAiModelEvidence Unknown(DateTimeOffset now) => + new(LocalAiModelAvailabilityState.Unknown, now); + + public static LocalAiModelEvidence NotInstalled(DateTimeOffset now) => + new(LocalAiModelAvailabilityState.NotInstalled, now); +} + +public sealed record LocalAiRuntimeSnapshot( + LocalAiRuntimeState State, + LocalAiOwnership Ownership, + Uri Endpoint, + string? EngineVersion, + string? ModelId, + LocalAiModelEvidence ModelEvidence, + int? ProcessId, + DateTimeOffset? ProcessStartedAtUtc, + string? Detail, + DateTimeOffset UpdatedAtUtc) +{ + public static LocalAiRuntimeSnapshot Initial(Uri endpoint, DateTimeOffset now) => + new( + LocalAiRuntimeState.Stopped, + LocalAiOwnership.None, + endpoint, + null, + null, + LocalAiModelEvidence.Unknown(now), + null, + null, + null, + now); +} + +public sealed class LocalAiRuntimeSnapshotChangedEventArgs(LocalAiRuntimeSnapshot snapshot) : EventArgs +{ + public LocalAiRuntimeSnapshot Snapshot { get; } = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); +} + +public interface ILocalAiRuntime : IAsyncDisposable +{ + LocalAiRuntimeSnapshot Snapshot { get; } + event EventHandler? StateChanged; + Task EnsureStartedAsync(CancellationToken cancellationToken = default); + Task StopAsync(CancellationToken cancellationToken = default); + Task RestartAsync(CancellationToken cancellationToken = default); + Task RefreshAsync(CancellationToken cancellationToken = default); +} From d635e8acfe8182a612ef5115ed0d853ca0a6b68b Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:50:18 -0700 Subject: [PATCH 03/23] refactor(local-ai): add native Windows managed-process host Host native Local AI processes with bounded startup and shutdown behavior. Capture output and terminate owned process trees reliably on Windows. Signed-off-by: Joel Fernandes --- .../LocalAi/LocalAiManagedProcessHost.cs | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 src/OpenClaw.Connection/LocalAi/LocalAiManagedProcessHost.cs diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiManagedProcessHost.cs b/src/OpenClaw.Connection/LocalAi/LocalAiManagedProcessHost.cs new file mode 100644 index 000000000..6f32db77d --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LocalAiManagedProcessHost.cs @@ -0,0 +1,518 @@ +using Microsoft.Win32.SafeHandles; +using OpenClaw.Shared; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace OpenClaw.Connection.LocalAi; + +internal sealed record LocalAiProcessStartSpec( + string ExecutablePath, + string WorkingDirectory, + IReadOnlyList Arguments, + IReadOnlyDictionary Environment, + string StandardOutputLogPath, + string StandardErrorLogPath, + long MaxLogBytes, + int LogBackupCount, + int MaxLogLineCharacters); + +internal sealed record LocalAiManagedProcessExit( + int ProcessId, + DateTimeOffset StartedAtUtc, + int? ExitCode); + +internal interface ILocalAiManagedProcess : IAsyncDisposable +{ + int ProcessId { get; } + DateTimeOffset StartedAtUtc { get; } + bool HasExited { get; } + Task StopAsync(TimeSpan timeout, CancellationToken cancellationToken); +} + +internal interface ILocalAiManagedProcessHost +{ + Task StartProcessAsync( + LocalAiProcessStartSpec spec, + Action exited, + CancellationToken cancellationToken); +} + +/// +/// Starts a native Windows inference process in a kill-on-close Job Object and +/// captures its output in bounded, sanitized logs. +/// +internal sealed class WindowsLocalAiManagedProcessHost(IOpenClawLogger logger) : ILocalAiManagedProcessHost +{ + public Task StartProcessAsync( + LocalAiProcessStartSpec spec, + Action exited, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(spec); + ArgumentNullException.ThrowIfNull(exited); + cancellationToken.ThrowIfCancellationRequested(); + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Managed local inference is supported only on Windows."); + + Directory.CreateDirectory(spec.WorkingDirectory); + var stdout = new BoundedRotatingLogWriter( + spec.StandardOutputLogPath, + spec.MaxLogBytes, + spec.LogBackupCount, + spec.MaxLogLineCharacters, + logger); + var stderr = new BoundedRotatingLogWriter( + spec.StandardErrorLogPath, + spec.MaxLogBytes, + spec.LogBackupCount, + spec.MaxLogLineCharacters, + logger); + var process = new Process + { + StartInfo = CreateStartInfo(spec), + EnableRaisingEvents = false, + }; + SafeJobHandle? job = null; + try + { + process.OutputDataReceived += (_, eventArgs) => + { + if (eventArgs.Data is not null) + stdout.WriteLine(eventArgs.Data); + }; + process.ErrorDataReceived += (_, eventArgs) => + { + if (eventArgs.Data is not null) + stderr.WriteLine(eventArgs.Data); + }; + if (!process.Start()) + throw new InvalidOperationException("The managed local inference process did not start."); + + var processId = process.Id; + var startedAtUtc = new DateTimeOffset(process.StartTime.ToUniversalTime()); + job = WindowsJob.CreateKillOnClose(); + if (!AssignProcessToJobObject(job, process.SafeHandle)) + { + throw new System.ComponentModel.Win32Exception( + Marshal.GetLastWin32Error(), + "Could not assign the managed local inference process to its lifecycle job."); + } + + var managed = new WindowsManagedProcess( + process, + job, + stdout, + stderr, + processId, + startedAtUtc, + exited, + logger); + job = null; + managed.EnableExitNotifications(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + return Task.FromResult(managed); + } + catch + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // The Job Object close below remains the authoritative cleanup. + } + + job?.Dispose(); + process.Dispose(); + stdout.Dispose(); + stderr.Dispose(); + throw; + } + } + + internal static ProcessStartInfo CreateStartInfo(LocalAiProcessStartSpec spec) + { + ArgumentNullException.ThrowIfNull(spec); + ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath); + ArgumentException.ThrowIfNullOrWhiteSpace(spec.WorkingDirectory); + if (spec.Arguments is null) + throw new ArgumentException("An explicit argument list is required.", nameof(spec)); + if (spec.Environment is null) + throw new ArgumentException("An explicit environment map is required.", nameof(spec)); + + var startInfo = new ProcessStartInfo + { + FileName = spec.ExecutablePath, + WorkingDirectory = spec.WorkingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in spec.Arguments) + { + if (argument is null) + throw new ArgumentException("Process arguments cannot contain null values.", nameof(spec)); + startInfo.ArgumentList.Add(argument); + } + foreach (var pair in spec.Environment) + { + if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null) + throw new ArgumentException("Process environment entries must have non-empty keys and non-null values.", nameof(spec)); + startInfo.Environment[pair.Key] = pair.Value; + } + + return startInfo; + } + + private sealed class WindowsManagedProcess : ILocalAiManagedProcess + { + private readonly Process _process; + private readonly SafeJobHandle _job; + private readonly BoundedRotatingLogWriter _stdout; + private readonly BoundedRotatingLogWriter _stderr; + private readonly Action _exited; + private readonly IOpenClawLogger _logger; + private int _exitNotified; + private int _disposed; + + public WindowsManagedProcess( + Process process, + SafeJobHandle job, + BoundedRotatingLogWriter stdout, + BoundedRotatingLogWriter stderr, + int processId, + DateTimeOffset startedAtUtc, + Action exited, + IOpenClawLogger logger) + { + _process = process; + _job = job; + _stdout = stdout; + _stderr = stderr; + ProcessId = processId; + StartedAtUtc = startedAtUtc; + _exited = exited; + _logger = logger; + } + + public int ProcessId { get; } + public DateTimeOffset StartedAtUtc { get; } + public bool HasExited + { + get + { + try + { + return _process.HasExited; + } + catch (InvalidOperationException) + { + return true; + } + } + } + + public void EnableExitNotifications() + { + _process.Exited += (_, _) => NotifyExited(); + _process.EnableRaisingEvents = true; + } + + public async Task StopAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout), "The process stop timeout must be positive."); + cancellationToken.ThrowIfCancellationRequested(); + if (HasExited) + return; + + try + { + _process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + return; + } + + using var timeoutCancellation = new CancellationTokenSource(timeout); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellation.Token); + try + { + await _process.WaitForExitAsync(linkedCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when ( + timeoutCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("The managed local inference process did not stop within the configured timeout."); + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + try + { + await StopAsync(TimeSpan.FromSeconds(2), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warn($"Could not stop the managed local inference process cleanly: {TokenSanitizer.SanitizeLogMessage(ex.Message)}"); + } + finally + { + _job.Dispose(); + _process.Dispose(); + _stdout.Dispose(); + _stderr.Dispose(); + } + } + + private void NotifyExited() + { + if (Interlocked.Exchange(ref _exitNotified, 1) != 0) + return; + + int? exitCode = null; + try + { + exitCode = _process.ExitCode; + } + catch + { + // The exact PID and start time remain useful even if the code raced disposal. + } + + try + { + _exited(new LocalAiManagedProcessExit(ProcessId, StartedAtUtc, exitCode)); + } + catch (Exception ex) + { + _logger.Warn($"The managed local inference exit callback failed: {TokenSanitizer.SanitizeLogMessage(ex.Message)}"); + } + } + } + + private static class WindowsJob + { + private const uint JobObjectLimitKillOnJobClose = 0x00002000; + private const int JobObjectExtendedLimitInformationClass = 9; + + public static SafeJobHandle CreateKillOnClose() + { + var job = CreateJobObjectW(IntPtr.Zero, null); + if (job.IsInvalid) + { + throw new System.ComponentModel.Win32Exception( + Marshal.GetLastWin32Error(), + "Could not create the managed local inference lifecycle job."); + } + + var limits = new JobObjectExtendedLimitInformation + { + BasicLimitInformation = new JobObjectBasicLimitInformation + { + LimitFlags = JobObjectLimitKillOnJobClose, + }, + }; + var size = Marshal.SizeOf(); + var pointer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, pointer, fDeleteOld: false); + if (!SetInformationJobObject( + job, + JobObjectExtendedLimitInformationClass, + pointer, + checked((uint)size))) + { + throw new System.ComponentModel.Win32Exception( + Marshal.GetLastWin32Error(), + "Could not configure the managed local inference lifecycle job."); + } + + return job; + } + catch + { + job.Dispose(); + throw; + } + finally + { + Marshal.FreeHGlobal(pointer); + } + } + } + + private sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeJobHandle() : base(ownsHandle: true) + { + } + + protected override bool ReleaseHandle() => CloseHandle(handle); + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicLimitInformation + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectExtendedLimitInformation + { + public JobObjectBasicLimitInformation BasicLimitInformation; + public IoCounters IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeJobHandle CreateJobObjectW(IntPtr securityAttributes, string? name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject( + SafeJobHandle job, + int informationClass, + IntPtr information, + uint length); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeJobHandle job, SafeProcessHandle process); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} + +internal sealed class BoundedRotatingLogWriter : IDisposable +{ + private readonly object _gate = new(); + private readonly string _path; + private readonly long _maxBytes; + private readonly int _backupCount; + private readonly int _maxLineCharacters; + private readonly IOpenClawLogger _logger; + private bool _disposed; + + public BoundedRotatingLogWriter( + string path, + long maxBytes, + int backupCount, + int maxLineCharacters, + IOpenClawLogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(logger); + _path = path; + _maxBytes = Math.Max(1024, maxBytes); + _backupCount = Math.Clamp(backupCount, 0, 10); + _maxLineCharacters = Math.Max(256, maxLineCharacters); + _logger = logger; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + } + + public void WriteLine(string line) + { + ArgumentNullException.ThrowIfNull(line); + lock (_gate) + { + if (_disposed) + return; + + try + { + var sanitized = TokenSanitizer.SanitizeLogMessage(line); + sanitized = ReplaceLineBreakingCharacters(sanitized); + if (sanitized.Length > _maxLineCharacters) + sanitized = sanitized[.._maxLineCharacters] + " [truncated]"; + + var newlineBytes = Encoding.UTF8.GetByteCount(Environment.NewLine); + var allowedBytes = checked((int)Math.Min(int.MaxValue, _maxBytes - newlineBytes)); + while (Encoding.UTF8.GetByteCount(sanitized) > allowedBytes && sanitized.Length > 1) + sanitized = sanitized[..Math.Max(1, sanitized.Length * 3 / 4)]; + + var bytes = Encoding.UTF8.GetByteCount(sanitized) + newlineBytes; + var currentBytes = File.Exists(_path) ? new FileInfo(_path).Length : 0; + if (currentBytes + bytes > _maxBytes) + Rotate(); + File.AppendAllText(_path, sanitized + Environment.NewLine, Encoding.UTF8); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.Warn($"Could not write the managed local inference log: {TokenSanitizer.SanitizeLogMessage(ex.Message)}"); + } + } + } + + private static string ReplaceLineBreakingCharacters(string value) + { + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(character is '\r' or '\n' or '\u0085' or '\u2028' or '\u2029' ? ' ' : character); + } + + return builder.ToString(); + } + + private void Rotate() + { + if (_backupCount == 0) + { + File.Delete(_path); + return; + } + + File.Delete(_path + "." + _backupCount); + for (var index = _backupCount - 1; index >= 1; index--) + { + var source = _path + "." + index; + if (File.Exists(source)) + File.Move(source, _path + "." + (index + 1), overwrite: true); + } + if (File.Exists(_path)) + File.Move(_path, _path + ".1", overwrite: true); + } + + public void Dispose() + { + lock (_gate) + _disposed = true; + } +} From 5cb14681cfba3bec6e55ca8932c50cc76752b24f Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:50:19 -0700 Subject: [PATCH 04/23] feat(inference): add pinned runtime and model catalogs Define immutable llama.cpp, CUDA, and GGUF catalog entries. Pin versions, URLs, hashes, and hardware requirements for reproducible installs. Signed-off-by: Joel Fernandes --- .../Inference/Catalog/LlamaRuntimeCatalog.cs | 126 +++++++++ .../Inference/Catalog/LocalModelCatalog.cs | 213 +++++++++++++++ .../Inference/Catalog/PinnedArtifact.cs | 242 ++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs create mode 100644 src/OpenClaw.Shared/Inference/Catalog/LocalModelCatalog.cs create mode 100644 src/OpenClaw.Shared/Inference/Catalog/PinnedArtifact.cs diff --git a/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs b/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs new file mode 100644 index 000000000..0cdc19029 --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs @@ -0,0 +1,126 @@ +using System.Collections.ObjectModel; +using System.Runtime.InteropServices; + +namespace OpenClaw.Shared.Inference.Catalog; + +/// A native Windows llama.cpp runtime and every archive required to execute it. +public sealed record LlamaRuntimeVariant +{ + public LlamaRuntimeVariant( + string id, + Architecture architecture, + Version cudaVersion, + IReadOnlyList artifacts) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentNullException.ThrowIfNull(cudaVersion); + ArgumentNullException.ThrowIfNull(artifacts); + if (architecture is not (Architecture.X64 or Architecture.Arm64)) + throw new ArgumentOutOfRangeException(nameof(architecture), "Only native Windows x64 and ARM64 runtimes are cataloged."); + if (cudaVersion.Major != 13) + throw new ArgumentOutOfRangeException(nameof(cudaVersion), "The qualified runtime requires CUDA 13."); + if (artifacts.Count != 2 || + artifacts.Count(artifact => artifact.Role == ArtifactRole.RuntimeBinary) != 1 || + artifacts.Count(artifact => artifact.Role == ArtifactRole.RuntimeDependency) != 1) + { + throw new ArgumentException( + "A CUDA runtime variant requires one llama.cpp archive and one CUDA runtime archive.", + nameof(artifacts)); + } + + Id = id; + Architecture = architecture; + CudaVersion = cudaVersion; + Artifacts = artifacts; + } + + public string Id { get; } + public Architecture Architecture { get; } + public Version CudaVersion { get; } + public IReadOnlyList Artifacts { get; } + public long TotalDownloadSizeBytes => Artifacts.Sum(artifact => artifact.SizeBytes); +} + +/// +/// Integrity-pinned native Windows llama.cpp builds for the supported NVIDIA +/// hardware profiles. Unsupported hardware does not receive a CPU or Vulkan +/// fallback from this catalog. +/// +public static class LlamaRuntimeCatalog +{ + public const string ReleaseTag = "b10488"; + public const string ReleaseCommitSha = "9d77fa17254e1dee4b9e92504c91611a60b1359f"; + public const string ServerExecutableName = "llama-server.exe"; + public const string X64RuntimeId = "b10488-cuda13-x64"; + public const string Arm64RuntimeId = "b10488-cuda13-arm64"; + + public static GitHubReleaseSource Source { get; } = new( + "ggml-org/llama.cpp", + ReleaseTag, + ReleaseCommitSha); + + private static readonly ReadOnlyCollection s_variants = Array.AsReadOnly( + new[] + { + new LlamaRuntimeVariant( + X64RuntimeId, + Architecture.X64, + new Version(13, 3), + Array.AsReadOnly( + new[] + { + RuntimeArtifact( + "llama-b10488-cuda13-x64", + ArtifactRole.RuntimeBinary, + "llama-b10488-bin-win-cuda-13.3-x64.zip", + 146_824_581, + "f4ea53c2e7f3d295cb9fd092515d50af4969266b4cdae01f03a1cbaa8b4d9af0"), + RuntimeArtifact( + "cudart-b10488-cuda13-x64", + ArtifactRole.RuntimeDependency, + "cudart-llama-bin-win-cuda-13.3-x64.zip", + 390_970_417, + "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e"), + })), + new LlamaRuntimeVariant( + Arm64RuntimeId, + Architecture.Arm64, + new Version(13, 4), + Array.AsReadOnly( + new[] + { + RuntimeArtifact( + "llama-b10488-cuda13-arm64", + ArtifactRole.RuntimeBinary, + "llama-b10488-bin-win-cuda-13.4-arm64.zip", + 140_379_054, + "75554d62f4af8f4150d3b4b0cca7df62d44105e98fb7cd92ab2d177e382b441d"), + RuntimeArtifact( + "cudart-b10488-cuda13-arm64", + ArtifactRole.RuntimeDependency, + "cudart-llama-bin-win-cuda-13.4-arm64.zip", + 153_318_797, + "5a40dc7c5fa3d0a80ceeba4f16f9e8d25d87bcf1399c9233588953c43436c33c"), + })), + }); + + public static IReadOnlyList Variants => s_variants; + + public static LlamaRuntimeVariant? Find(Architecture architecture) => + s_variants.SingleOrDefault(variant => variant.Architecture == architecture); + + private static PinnedArtifact RuntimeArtifact( + string id, + ArtifactRole role, + string fileName, + long sizeBytes, + string sha256) => + new( + id, + role, + Source, + fileName, + sizeBytes, + new Sha256Digest(sha256), + LocalInferenceCatalogProvenance.NvidiaCair); +} diff --git a/src/OpenClaw.Shared/Inference/Catalog/LocalModelCatalog.cs b/src/OpenClaw.Shared/Inference/Catalog/LocalModelCatalog.cs new file mode 100644 index 000000000..23e2886e1 --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/LocalModelCatalog.cs @@ -0,0 +1,213 @@ +using System.Collections.ObjectModel; + +namespace OpenClaw.Shared.Inference.Catalog; + +/// Key/value cache storage precision passed to llama-server. +public enum KvCachePrecision +{ + F16 = 0, +} + +/// Speculative decoding implementation used by a model recipe. +public enum SpeculativeDecodingMode +{ + DraftMtp = 0, +} + +/// Sampling values recommended for the model's thinking mode. +public sealed record ModelSamplingPreset( + double Temperature, + int TopK, + double TopP, + double MinP, + double RepetitionPenalty, + double PresencePenalty); + +/// Model-owned llama-server settings that affect capacity or output behavior. +public sealed record LocalModelRunRecipe +{ + public LocalModelRunRecipe( + int contextTokens, + KvCachePrecision keyCachePrecision, + KvCachePrecision valueCachePrecision, + int batchTokens, + int microBatchTokens, + int parallelRequests, + bool flashAttention, + bool offloadAllLayers, + SpeculativeDecodingMode speculativeDecoding, + int speculativeDraftMaxTokens, + ModelSamplingPreset sampling) + { + if (contextTokens <= 0) + throw new ArgumentOutOfRangeException(nameof(contextTokens)); + if (batchTokens <= 0) + throw new ArgumentOutOfRangeException(nameof(batchTokens)); + if (microBatchTokens <= 0 || microBatchTokens > batchTokens) + throw new ArgumentOutOfRangeException(nameof(microBatchTokens)); + if (parallelRequests <= 0) + throw new ArgumentOutOfRangeException(nameof(parallelRequests)); + if (speculativeDraftMaxTokens <= 0) + throw new ArgumentOutOfRangeException(nameof(speculativeDraftMaxTokens)); + ArgumentNullException.ThrowIfNull(sampling); + + ContextTokens = contextTokens; + KeyCachePrecision = keyCachePrecision; + ValueCachePrecision = valueCachePrecision; + BatchTokens = batchTokens; + MicroBatchTokens = microBatchTokens; + ParallelRequests = parallelRequests; + FlashAttention = flashAttention; + OffloadAllLayers = offloadAllLayers; + SpeculativeDecoding = speculativeDecoding; + SpeculativeDraftMaxTokens = speculativeDraftMaxTokens; + Sampling = sampling; + } + + public int ContextTokens { get; } + public KvCachePrecision KeyCachePrecision { get; } + public KvCachePrecision ValueCachePrecision { get; } + public int BatchTokens { get; } + public int MicroBatchTokens { get; } + public int ParallelRequests { get; } + public bool FlashAttention { get; } + public bool OffloadAllLayers { get; } + public SpeculativeDecodingMode SpeculativeDecoding { get; } + public int SpeculativeDraftMaxTokens { get; } + public ModelSamplingPreset Sampling { get; } +} + +/// A downloadable GGUF model and its deterministic llama-server recipe. +public sealed record LocalModelInfo( + string Id, + string DisplayName, + string Family, + string Quantization, + PinnedArtifact Weights, + LocalModelRunRecipe Recipe, + bool IsDefault, + bool IsExplicitAlternative, + bool SupportsVision); + +/// Immutable Hugging Face model pins offered by the Windows local inference flow. +public static class LocalModelCatalog +{ + public const string Qwen35BModelId = "qwen3.6-35b-a3b-mtp-q4-k-m"; + public const string Qwen27BModelId = "qwen3.6-27b-mtp-q4-k-m"; + public const string Qwen9BModelId = "qwen3.5-9b-mtp-q4-k-m"; + public const int NativeContextTokens = 262_144; + + private static readonly HuggingFaceRevisionSource s_qwen35BSource = new( + "unsloth/Qwen3.6-35B-A3B-MTP-GGUF", + "5bc3e238d916f48a861bac2f8a1990a0e9b7e98d"); + + private static readonly HuggingFaceRevisionSource s_qwen27BSource = new( + "unsloth/Qwen3.6-27B-MTP-GGUF", + "5cb35eb3dcbf52dbce5f87dbc64df6aaffadcace"); + + private static readonly HuggingFaceRevisionSource s_qwen9BSource = new( + "unsloth/Qwen3.5-9B-MTP-GGUF", + "9716a636ee4bddc3fed678220b7a33dd2a4160ae"); + + private static readonly ReadOnlyCollection s_models = Array.AsReadOnly( + new[] + { + new LocalModelInfo( + Qwen35BModelId, + "Qwen3.6 35B-A3B (UD-Q4_K_M)", + "Qwen3.6", + "Q4_K_M", + ModelArtifact( + Qwen35BModelId, + s_qwen35BSource, + "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", + 22_663_387_424, + "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b"), + Recipe( + temperature: 0.6), + IsDefault: true, + IsExplicitAlternative: false, + SupportsVision: false), + new LocalModelInfo( + Qwen27BModelId, + "Qwen3.6 27B (Q4_K_M)", + "Qwen3.6", + "Q4_K_M", + ModelArtifact( + Qwen27BModelId, + s_qwen27BSource, + "Qwen3.6-27B-Q4_K_M.gguf", + 17_106_773_120, + "a7cbd3ecc0e3f9b333edee61ae66bc87ed713c5d49587a8355814722ed329e0f"), + Recipe( + temperature: 1.0), + IsDefault: false, + IsExplicitAlternative: true, + SupportsVision: false), + new LocalModelInfo( + Qwen9BModelId, + "Qwen3.5 9B (Q4_K_M)", + "Qwen3.5", + "Q4_K_M", + ModelArtifact( + Qwen9BModelId, + s_qwen9BSource, + "Qwen3.5-9B-Q4_K_M.gguf", + 5_868_826_976, + "e8dd94817e95d6c0939102049d068418269978377b13616c4726235e232841fe"), + Recipe( + temperature: 1.0), + IsDefault: false, + IsExplicitAlternative: true, + SupportsVision: false), + }); + + private static readonly ReadOnlyCollection s_explicitAlternatives = + Array.AsReadOnly(s_models.Where(model => model.IsExplicitAlternative).ToArray()); + + public static IReadOnlyList Models => s_models; + + public static LocalModelInfo Default => s_models.Single(model => model.IsDefault); + + public static IReadOnlyList ExplicitAlternatives => s_explicitAlternatives; + + public static LocalModelInfo? Find(string? id) => + string.IsNullOrWhiteSpace(id) + ? null + : s_models.SingleOrDefault(model => string.Equals(model.Id, id, StringComparison.OrdinalIgnoreCase)); + + private static PinnedArtifact ModelArtifact( + string id, + HuggingFaceRevisionSource source, + string fileName, + long sizeBytes, + string sha256) => + new( + id, + ArtifactRole.ModelWeights, + source, + fileName, + sizeBytes, + new Sha256Digest(sha256), + LocalInferenceCatalogProvenance.NvidiaCair); + + private static LocalModelRunRecipe Recipe(double temperature) => + new( + contextTokens: NativeContextTokens, + keyCachePrecision: KvCachePrecision.F16, + valueCachePrecision: KvCachePrecision.F16, + batchTokens: 4_096, + microBatchTokens: 4_096, + parallelRequests: 1, + flashAttention: true, + offloadAllLayers: true, + speculativeDecoding: SpeculativeDecodingMode.DraftMtp, + speculativeDraftMaxTokens: 3, + sampling: new ModelSamplingPreset( + Temperature: temperature, + TopK: 20, + TopP: 0.95, + MinP: 0.0, + RepetitionPenalty: 1.0, + PresencePenalty: 0.0)); +} diff --git a/src/OpenClaw.Shared/Inference/Catalog/PinnedArtifact.cs b/src/OpenClaw.Shared/Inference/Catalog/PinnedArtifact.cs new file mode 100644 index 000000000..fbe955209 --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/PinnedArtifact.cs @@ -0,0 +1,242 @@ +namespace OpenClaw.Shared.Inference.Catalog; + +/// The role a downloaded artifact plays in a local inference installation. +public enum ArtifactRole +{ + RuntimeBinary = 0, + RuntimeDependency = 1, + ModelWeights = 2, +} + +/// A validated lowercase SHA-256 digest. +public sealed record Sha256Digest +{ + public Sha256Digest(string value) + { + if (!PinnedArtifactValidation.IsLowerHex(value, 64)) + throw new ArgumentException("A SHA-256 digest must contain exactly 64 lowercase hexadecimal characters.", nameof(value)); + + Value = value; + } + + public string Value { get; } + + public override string ToString() => Value; +} + +/// +/// Attribution for catalog facts that were adapted from an upstream catalog, +/// separate from the location that distributes each binary artifact. +/// +public sealed record CatalogProvenance +{ + public CatalogProvenance( + string sourceId, + string title, + string creator, + Uri? sourceUri, + string licenseIdentifier, + Uri licenseUri, + string changes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceId); + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(creator); + ArgumentException.ThrowIfNullOrWhiteSpace(licenseIdentifier); + ArgumentException.ThrowIfNullOrWhiteSpace(changes); + if (sourceUri is not null) + PinnedArtifactValidation.RequireHttps(sourceUri, nameof(sourceUri)); + PinnedArtifactValidation.RequireHttps(licenseUri, nameof(licenseUri)); + + SourceId = sourceId; + Title = title; + Creator = creator; + SourceUri = sourceUri; + LicenseIdentifier = licenseIdentifier; + LicenseUri = licenseUri; + Changes = changes; + } + + public string SourceId { get; } + public string Title { get; } + public string Creator { get; } + public Uri? SourceUri { get; } + public string LicenseIdentifier { get; } + public Uri LicenseUri { get; } + public string Changes { get; } +} + +/// Distribution origin for an immutable artifact. +public abstract record ArtifactSource +{ + public abstract string RepositoryId { get; } + public abstract string ImmutableRevision { get; } + public abstract Uri RepositoryUri { get; } + public abstract Uri RevisionUri { get; } + + internal abstract Uri ResolveDownloadUri(string relativePath); +} + +/// An asset attached to a GitHub release whose tag and commit are both pinned. +public sealed record GitHubReleaseSource : ArtifactSource +{ + public GitHubReleaseSource(string repositoryId, string releaseTag, string commitSha) + { + PinnedArtifactValidation.RequireRepositoryId(repositoryId, nameof(repositoryId)); + ArgumentException.ThrowIfNullOrWhiteSpace(releaseTag); + if (!PinnedArtifactValidation.IsLowerHex(commitSha, 40)) + throw new ArgumentException("A Git commit must contain exactly 40 lowercase hexadecimal characters.", nameof(commitSha)); + if (releaseTag.Any(char.IsWhiteSpace) || releaseTag.Contains('/') || releaseTag.Contains('\\')) + throw new ArgumentException("A GitHub release tag must be a single safe path segment.", nameof(releaseTag)); + + RepositoryId = repositoryId; + ReleaseTag = releaseTag; + CommitSha = commitSha; + } + + public override string RepositoryId { get; } + public string ReleaseTag { get; } + public string CommitSha { get; } + public override string ImmutableRevision => CommitSha; + public override Uri RepositoryUri => new($"https://github.com/{RepositoryId}"); + public override Uri RevisionUri => new($"{RepositoryUri}/releases/tag/{Uri.EscapeDataString(ReleaseTag)}"); + + internal override Uri ResolveDownloadUri(string relativePath) + { + string escapedPath = PinnedArtifactValidation.EscapeRelativePath(relativePath); + return new Uri($"{RepositoryUri}/releases/download/{Uri.EscapeDataString(ReleaseTag)}/{escapedPath}"); + } +} + +/// A file served from an immutable Hugging Face repository revision. +public sealed record HuggingFaceRevisionSource : ArtifactSource +{ + public HuggingFaceRevisionSource(string repositoryId, string revisionSha) + { + PinnedArtifactValidation.RequireRepositoryId(repositoryId, nameof(repositoryId)); + if (!PinnedArtifactValidation.IsLowerHex(revisionSha, 40)) + throw new ArgumentException("A Hugging Face revision must contain exactly 40 lowercase hexadecimal characters.", nameof(revisionSha)); + + RepositoryId = repositoryId; + RevisionSha = revisionSha; + } + + public override string RepositoryId { get; } + public string RevisionSha { get; } + public override string ImmutableRevision => RevisionSha; + public override Uri RepositoryUri => new($"https://huggingface.co/{RepositoryId}"); + public override Uri RevisionUri => new($"{RepositoryUri}/tree/{RevisionSha}"); + + internal override Uri ResolveDownloadUri(string relativePath) + { + string escapedPath = PinnedArtifactValidation.EscapeRelativePath(relativePath); + return new Uri($"{RepositoryUri}/resolve/{RevisionSha}/{escapedPath}?download=true"); + } +} + +/// A content-verified file and the immutable upstream revision that distributes it. +public sealed record PinnedArtifact +{ + public PinnedArtifact( + string id, + ArtifactRole role, + ArtifactSource source, + string relativePath, + long sizeBytes, + Sha256Digest sha256, + CatalogProvenance? catalogProvenance = null) + { + PinnedArtifactValidation.RequireSafeId(id, nameof(id)); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(sha256); + _ = PinnedArtifactValidation.EscapeRelativePath(relativePath); + if (sizeBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(sizeBytes), "Artifact size must be positive."); + + Id = id; + Role = role; + Source = source; + RelativePath = relativePath; + SizeBytes = sizeBytes; + Sha256 = sha256; + CatalogProvenance = catalogProvenance; + } + + public string Id { get; } + public ArtifactRole Role { get; } + public ArtifactSource Source { get; } + public string RelativePath { get; } + public long SizeBytes { get; } + public Sha256Digest Sha256 { get; } + public CatalogProvenance? CatalogProvenance { get; } + public Uri DownloadUri => Source.ResolveDownloadUri(RelativePath); +} + +/// Tracked attribution shared by catalog entries adapted from NVIDIA CAIR. +public static class LocalInferenceCatalogProvenance +{ + public static CatalogProvenance NvidiaCair { get; } = new( + sourceId: "nvidia-cair", + title: "NVIDIA CAIR recipe catalog", + creator: "NVIDIA Corporation", + sourceUri: null, + licenseIdentifier: "CC-BY-4.0", + licenseUri: new Uri("https://creativecommons.org/licenses/by/4.0/"), + changes: "Adapted into typed Windows catalog records with independently verified public artifact pins."); +} + +internal static class PinnedArtifactValidation +{ + public static bool IsLowerHex(string? value, int expectedLength) => + value is not null && + value.Length == expectedLength && + value.All(character => character is >= '0' and <= '9' or >= 'a' and <= 'f'); + + public static void RequireHttps(Uri uri, string parameterName) + { + ArgumentNullException.ThrowIfNull(uri, parameterName); + if (!uri.IsAbsoluteUri || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("The URI must be an absolute HTTPS URI.", parameterName); + } + + public static void RequireRepositoryId(string repositoryId, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryId, parameterName); + string[] segments = repositoryId.Split('/'); + if (segments.Length != 2 || segments.Any(segment => !IsSafeRepositorySegment(segment))) + throw new ArgumentException("A repository id must contain exactly two safe path segments.", parameterName); + } + + public static void RequireSafeId(string id, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id, parameterName); + if (id.Any(character => + !(character is >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '-'))) + { + throw new ArgumentException("An artifact id may contain lowercase ASCII letters, digits, dots, and hyphens only.", parameterName); + } + } + + public static string EscapeRelativePath(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + if (relativePath.Contains('\\') || relativePath.StartsWith('/') || relativePath.EndsWith('/')) + throw new ArgumentException("An artifact path must be a normalized relative URI path.", nameof(relativePath)); + + string[] segments = relativePath.Split('/'); + if (segments.Any(segment => + string.IsNullOrWhiteSpace(segment) || + segment is "." or ".." || + segment.Any(char.IsControl))) + { + throw new ArgumentException("An artifact path contains an unsafe segment.", nameof(relativePath)); + } + + return string.Join('/', segments.Select(Uri.EscapeDataString)); + } + + private static bool IsSafeRepositorySegment(string segment) => + !string.IsNullOrWhiteSpace(segment) && + segment.All(character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.'); +} From d565c1794efbd2100eaa5594e739023d2c8baf64 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:50:19 -0700 Subject: [PATCH 05/23] feat(inference): qualify NVIDIA GPUs by capability Select any NVML-backed NVIDIA adapter with a stable ID, compatible driver and CUDA, and enough memory for the chosen model. Recommend the largest fitting model without GPU SKU allowlists or CPU/GPU pairing. Signed-off-by: Joel Fernandes --- .../Inference/Catalog/LlamaRuntimeCatalog.cs | 5 +- .../Catalog/LocalInferenceEligibility.cs | 186 ++++++++++++++++++ .../Catalog/LocalInferenceSelector.cs | 146 ++++++++++++++ .../LocalInferenceQualificationTests.cs | 134 +++++++++++++ 4 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 src/OpenClaw.Shared/Inference/Catalog/LocalInferenceEligibility.cs create mode 100644 src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs create mode 100644 tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs diff --git a/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs b/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs index 0cdc19029..0266f51dd 100644 --- a/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs +++ b/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs @@ -42,9 +42,8 @@ public LlamaRuntimeVariant( } /// -/// Integrity-pinned native Windows llama.cpp builds for the supported NVIDIA -/// hardware profiles. Unsupported hardware does not receive a CPU or Vulkan -/// fallback from this catalog. +/// Integrity-pinned native Windows llama.cpp builds routed by Windows CPU +/// architecture. Unsupported hardware does not receive a CPU or Vulkan fallback. /// public static class LlamaRuntimeCatalog { diff --git a/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceEligibility.cs b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceEligibility.cs new file mode 100644 index 000000000..c72223ab5 --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceEligibility.cs @@ -0,0 +1,186 @@ +namespace OpenClaw.Shared.Inference.Catalog; + +public enum LocalInferenceEligibilityStatus +{ + Eligible = 0, + EligibleButBusy = 1, + Unsupported = 2, +} + +public enum LocalInferenceEligibilityFailureCode +{ + None = 0, + CatalogSelectionFailed = 1, + HardwareFactsIncomplete = 2, + InsufficientGpuMemory = 3, + DriverTooOld = 4, + CudaCapabilityTooLow = 5, +} + +public sealed record LocalInferenceEligibilityResult( + LocalInferenceEligibilityStatus Status, + LocalInferenceEligibilityFailureCode FailureCode, + LocalInferenceSelectionFailureCode SelectionFailureCode, + LocalInferencePlan? Plan, + GpuInfo? SelectedGpu, + long RequiredTotalMemoryBytes, + long? DetectedTotalMemoryBytes, + long RequiredFreeMemoryBytes, + long? AvailableFreeMemoryBytes) +{ + public bool CanInstall => Status is + LocalInferenceEligibilityStatus.Eligible or + LocalInferenceEligibilityStatus.EligibleButBusy; +} + +/// +/// Applies the pinned model capacity, driver, and CUDA guardrails after catalog +/// selection. Total GPU memory is stable capacity. Free GPU memory is launch +/// readiness and never changes the selected model automatically. +/// +public static class LocalInferenceEligibility +{ + public const long ModelCapacityMarginBytes = LocalInferenceQualificationPolicy.CapacityMarginBytes; + public static Version MinimumNvidiaDriverVersion { get; } = new(615, 0); + + public static long GetRequiredMemoryBytes(LocalModelInfo model) => + LocalInferenceQualificationPolicy.GetRequiredMemoryBytes(model); + + public static LocalInferenceEligibilityResult Evaluate( + HostHardwareInfo hardware, + string? requestedModelId = null) + { + ArgumentNullException.ThrowIfNull(hardware); + + LocalInferenceSelectionResult selection = LocalInferenceSelector.Select(hardware, requestedModelId); + if (!selection.IsSelected || selection.Plan is null) + { + return Unsupported( + LocalInferenceEligibilityFailureCode.CatalogSelectionFailed, + selection.FailureCode); + } + + LocalInferencePlan plan = selection.Plan; + long requiredMemoryBytes = GetRequiredMemoryBytes(plan.Model); + CandidateAssessment? selected = hardware.NvidiaGpus + .Select(gpu => Assess(gpu, plan.Runtime, requiredMemoryBytes)) + .OrderBy(candidate => StatusRank(candidate.Status)) + .ThenByDescending(candidate => candidate.FreeMemoryBytes.HasValue) + .ThenByDescending(candidate => candidate.FreeMemoryBytes ?? long.MinValue) + .ThenByDescending(candidate => candidate.TotalMemoryBytes) + .ThenBy(candidate => candidate.Gpu.StableId ?? string.Empty, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Gpu.Name, StringComparer.Ordinal) + .FirstOrDefault(); + + if (selected is null) + return Unsupported(LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete); + + return new LocalInferenceEligibilityResult( + selected.Status, + selected.FailureCode, + LocalInferenceSelectionFailureCode.None, + plan, + selected.Gpu, + requiredMemoryBytes, + selected.TotalMemoryBytes > 0 ? selected.TotalMemoryBytes : null, + requiredMemoryBytes, + selected.FreeMemoryBytes); + } + + private static LocalInferenceEligibilityResult Unsupported( + LocalInferenceEligibilityFailureCode failureCode, + LocalInferenceSelectionFailureCode selectionFailureCode = LocalInferenceSelectionFailureCode.None, + GpuInfo? selectedGpu = null) => + new( + LocalInferenceEligibilityStatus.Unsupported, + failureCode, + selectionFailureCode, + null, + selectedGpu, + 0, + selectedGpu is null ? null : LocalInferenceQualificationPolicy.GetEffectiveTotalMemoryBytes(selectedGpu), + 0, + selectedGpu is null ? null : LocalInferenceQualificationPolicy.GetEffectiveFreeMemoryBytes(selectedGpu)); + + private static CandidateAssessment Assess( + GpuInfo gpu, + LlamaRuntimeVariant runtime, + long requiredMemoryBytes) + { + long totalMemoryBytes = LocalInferenceQualificationPolicy.GetEffectiveTotalMemoryBytes(gpu); + long? freeMemoryBytes = LocalInferenceQualificationPolicy.GetEffectiveFreeMemoryBytes(gpu); + if (!LocalInferenceQualificationPolicy.HasCompleteFacts(gpu)) + { + return UnsupportedCandidate( + gpu, + LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete, + totalMemoryBytes, + freeMemoryBytes); + } + + if (!Version.TryParse(gpu.DriverVersion, out Version? driverVersion) || + driverVersion < MinimumNvidiaDriverVersion) + { + return UnsupportedCandidate( + gpu, + LocalInferenceEligibilityFailureCode.DriverTooOld, + totalMemoryBytes, + freeMemoryBytes); + } + + if (gpu.CudaMajorVersion < runtime.CudaVersion.Major) + { + return UnsupportedCandidate( + gpu, + LocalInferenceEligibilityFailureCode.CudaCapabilityTooLow, + totalMemoryBytes, + freeMemoryBytes); + } + + if (totalMemoryBytes < requiredMemoryBytes) + { + return UnsupportedCandidate( + gpu, + LocalInferenceEligibilityFailureCode.InsufficientGpuMemory, + totalMemoryBytes, + freeMemoryBytes); + } + + LocalInferenceEligibilityStatus status = + freeMemoryBytes is not null && freeMemoryBytes < requiredMemoryBytes + ? LocalInferenceEligibilityStatus.EligibleButBusy + : LocalInferenceEligibilityStatus.Eligible; + return new CandidateAssessment( + gpu, + status, + LocalInferenceEligibilityFailureCode.None, + totalMemoryBytes, + freeMemoryBytes); + } + + private static CandidateAssessment UnsupportedCandidate( + GpuInfo gpu, + LocalInferenceEligibilityFailureCode failureCode, + long totalMemoryBytes, + long? freeMemoryBytes) => + new( + gpu, + LocalInferenceEligibilityStatus.Unsupported, + failureCode, + totalMemoryBytes, + freeMemoryBytes); + + private static int StatusRank(LocalInferenceEligibilityStatus status) => status switch + { + LocalInferenceEligibilityStatus.Eligible => 0, + LocalInferenceEligibilityStatus.EligibleButBusy => 1, + _ => 2, + }; + + private sealed record CandidateAssessment( + GpuInfo Gpu, + LocalInferenceEligibilityStatus Status, + LocalInferenceEligibilityFailureCode FailureCode, + long TotalMemoryBytes, + long? FreeMemoryBytes); +} diff --git a/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs new file mode 100644 index 000000000..dff206adf --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs @@ -0,0 +1,146 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Shared.Inference.Catalog; + +/// Whether catalog selection produced a complete native inference plan. +public enum LocalInferenceSelectionStatus +{ + Selected = 0, + Unsupported = 1, +} + +/// Stable reason returned when no inference plan can be selected. +public enum LocalInferenceSelectionFailureCode +{ + None = 0, + RuntimeUnavailable = 1, + NoNvidiaGpu = 2, + UnknownModel = 3, +} + +/// Whether a caller accepted the catalog default or named a model explicitly. +public enum LocalInferenceModelSelectionOrigin +{ + Default = 0, + Explicit = 1, +} + +/// A complete, immutable native inference choice. +public sealed record LocalInferencePlan( + LlamaRuntimeVariant Runtime, + LocalModelInfo Model, + LocalInferenceModelSelectionOrigin ModelSelectionOrigin); + +/// The deterministic result of selecting from the pinned local inference catalog. +public sealed record LocalInferenceSelectionResult +{ + private LocalInferenceSelectionResult( + LocalInferenceSelectionStatus status, + LocalInferenceSelectionFailureCode failureCode, + LocalInferencePlan? plan) + { + Status = status; + FailureCode = failureCode; + Plan = plan; + } + + public LocalInferenceSelectionStatus Status { get; } + public LocalInferenceSelectionFailureCode FailureCode { get; } + public LocalInferencePlan? Plan { get; } + public bool IsSelected => Status == LocalInferenceSelectionStatus.Selected; + + internal static LocalInferenceSelectionResult Selected(LocalInferencePlan plan) => + new(LocalInferenceSelectionStatus.Selected, LocalInferenceSelectionFailureCode.None, plan); + + internal static LocalInferenceSelectionResult Unsupported(LocalInferenceSelectionFailureCode failureCode) => + new(LocalInferenceSelectionStatus.Unsupported, failureCode, null); +} + +/// +/// Pure selection from a hardware snapshot and optional model ID. The CPU +/// architecture chooses only the native runtime. GPU names and CPU/GPU SKU +/// pairings are not part of qualification. +/// +public static class LocalInferenceSelector +{ + public static LocalInferenceSelectionResult Select( + HostHardwareInfo hardware, + string? requestedModelId = null) + { + ArgumentNullException.ThrowIfNull(hardware); + + LlamaRuntimeVariant? runtime = LlamaRuntimeCatalog.Find(hardware.CpuArchitecture); + if (runtime is null) + return LocalInferenceSelectionResult.Unsupported( + LocalInferenceSelectionFailureCode.RuntimeUnavailable); + + if (!hardware.HasNvidiaGpu) + return LocalInferenceSelectionResult.Unsupported(LocalInferenceSelectionFailureCode.NoNvidiaGpu); + + LocalModelInfo? model; + LocalInferenceModelSelectionOrigin modelSelectionOrigin; + if (string.IsNullOrWhiteSpace(requestedModelId)) + { + model = LocalModelCatalog.Models + .OrderByDescending(candidate => candidate.Weights.SizeBytes) + .FirstOrDefault(candidate => hardware.NvidiaGpus.Any(gpu => + LocalInferenceQualificationPolicy.HasRuntimePrerequisites(gpu, runtime) && + LocalInferenceQualificationPolicy.GetEffectiveTotalMemoryBytes(gpu) >= + LocalInferenceQualificationPolicy.GetRequiredMemoryBytes(candidate))) + ?? LocalModelCatalog.Models.OrderBy(candidate => candidate.Weights.SizeBytes).First(); + modelSelectionOrigin = LocalInferenceModelSelectionOrigin.Default; + } + else + { + model = LocalModelCatalog.Find(requestedModelId); + if (model is null) + return LocalInferenceSelectionResult.Unsupported(LocalInferenceSelectionFailureCode.UnknownModel); + modelSelectionOrigin = LocalInferenceModelSelectionOrigin.Explicit; + } + + return LocalInferenceSelectionResult.Selected( + new LocalInferencePlan(runtime, model, modelSelectionOrigin)); + } +} + +internal static class LocalInferenceQualificationPolicy +{ + public const long CapacityMarginBytes = 2L * 1024 * 1024 * 1024; + + public static bool HasCompleteFacts(GpuInfo gpu) => + IsStableGpuId(gpu.StableId) && + gpu.GpuVisibleMemoryBytes is > 0 && + !string.IsNullOrWhiteSpace(gpu.DriverVersion) && + gpu.CudaMajorVersion is not null; + + public static bool HasRuntimePrerequisites(GpuInfo gpu, LlamaRuntimeVariant runtime) + { + ArgumentNullException.ThrowIfNull(gpu); + ArgumentNullException.ThrowIfNull(runtime); + return HasCompleteFacts(gpu) && + Version.TryParse(gpu.DriverVersion, out Version? driverVersion) && + driverVersion >= LocalInferenceEligibility.MinimumNvidiaDriverVersion && + gpu.CudaMajorVersion >= runtime.CudaVersion.Major; + } + + public static long GetRequiredMemoryBytes(LocalModelInfo model) + { + ArgumentNullException.ThrowIfNull(model); + return SaturatingAdd(model.Weights.SizeBytes, CapacityMarginBytes); + } + + public static long GetEffectiveTotalMemoryBytes(GpuInfo gpu) => + gpu.GpuVisibleMemoryBytes is not > 0 + ? 0 + : gpu.GpuVisibleMemoryBytes.Value; + + public static long? GetEffectiveFreeMemoryBytes(GpuInfo gpu) => + gpu.FreeGpuVisibleMemoryBytes is >= 0 ? gpu.FreeGpuVisibleMemoryBytes : null; + + private static bool IsStableGpuId(string? value) => + !string.IsNullOrWhiteSpace(value) && + !value.Any(character => char.IsControl(character) || char.IsWhiteSpace(character)); + + public static long SaturatingAdd(long left, long right) => + right > long.MaxValue - left ? long.MaxValue : left + right; +} diff --git a/tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs b/tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs new file mode 100644 index 000000000..922e15f1c --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs @@ -0,0 +1,134 @@ +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; +using RuntimeArchitecture = System.Runtime.InteropServices.Architecture; + +namespace OpenClaw.Shared.Tests; + +public class LocalInferenceQualificationTests +{ + private const long GiB = 1024L * 1024 * 1024; + + [Theory] + [InlineData(RuntimeArchitecture.X64, "NVIDIA RTX Spark N1X", LlamaRuntimeCatalog.X64RuntimeId)] + [InlineData(RuntimeArchitecture.Arm64, "NVIDIA GeForce RTX 5090", LlamaRuntimeCatalog.Arm64RuntimeId)] + public void Evaluate_RoutesRuntimeByArchitectureWithoutGpuSkuPairing( + RuntimeArchitecture architecture, + string gpuName, + string expectedRuntimeId) + { + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate( + Hardware(architecture, Gpu(gpuName, "GPU-generic", totalGiB: 32, freeGiB: 32))); + + Assert.Equal(LocalInferenceEligibilityStatus.Eligible, result.Status); + Assert.Equal(expectedRuntimeId, result.Plan?.Runtime.Id); + } + + [Fact] + public void Evaluate_UnsetModelChoosesLargestModelThatFitsTotalCapacity() + { + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate( + Hardware(RuntimeArchitecture.X64, Gpu("NVIDIA arbitrary adapter", "GPU-16", 16, 16))); + + Assert.Equal(LocalInferenceEligibilityStatus.Eligible, result.Status); + Assert.Equal(LocalModelCatalog.Qwen9BModelId, result.Plan?.Model.Id); + Assert.Equal(LocalInferenceModelSelectionOrigin.Default, result.Plan?.ModelSelectionOrigin); + } + + [Fact] + public void Evaluate_ExplicitModelNeverDowngradesAndReportsExactCapacity() + { + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate( + Hardware(RuntimeArchitecture.X64, Gpu("NVIDIA arbitrary adapter", "GPU-16", 16, 16)), + LocalModelCatalog.Qwen35BModelId); + + Assert.Equal(LocalInferenceEligibilityStatus.Unsupported, result.Status); + Assert.Equal(LocalInferenceEligibilityFailureCode.InsufficientGpuMemory, result.FailureCode); + Assert.Equal(LocalModelCatalog.Qwen35BModelId, result.Plan?.Model.Id); + Assert.Equal(LocalModelCatalog.Default.Weights.SizeBytes + 2 * GiB, result.RequiredTotalMemoryBytes); + Assert.Equal(16 * GiB, result.DetectedTotalMemoryBytes); + } + + [Fact] + public void Evaluate_RanksEligibleBeforeBusyAndUnsupportedAdapters() + { + GpuInfo unsupported = Gpu("NVIDIA old", "GPU-old", 48, 48) with { DriverVersion = "614.99" }; + GpuInfo busy = Gpu("NVIDIA busy", "GPU-busy", 32, 1); + GpuInfo eligible = Gpu("NVIDIA ready", "GPU-ready", 16, 12); + + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate( + Hardware(RuntimeArchitecture.X64, unsupported, busy, eligible), + LocalModelCatalog.Qwen9BModelId); + + Assert.Equal(LocalInferenceEligibilityStatus.Eligible, result.Status); + Assert.Equal("GPU-ready", result.SelectedGpu?.StableId); + } + + [Fact] + public void Evaluate_RanksEligibleAdaptersByFreeThenTotalThenUuid() + { + GpuInfo moreTotal = Gpu("NVIDIA total", "GPU-z", 48, 12); + GpuInfo moreFree = Gpu("NVIDIA free", "GPU-b", 16, 14); + GpuInfo sameFreeAndTotalLowerUuid = Gpu("NVIDIA tie", "GPU-a", 16, 14); + + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate( + Hardware(RuntimeArchitecture.X64, moreTotal, moreFree, sameFreeAndTotalLowerUuid), + LocalModelCatalog.Qwen9BModelId); + + Assert.Equal("GPU-a", result.SelectedGpu?.StableId); + } + + [Theory] + [InlineData(null, "616.30", 13, LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete)] + [InlineData("GPU-old", "614.99", 13, LocalInferenceEligibilityFailureCode.DriverTooOld)] + [InlineData("GPU-cuda", "616.30", 12, LocalInferenceEligibilityFailureCode.CudaCapabilityTooLow)] + public void Evaluate_RequiresStableUuidDriverAndCuda( + string? stableId, + string driverVersion, + int cudaMajor, + LocalInferenceEligibilityFailureCode expectedFailure) + { + GpuInfo gpu = Gpu("NVIDIA arbitrary", stableId, 32, 32) with + { + DriverVersion = driverVersion, + CudaMajorVersion = cudaMajor, + }; + + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate( + Hardware(RuntimeArchitecture.X64, gpu)); + + Assert.Equal(LocalInferenceEligibilityStatus.Unsupported, result.Status); + Assert.Equal(expectedFailure, result.FailureCode); + } + + [Fact] + public void Evaluate_ReportsNoNvidiaGpu() + { + var hardware = new HostHardwareInfo( + RuntimeArchitecture.X64, + null, + null, + [new GpuInfo(GpuVendor.Amd, "AMD GPU")], + false); + + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate(hardware); + + Assert.Equal(LocalInferenceSelectionFailureCode.NoNvidiaGpu, result.SelectionFailureCode); + } + + private static HostHardwareInfo Hardware(RuntimeArchitecture architecture, params GpuInfo[] gpus) => + new(architecture, 64 * GiB, 48 * GiB, gpus, false); + + private static GpuInfo Gpu( + string name, + string? stableId, + long totalGiB, + long freeGiB) => + new( + GpuVendor.Nvidia, + name, + totalGiB * GiB, + freeGiB * GiB, + DriverVersion: "616.30", + CudaMajorVersion: 13, + StableId: stableId); +} From 4e692ea303eac603a9a484eef5a7c548bbdba376 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:50:35 -0700 Subject: [PATCH 06/23] feat(inference): probe NVIDIA hardware through trusted NVML Load NVML from trusted locations and collect NVIDIA GPU capabilities. Avoid unsafe library resolution while providing selector-grade hardware data. Signed-off-by: Joel Fernandes --- .../Inference/HostHardwareInfo.cs | 78 ++---- .../Inference/NvmlHostHardwareProbe.cs | 263 ++++++++++++++++++ 2 files changed, 280 insertions(+), 61 deletions(-) create mode 100644 src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs diff --git a/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs b/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs index b06fb41fa..ec3a85b2f 100644 --- a/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs +++ b/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs @@ -24,28 +24,31 @@ public enum GpuVendor /// /// Classified vendor. /// Adapter name as reported by the source (e.g. "NVIDIA RTX 6000 Ada Generation"). -/// -/// Dedicated video memory in bytes, or null when unknown. Only ever populated -/// from a trustworthy source (nvidia-smi). WMI's Win32_VideoController.AdapterRAM -/// is a 32-bit field that wraps above 4 GB, so the WMI fallback deliberately -/// leaves this null rather than reporting a wrong number. +/// +/// CUDA-visible memory in bytes, or null when unknown. On a discrete GPU this +/// is dedicated VRAM. On a unified-memory SKU it is the configured GPU-visible +/// allocation. The value must come from a trustworthy driver API, never the +/// 32-bit Win32_VideoController.AdapterRAM field. /// +/// Currently free CUDA-visible memory, or null when unknown. /// Display driver version, when known. /// -/// Major version of the CUDA runtime the driver supports, when known. Drives the -/// choice between the CUDA 12.x and CUDA 13.x llama.cpp builds. +/// Major version of the CUDA driver API the display driver supports, when known. /// +/// A driver-provided stable adapter identifier, such as an NVML UUID. public sealed record GpuInfo( GpuVendor Vendor, string Name, - long? DedicatedMemoryBytes = null, + long? GpuVisibleMemoryBytes = null, + long? FreeGpuVisibleMemoryBytes = null, string? DriverVersion = null, - int? CudaMajorVersion = null); + int? CudaMajorVersion = null, + string? StableId = null); /// -/// Snapshot of the host's inference-relevant hardware. Every field is optional: -/// the probe never throws, and unknown values degrade to null so the backend -/// selector falls through to CPU rather than guessing. +/// Snapshot of the host's inference-relevant hardware. Every probed field is +/// optional. Unknown values remain unknown so a qualified selector can fail +/// closed instead of guessing a backend or recipe. /// /// OS architecture (x64 / Arm64 in practice). /// Installed system RAM, or null when the query failed. @@ -60,8 +63,8 @@ public sealed record HostHardwareInfo( bool VulkanAvailable) { /// - /// The "we learned nothing" result. Used when every probe path failed; the - /// selector maps this to the CPU backend. + /// The "we learned nothing" result. Qualified selectors must treat it as + /// unsupported rather than selecting a fallback backend. /// public static HostHardwareInfo Unknown { get; } = new( RuntimeInformation.OSArchitecture, @@ -76,51 +79,4 @@ public sealed record HostHardwareInfo( /// True when at least one NVIDIA adapter was detected. public bool HasNvidiaGpu => Gpus.Any(g => g.Vendor == GpuVendor.Nvidia); - /// - /// True when a non-NVIDIA adapter that a Vulkan build could drive was detected. - /// does not count: an unclassified adapter is - /// not evidence that a Vulkan build will work. - /// - public bool HasNonNvidiaGpu => - Gpus.Any(g => g.Vendor is GpuVendor.Amd or GpuVendor.Intel or GpuVendor.Other); - - /// - /// Combined dedicated VRAM across all NVIDIA adapters whose size is known, or - /// null when no NVIDIA adapter reported a size. llama.cpp's default - /// --split-mode layer spreads a model across every visible device, so - /// the sum (not the maximum) is the capacity that matters for model fit. - /// - public long? TotalNvidiaVramBytes - { - get - { - long total = 0; - var sawAny = false; - foreach (var gpu in NvidiaGpus) - { - if (gpu.DedicatedMemoryBytes is not { } bytes || bytes <= 0) continue; - total += bytes; - sawAny = true; - } - return sawAny ? total : null; - } - } - - /// - /// Highest CUDA major version reported by any NVIDIA adapter, or null when - /// unknown. Null must be treated as "assume the older CUDA build". - /// - public int? MaxCudaMajorVersion - { - get - { - int? best = null; - foreach (var gpu in NvidiaGpus) - { - if (gpu.CudaMajorVersion is not { } major) continue; - if (best is null || major > best) best = major; - } - return best; - } - } } diff --git a/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs b/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs new file mode 100644 index 000000000..bb14f9277 --- /dev/null +++ b/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs @@ -0,0 +1,263 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace OpenClaw.Shared.Inference; + +public interface IHostHardwareProbe +{ + HostHardwareInfo Probe(); +} + +/// +/// Reads NVIDIA GPU identity and CUDA-visible memory through the NVML library +/// installed by the Windows display driver. The probe loads only explicit +/// driver-owned paths and returns unknown facts instead of guessing. +/// +public sealed class NvmlHostHardwareProbe : IHostHardwareProbe +{ + private readonly Func _captureNvml; + private readonly Func _readPhysicalMemory; + private readonly Architecture _architecture; + + public NvmlHostHardwareProbe() + : this(CaptureNvml, PhysicalMemoryProbe.TryRead, RuntimeInformation.OSArchitecture) + { + } + + internal NvmlHostHardwareProbe( + Func captureNvml, + Func readPhysicalMemory, + Architecture architecture) + { + _captureNvml = captureNvml ?? throw new ArgumentNullException(nameof(captureNvml)); + _readPhysicalMemory = readPhysicalMemory ?? throw new ArgumentNullException(nameof(readPhysicalMemory)); + _architecture = architecture; + } + + public HostHardwareInfo Probe() + { + PhysicalMemorySnapshot? memory = null; + NvmlProbeResult nvml = NvmlProbeResult.Empty; + try + { + memory = _readPhysicalMemory(); + } + catch + { + // Hardware discovery is fail-closed. Unknown RAM remains null. + } + + try + { + nvml = _captureNvml(); + } + catch + { + // Hardware discovery is fail-closed. Unknown GPUs remain absent. + } + + var gpus = nvml.Devices + .Where(device => + device.TotalMemoryBytes is > 0 and <= long.MaxValue && + device.FreeMemoryBytes <= device.TotalMemoryBytes && + !string.IsNullOrWhiteSpace(device.Name)) + .Select(device => new GpuInfo( + GpuVendor.Nvidia, + device.Name.Trim(), + (long)device.TotalMemoryBytes, + (long)device.FreeMemoryBytes, + nvml.DriverVersion, + nvml.CudaMajorVersion, + string.IsNullOrWhiteSpace(device.Uuid) ? null : device.Uuid.Trim())) + .ToArray(); + + return new HostHardwareInfo( + _architecture, + memory?.TotalBytes, + memory?.AvailableBytes, + gpus, + VulkanAvailable: false); + } + + internal static IReadOnlyList GetNvmlLibraryCandidates() + { + string[] candidates = + [ + Path.Combine(Environment.SystemDirectory, "nvml.dll"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "NVIDIA Corporation", + "NVSMI", + "nvml.dll"), + ]; + + return candidates + .Where(Path.IsPathFullyQualified) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static NvmlProbeResult CaptureNvml() + { + if (!OperatingSystem.IsWindows() || !TryLoadNvml(out IntPtr library)) + return NvmlProbeResult.Empty; + + bool initialized = false; + NvmlShutdown? shutdown = null; + try + { + var initialize = GetDelegate(library, "nvmlInit_v2"); + shutdown = GetDelegate(library, "nvmlShutdown"); + var getCount = GetDelegate(library, "nvmlDeviceGetCount_v2"); + var getHandle = GetDelegate(library, "nvmlDeviceGetHandleByIndex_v2"); + var getName = GetDelegate(library, "nvmlDeviceGetName"); + var getUuid = GetDelegate(library, "nvmlDeviceGetUUID"); + var getMemory = GetDelegate(library, "nvmlDeviceGetMemoryInfo"); + var getDriver = GetDelegate(library, "nvmlSystemGetDriverVersion"); + var getCuda = GetDelegate(library, "nvmlSystemGetCudaDriverVersion_v2"); + + if (initialize() != NvmlSuccess) + return NvmlProbeResult.Empty; + initialized = true; + + string? driverVersion = ReadSystemString(getDriver, DriverVersionCapacity); + int? cudaMajorVersion = getCuda(out int cudaDriverVersion) == NvmlSuccess && cudaDriverVersion > 0 + ? cudaDriverVersion / 1000 + : null; + + if (getCount(out uint count) != NvmlSuccess) + return new NvmlProbeResult([], driverVersion, cudaMajorVersion); + + var devices = new List(); + for (uint index = 0; index < count; index++) + { + if (getHandle(index, out IntPtr device) != NvmlSuccess || + getMemory(device, out NvmlMemory memory) != NvmlSuccess || + memory.Total == 0) + { + continue; + } + + string? name = ReadDeviceString(device, getName, DeviceNameCapacity); + if (string.IsNullOrWhiteSpace(name)) + continue; + string? uuid = ReadDeviceString(device, getUuid, DeviceUuidCapacity); + devices.Add(new NvmlGpuSnapshot(name, uuid, memory.Total, memory.Free)); + } + + return new NvmlProbeResult(devices, driverVersion, cudaMajorVersion); + } + catch (Exception exception) when (exception is + EntryPointNotFoundException or + BadImageFormatException or + MarshalDirectiveException or + SEHException) + { + return NvmlProbeResult.Empty; + } + finally + { + try + { + if (initialized) + shutdown?.Invoke(); + } + finally + { + NativeLibrary.Free(library); + } + } + } + + private static bool TryLoadNvml(out IntPtr library) + { + foreach (string candidate in GetNvmlLibraryCandidates()) + { + try + { + if (NativeLibrary.TryLoad(candidate, out library)) + return true; + } + catch (BadImageFormatException) + { + // Try the next explicit driver-owned candidate. + } + } + + library = IntPtr.Zero; + return false; + } + + private static T GetDelegate(IntPtr library, string exportName) where T : Delegate => + Marshal.GetDelegateForFunctionPointer(NativeLibrary.GetExport(library, exportName)); + + private static string? ReadSystemString(NvmlSystemGetString getter, uint capacity) + { + var buffer = new byte[capacity]; + return getter(buffer, capacity) == NvmlSuccess ? DecodeUtf8(buffer) : null; + } + + private static string? ReadDeviceString(IntPtr device, NvmlDeviceGetString getter, uint capacity) + { + var buffer = new byte[capacity]; + return getter(device, buffer, capacity) == NvmlSuccess ? DecodeUtf8(buffer) : null; + } + + private static string? DecodeUtf8(byte[] buffer) + { + int terminator = Array.IndexOf(buffer, (byte)0); + string value = Encoding.UTF8.GetString(buffer, 0, terminator >= 0 ? terminator : buffer.Length).Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private const int NvmlSuccess = 0; + private const uint DriverVersionCapacity = 96; + private const uint DeviceNameCapacity = 192; + private const uint DeviceUuidCapacity = 96; + + [StructLayout(LayoutKind.Sequential)] + private struct NvmlMemory + { + public ulong Total; + public ulong Free; + public ulong Used; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlInitialize(); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlShutdown(); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlSystemGetString([Out] byte[] value, uint length); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlSystemGetCudaDriverVersion(out int cudaDriverVersion); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlDeviceGetCount(out uint count); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlDeviceGetHandleByIndex(uint index, out IntPtr device); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlDeviceGetString(IntPtr device, [Out] byte[] value, uint length); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int NvmlDeviceGetMemoryInfo(IntPtr device, out NvmlMemory memory); +} + +internal sealed record NvmlGpuSnapshot( + string Name, + string? Uuid, + ulong TotalMemoryBytes, + ulong FreeMemoryBytes); + +internal sealed record NvmlProbeResult( + IReadOnlyList Devices, + string? DriverVersion, + int? CudaMajorVersion) +{ + public static NvmlProbeResult Empty { get; } = new([], null, null); +} From 1489369c4ec4bda8dd0173ee41c1f239f4d246a2 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Thu, 20 Aug 2026 14:53:54 -0700 Subject: [PATCH 07/23] improve(inference): account for shared NVIDIA GPU memory Correlate NVML adapters with unambiguous DXGI memory observations and count shared memory for any NVIDIA GPU. Fail closed on duplicate or ambiguous adapter names so model selection cannot borrow another device's budget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Joel Fernandes --- src/OpenClaw.Shared/AssemblyInfo.cs | 3 + .../Catalog/LocalInferenceSelector.cs | 20 +- .../Inference/DxgiGpuMemoryProbe.cs | 212 ++++++++++++++++++ .../Inference/HostHardwareInfo.cs | 9 + .../Inference/NvmlHostHardwareProbe.cs | 85 ++++++- .../LocalInferenceQualificationTests.cs | 120 ++++++++++ 6 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 src/OpenClaw.Shared/AssemblyInfo.cs create mode 100644 src/OpenClaw.Shared/Inference/DxgiGpuMemoryProbe.cs diff --git a/src/OpenClaw.Shared/AssemblyInfo.cs b/src/OpenClaw.Shared/AssemblyInfo.cs new file mode 100644 index 000000000..7414d855f --- /dev/null +++ b/src/OpenClaw.Shared/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenClaw.Shared.Tests")] diff --git a/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs index dff206adf..dbe9c5fdf 100644 --- a/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs +++ b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs @@ -132,10 +132,24 @@ public static long GetRequiredMemoryBytes(LocalModelInfo model) public static long GetEffectiveTotalMemoryBytes(GpuInfo gpu) => gpu.GpuVisibleMemoryBytes is not > 0 ? 0 - : gpu.GpuVisibleMemoryBytes.Value; + : SaturatingAdd( + gpu.GpuVisibleMemoryBytes.Value, + gpu.SharedGpuMemoryBytes is > 0 ? gpu.SharedGpuMemoryBytes.Value : 0); - public static long? GetEffectiveFreeMemoryBytes(GpuInfo gpu) => - gpu.FreeGpuVisibleMemoryBytes is >= 0 ? gpu.FreeGpuVisibleMemoryBytes : null; + public static long? GetEffectiveFreeMemoryBytes(GpuInfo gpu) + { + if (gpu.FreeGpuVisibleMemoryBytes is not >= 0) + return null; + + if (gpu.SharedGpuMemoryBytes is > 0 && gpu.FreeSharedGpuMemoryBytes is null) + return null; + + return SaturatingAdd( + gpu.FreeGpuVisibleMemoryBytes.Value, + gpu.SharedGpuMemoryBytes is > 0 && gpu.FreeSharedGpuMemoryBytes is > 0 + ? gpu.FreeSharedGpuMemoryBytes.Value + : 0); + } private static bool IsStableGpuId(string? value) => !string.IsNullOrWhiteSpace(value) && diff --git a/src/OpenClaw.Shared/Inference/DxgiGpuMemoryProbe.cs b/src/OpenClaw.Shared/Inference/DxgiGpuMemoryProbe.cs new file mode 100644 index 000000000..509a59118 --- /dev/null +++ b/src/OpenClaw.Shared/Inference/DxgiGpuMemoryProbe.cs @@ -0,0 +1,212 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Shared.Inference; + +/// +/// Reads the shared-GPU-memory capacity and current DXGI budget for NVIDIA +/// adapters. NVML reports dedicated CUDA memory only, while Task Manager's GPU +/// memory total also includes this DXGI-reported shared allocation. +/// +internal static class DxgiGpuMemoryProbe +{ + private const uint NvidiaVendorId = 0x10DE; + private const int DxgiErrorNotFound = unchecked((int)0x887A0002); + private static readonly Guid IidDxgiFactory1 = new("770AAE78-F26F-4DBA-A829-253C83D1B387"); + private static readonly Guid IidDxgiAdapter3 = new("645967A4-1392-4310-A798-8053CE3E93FD"); + + public static IReadOnlyDictionary CaptureNvidiaMemoryByName() + { + if (!OperatingSystem.IsWindows()) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + return Capture(); + } + catch (DllNotFoundException) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + catch (EntryPointNotFoundException) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + private static IReadOnlyDictionary Capture() + { + Guid factoryId = IidDxgiFactory1; + int createResult = CreateDXGIFactory1(ref factoryId, out IntPtr factory); + if (createResult < 0 || factory == IntPtr.Zero) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ambiguousNames = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + var enumerateAdapters = GetDelegate(factory, 12); + for (uint index = 0; ; index++) + { + int enumerateResult = enumerateAdapters(factory, index, out IntPtr adapter); + if (enumerateResult == DxgiErrorNotFound) + break; + if (enumerateResult < 0 || adapter == IntPtr.Zero) + continue; + + try + { + AddNvidiaAdapterMemory(adapter, results, ambiguousNames); + } + finally + { + Marshal.Release(adapter); + } + } + } + finally + { + Marshal.Release(factory); + } + + return results; + } + + private static void AddNvidiaAdapterMemory( + IntPtr adapter, + IDictionary results, + ISet ambiguousNames) + { + var getDescription = GetDelegate(adapter, 10); + if (getDescription(adapter, out DxgiAdapterDescription description) < 0 || + description.VendorId != NvidiaVendorId || + string.IsNullOrWhiteSpace(description.Description)) + { + return; + } + + long? sharedMemoryBytes = ToInt64(description.SharedSystemMemory); + long? freeSharedMemoryBytes = QueryFreeSharedMemory(adapter); + AddMemoryByName( + results, + ambiguousNames, + description.Description, + new DxgiGpuMemoryInfo(sharedMemoryBytes, freeSharedMemoryBytes)); + } + + internal static void AddMemoryByName( + IDictionary results, + ISet ambiguousNames, + string adapterName, + DxgiGpuMemoryInfo memory) + { + ArgumentNullException.ThrowIfNull(results); + ArgumentNullException.ThrowIfNull(ambiguousNames); + ArgumentException.ThrowIfNullOrWhiteSpace(adapterName); + ArgumentNullException.ThrowIfNull(memory); + + string normalizedName = NormalizeName(adapterName); + if (ambiguousNames.Contains(normalizedName)) + return; + if (results.ContainsKey(normalizedName)) + { + results.Remove(normalizedName); + ambiguousNames.Add(normalizedName); + return; + } + + results.Add(normalizedName, memory); + } + + private static long? QueryFreeSharedMemory(IntPtr adapter) + { + var queryInterface = GetDelegate(adapter, 0); + Guid adapter3Id = IidDxgiAdapter3; + if (queryInterface(adapter, ref adapter3Id, out IntPtr adapter3) < 0 || adapter3 == IntPtr.Zero) + return null; + + try + { + var queryMemory = GetDelegate(adapter3, 14); + if (queryMemory(adapter3, 0, DxgiMemorySegmentGroup.NonLocal, out DxgiVideoMemoryInfo memory) < 0 || + memory.Budget == 0 || + memory.Budget < memory.CurrentUsage) + { + return null; + } + + return ToInt64(memory.Budget - memory.CurrentUsage); + } + finally + { + Marshal.Release(adapter3); + } + } + + private static T GetDelegate(IntPtr instance, int index) where T : Delegate + { + IntPtr vtable = Marshal.ReadIntPtr(instance); + IntPtr function = Marshal.ReadIntPtr(vtable, index * IntPtr.Size); + return Marshal.GetDelegateForFunctionPointer(function); + } + + private static long? ToInt64(ulong value) => + value <= long.MaxValue ? (long)value : null; + + private static string NormalizeName(string value) => + string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + [DllImport("dxgi.dll", ExactSpelling = true)] + private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr factory); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int QueryInterface(IntPtr instance, ref Guid interfaceId, out IntPtr result); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int EnumAdapters1(IntPtr instance, uint index, out IntPtr adapter); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int GetDesc1(IntPtr instance, out DxgiAdapterDescription description); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int QueryVideoMemoryInfo( + IntPtr instance, + uint nodeIndex, + DxgiMemorySegmentGroup segmentGroup, + out DxgiVideoMemoryInfo memoryInfo); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct DxgiAdapterDescription + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string Description; + public uint VendorId; + public uint DeviceId; + public uint SubSystemId; + public uint Revision; + public ulong DedicatedVideoMemory; + public ulong DedicatedSystemMemory; + public ulong SharedSystemMemory; + public uint AdapterLuidLowPart; + public int AdapterLuidHighPart; + public uint Flags; + } + + private enum DxgiMemorySegmentGroup + { + Local = 0, + NonLocal = 1, + } + + [StructLayout(LayoutKind.Sequential)] + private struct DxgiVideoMemoryInfo + { + public ulong Budget; + public ulong CurrentUsage; + public ulong AvailableForReservation; + public ulong CurrentReservation; + } +} + +internal sealed record DxgiGpuMemoryInfo( + long? SharedMemoryBytes, + long? FreeSharedMemoryBytes); diff --git a/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs b/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs index ec3a85b2f..dea91bc7b 100644 --- a/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs +++ b/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs @@ -31,6 +31,13 @@ public enum GpuVendor /// 32-bit Win32_VideoController.AdapterRAM field. /// /// Currently free CUDA-visible memory, or null when unknown. +/// +/// GPU-addressable shared system memory reported by DXGI, or null when unknown. +/// This is not general available system RAM. +/// +/// +/// Currently available shared GPU memory reported by DXGI, or null when unknown. +/// /// Display driver version, when known. /// /// Major version of the CUDA driver API the display driver supports, when known. @@ -41,6 +48,8 @@ public sealed record GpuInfo( string Name, long? GpuVisibleMemoryBytes = null, long? FreeGpuVisibleMemoryBytes = null, + long? SharedGpuMemoryBytes = null, + long? FreeSharedGpuMemoryBytes = null, string? DriverVersion = null, int? CudaMajorVersion = null, string? StableId = null); diff --git a/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs b/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs index bb14f9277..7f6aaa089 100644 --- a/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs +++ b/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs @@ -17,20 +17,27 @@ public sealed class NvmlHostHardwareProbe : IHostHardwareProbe { private readonly Func _captureNvml; private readonly Func _readPhysicalMemory; + private readonly Func> _captureDxgiMemory; private readonly Architecture _architecture; public NvmlHostHardwareProbe() - : this(CaptureNvml, PhysicalMemoryProbe.TryRead, RuntimeInformation.OSArchitecture) + : this( + CaptureNvml, + PhysicalMemoryProbe.TryRead, + DxgiGpuMemoryProbe.CaptureNvidiaMemoryByName, + RuntimeInformation.OSArchitecture) { } internal NvmlHostHardwareProbe( Func captureNvml, Func readPhysicalMemory, + Func> captureDxgiMemory, Architecture architecture) { _captureNvml = captureNvml ?? throw new ArgumentNullException(nameof(captureNvml)); _readPhysicalMemory = readPhysicalMemory ?? throw new ArgumentNullException(nameof(readPhysicalMemory)); + _captureDxgiMemory = captureDxgiMemory ?? throw new ArgumentNullException(nameof(captureDxgiMemory)); _architecture = architecture; } @@ -38,6 +45,8 @@ public HostHardwareInfo Probe() { PhysicalMemorySnapshot? memory = null; NvmlProbeResult nvml = NvmlProbeResult.Empty; + IReadOnlyDictionary dxgiMemoryByName = + new Dictionary(StringComparer.OrdinalIgnoreCase); try { memory = _readPhysicalMemory(); @@ -56,19 +65,43 @@ public HostHardwareInfo Probe() // Hardware discovery is fail-closed. Unknown GPUs remain absent. } - var gpus = nvml.Devices + try + { + dxgiMemoryByName = _captureDxgiMemory(); + } + catch + { + // Shared GPU memory is optional. NVML facts remain usable alone. + } + + NvmlGpuSnapshot[] devices = nvml.Devices .Where(device => device.TotalMemoryBytes is > 0 and <= long.MaxValue && device.FreeMemoryBytes <= device.TotalMemoryBytes && !string.IsNullOrWhiteSpace(device.Name)) - .Select(device => new GpuInfo( - GpuVendor.Nvidia, - device.Name.Trim(), - (long)device.TotalMemoryBytes, - (long)device.FreeMemoryBytes, - nvml.DriverVersion, - nvml.CudaMajorVersion, - string.IsNullOrWhiteSpace(device.Uuid) ? null : device.Uuid.Trim())) + .ToArray(); + IReadOnlyDictionary nvmlNameCounts = devices + .GroupBy(device => NormalizeGpuName(device.Name), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + var gpus = devices + .Select(device => + { + string name = device.Name.Trim(); + string normalizedName = NormalizeGpuName(name); + DxgiGpuMemoryInfo? dxgiMemory = nvmlNameCounts[normalizedName] == 1 + ? FindDxgiMemoryByName(dxgiMemoryByName, name) + : null; + return new GpuInfo( + GpuVendor.Nvidia, + name, + GpuVisibleMemoryBytes: (long)device.TotalMemoryBytes, + FreeGpuVisibleMemoryBytes: (long)device.FreeMemoryBytes, + SharedGpuMemoryBytes: dxgiMemory?.SharedMemoryBytes, + FreeSharedGpuMemoryBytes: dxgiMemory?.FreeSharedMemoryBytes, + DriverVersion: nvml.DriverVersion, + CudaMajorVersion: nvml.CudaMajorVersion, + StableId: string.IsNullOrWhiteSpace(device.Uuid) ? null : device.Uuid.Trim()); + }) .ToArray(); return new HostHardwareInfo( @@ -79,6 +112,38 @@ public HostHardwareInfo Probe() VulkanAvailable: false); } + private static string NormalizeGpuName(string value) => + string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private static DxgiGpuMemoryInfo? FindDxgiMemoryByName( + IReadOnlyDictionary dxgiMemoryByName, + string nvmlName) + { + string normalizedNvmlName = NormalizeGpuName(nvmlName); + KeyValuePair[] normalizedEntries = dxgiMemoryByName + .Select(entry => new KeyValuePair( + NormalizeGpuName(entry.Key), + entry.Value)) + .ToArray(); + KeyValuePair[] exactMatches = normalizedEntries + .Where(entry => string.Equals( + entry.Key, + normalizedNvmlName, + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (exactMatches.Length == 1) + return exactMatches[0].Value; + if (exactMatches.Length > 1) + return null; + + KeyValuePair[] containmentMatches = normalizedEntries + .Where(entry => + entry.Key.Contains(normalizedNvmlName, StringComparison.OrdinalIgnoreCase) || + normalizedNvmlName.Contains(entry.Key, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return containmentMatches.Length == 1 ? containmentMatches[0].Value : null; + } + internal static IReadOnlyList GetNvmlLibraryCandidates() { string[] candidates = diff --git a/tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs b/tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs index 922e15f1c..e7e0a8db6 100644 --- a/tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs +++ b/tests/OpenClaw.Shared.Tests/LocalInferenceQualificationTests.cs @@ -63,6 +63,24 @@ public void Evaluate_RanksEligibleBeforeBusyAndUnsupportedAdapters() Assert.Equal("GPU-ready", result.SelectedGpu?.StableId); } + [Fact] + public void Evaluate_CountsSharedMemoryForAnyNvidiaGpuAndUnknownSharedFreeIsNotBusy() + { + GpuInfo gpu = Gpu("NVIDIA generic unified memory", "GPU-shared", 8, 8) with + { + SharedGpuMemoryBytes = 8 * GiB, + FreeSharedGpuMemoryBytes = null, + }; + + LocalInferenceEligibilityResult result = LocalInferenceEligibility.Evaluate( + Hardware(RuntimeArchitecture.Arm64, gpu)); + + Assert.Equal(LocalInferenceEligibilityStatus.Eligible, result.Status); + Assert.Equal(LocalModelCatalog.Qwen9BModelId, result.Plan?.Model.Id); + Assert.Equal(16 * GiB, result.DetectedTotalMemoryBytes); + Assert.Null(result.AvailableFreeMemoryBytes); + } + [Fact] public void Evaluate_RanksEligibleAdaptersByFreeThenTotalThenUuid() { @@ -115,6 +133,92 @@ [new GpuInfo(GpuVendor.Amd, "AMD GPU")], Assert.Equal(LocalInferenceSelectionFailureCode.NoNvidiaGpu, result.SelectionFailureCode); } + [Fact] + public void Probe_JoinsDxgiByNormalizedExactName() + { + GpuInfo gpu = ProbeWithDxgi( + "NVIDIA Generic GPU", + new Dictionary + { + ["NVIDIA Generic GPU"] = new(10 * GiB, 9 * GiB), + }); + + Assert.Equal(10 * GiB, gpu.SharedGpuMemoryBytes); + } + + [Theory] + [InlineData("NVIDIA Generic GPU (Device 1)", "NVIDIA Generic GPU")] + [InlineData("NVIDIA Generic GPU", "NVIDIA Generic GPU (Device 1)")] + public void Probe_JoinsDxgiByUniqueBidirectionalContainment(string nvmlName, string dxgiName) + { + GpuInfo gpu = ProbeWithDxgi( + nvmlName, + new Dictionary + { + [dxgiName] = new(10 * GiB, null), + }); + + Assert.Equal(10 * GiB, gpu.SharedGpuMemoryBytes); + } + + [Fact] + public void Probe_DoesNotJoinAmbiguousDxgiContainmentMatches() + { + GpuInfo gpu = ProbeWithDxgi( + "NVIDIA Generic GPU (Device 1)", + new Dictionary + { + ["NVIDIA Generic GPU"] = new(10 * GiB, null), + ["Generic GPU (Device 1)"] = new(12 * GiB, null), + }); + + Assert.Null(gpu.SharedGpuMemoryBytes); + } + + [Fact] + public void Probe_DoesNotJoinOneDxgiBudgetToDuplicateNvmlNames() + { + var probe = new NvmlHostHardwareProbe( + () => new NvmlProbeResult( + [ + new NvmlGpuSnapshot("NVIDIA Duplicate GPU", "GPU-a", 8UL * 1024 * 1024 * 1024, 8UL * 1024 * 1024 * 1024), + new NvmlGpuSnapshot("NVIDIA Duplicate GPU", "GPU-b", 8UL * 1024 * 1024 * 1024, 8UL * 1024 * 1024 * 1024), + ], + "616.30", + 13), + () => null, + () => new Dictionary + { + ["NVIDIA Duplicate GPU"] = new(10 * GiB, 9 * GiB), + }, + RuntimeArchitecture.X64); + + GpuInfo[] gpus = probe.Probe().NvidiaGpus.ToArray(); + + Assert.Equal(2, gpus.Length); + Assert.All(gpus, gpu => Assert.Null(gpu.SharedGpuMemoryBytes)); + } + + [Fact] + public void DxgiCapture_OmitsDuplicateNormalizedAdapterNames() + { + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ambiguousNames = new HashSet(StringComparer.OrdinalIgnoreCase); + DxgiGpuMemoryProbe.AddMemoryByName( + results, + ambiguousNames, + "NVIDIA Duplicate GPU", + new DxgiGpuMemoryInfo(10 * GiB, 9 * GiB)); + DxgiGpuMemoryProbe.AddMemoryByName( + results, + ambiguousNames, + "NVIDIA Duplicate GPU", + new DxgiGpuMemoryInfo(12 * GiB, 11 * GiB)); + + Assert.Empty(results); + Assert.Contains("NVIDIA Duplicate GPU", ambiguousNames); + } + private static HostHardwareInfo Hardware(RuntimeArchitecture architecture, params GpuInfo[] gpus) => new(architecture, 64 * GiB, 48 * GiB, gpus, false); @@ -131,4 +235,20 @@ private static GpuInfo Gpu( DriverVersion: "616.30", CudaMajorVersion: 13, StableId: stableId); + + private static GpuInfo ProbeWithDxgi( + string nvmlName, + IReadOnlyDictionary dxgiMemory) + { + var probe = new NvmlHostHardwareProbe( + () => new NvmlProbeResult( + [new NvmlGpuSnapshot(nvmlName, "GPU-probe", 8UL * 1024 * 1024 * 1024, 8UL * 1024 * 1024 * 1024)], + "616.30", + 13), + () => null, + () => dxgiMemory, + RuntimeArchitecture.X64); + + return Assert.Single(probe.Probe().NvidiaGpus); + } } From 632b1c08c2dd167308922c6c08bfcf8dbea201e7 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:51:22 -0700 Subject: [PATCH 08/23] feat(huggingface): download verified GGUF models Resume interrupted GGUF downloads with strict Range and Content-Range handling. Verify size and hash before atomic promotion, restarting safely when resume is unsupported. Signed-off-by: Joel Fernandes --- .../HuggingFaceModelInstaller.cs | 579 ++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 src/OpenClaw.SetupEngine/HuggingFaceModelInstaller.cs diff --git a/src/OpenClaw.SetupEngine/HuggingFaceModelInstaller.cs b/src/OpenClaw.SetupEngine/HuggingFaceModelInstaller.cs new file mode 100644 index 000000000..f215bb2aa --- /dev/null +++ b/src/OpenClaw.SetupEngine/HuggingFaceModelInstaller.cs @@ -0,0 +1,579 @@ +using OpenClaw.Shared.Inference.Catalog; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; + +namespace OpenClaw.SetupEngine; + +internal enum HuggingFaceModelInstallDisposition +{ + Downloaded, + ReusedVerified, +} + +internal sealed record HuggingFaceModelInstallProgress(long CompletedBytes, long TotalBytes) +{ + public double Fraction => TotalBytes > 0 + ? Math.Clamp((double)CompletedBytes / TotalBytes, 0, 1) + : 0; +} + +internal sealed record HuggingFaceModelInstallResult( + string ModelPath, + HuggingFaceModelInstallDisposition Disposition, + bool CreatedThisRun); + +internal class HuggingFaceModelInstallException : Exception +{ + public HuggingFaceModelInstallException(string message) + : base(message) + { + } + + public HuggingFaceModelInstallException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +internal sealed class TransientHuggingFaceModelInstallException : HuggingFaceModelInstallException +{ + public TransientHuggingFaceModelInstallException(string message) + : base(message) + { + } +} + +internal interface IHuggingFaceModelAcquirer +{ + Task InstallAsync( + string localDataDirectory, + LocalAiComponentIdentity component, + LocalModelInfo model, + IProgress? progress, + CancellationToken cancellationToken); + + void RemoveInstalledModel(string localDataDirectory, HuggingFaceModelInstallResult install); + + void RemovePartialModel( + string localDataDirectory, + LocalAiComponentIdentity component, + LocalModelInfo model); +} + +/// +/// Downloads one immutable Hugging Face GGUF, verifies its exact byte count and +/// SHA-256 digest, and atomically promotes it beside its partial file. A partial +/// left by process termination is resumed with an HTTP range request. Any +/// observed setup failure or cancellation removes the partial file. +/// +internal sealed class HuggingFaceModelInstaller : IHuggingFaceModelAcquirer +{ + private const int BufferSize = 1024 * 1024; + private const int ProgressIntervalBytes = 4 * 1024 * 1024; + private const int MaximumRedirects = 5; + private const int MaximumDownloadAttempts = 4; + + private readonly HttpClient _httpClient; + private readonly Func _retryDelay; + + public HuggingFaceModelInstaller(HttpClient httpClient) => + (_httpClient, _retryDelay) = + (httpClient ?? throw new ArgumentNullException(nameof(httpClient)), Task.Delay); + + internal HuggingFaceModelInstaller( + HttpClient httpClient, + Func retryDelay) => + (_httpClient, _retryDelay) = + (httpClient ?? throw new ArgumentNullException(nameof(httpClient)), + retryDelay ?? throw new ArgumentNullException(nameof(retryDelay))); + + public event EventHandler? ProgressChanged; + + public async Task InstallAsync( + string localDataDirectory, + LocalAiComponentIdentity component, + LocalModelInfo model, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(component); + ArgumentNullException.ThrowIfNull(model); + if (model.Weights.Role != ArtifactRole.ModelWeights || + model.Weights.Source is not HuggingFaceRevisionSource source) + { + throw new HuggingFaceModelInstallException( + "The Local AI model must be an immutable Hugging Face weights artifact."); + } + + if (!LocalAiPathPolicy.TryResolve(localDataDirectory, component, out LocalAiSetupPaths paths, out string pathError) || + !LocalAiPathPolicy.TryGetModelPaths( + paths, + source.RepositoryId, + source.RevisionSha, + model.Weights.RelativePath, + out string modelPath, + out string partialPath, + out pathError)) + { + throw new HuggingFaceModelInstallException(pathError); + } + + if (Directory.Exists(modelPath)) + throw new HuggingFaceModelInstallException("The managed Local AI model path is an existing directory."); + if (Directory.Exists(partialPath)) + throw new HuggingFaceModelInstallException("The managed Local AI partial model path is an existing directory."); + + if (File.Exists(modelPath)) + { + if (await VerifyFileAsync(modelPath, model.Weights, cancellationToken).ConfigureAwait(false)) + { + return new HuggingFaceModelInstallResult( + modelPath, + HuggingFaceModelInstallDisposition.ReusedVerified, + CreatedThisRun: false); + } + + if (!LocalAiPathPolicy.TryValidateManagedDeleteTarget( + localDataDirectory, + modelPath, + out string invalidModelPath, + out pathError)) + { + throw new HuggingFaceModelInstallException(pathError); + } + File.Delete(invalidModelPath); + } + + Directory.CreateDirectory(Path.GetDirectoryName(modelPath)!); + var promoted = false; + var preservePartial = false; + try + { + bool verifiedCompletePartial = File.Exists(partialPath) && + new FileInfo(partialPath).Length == model.Weights.SizeBytes && + await VerifyFileAsync(partialPath, model.Weights, cancellationToken).ConfigureAwait(false); + if (!verifiedCompletePartial) + { + if (File.Exists(partialPath) && + new FileInfo(partialPath).Length >= model.Weights.SizeBytes) + { + TryDeletePartial(localDataDirectory, partialPath); + } + + await DownloadAndVerifyAsync( + model.Weights, + localDataDirectory, + partialPath, + progress, + cancellationToken) + .ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!LocalAiPathPolicy.TryResolve( + localDataDirectory, + component, + out LocalAiSetupPaths revalidatedPaths, + out pathError) || + !LocalAiPathPolicy.TryGetModelPaths( + revalidatedPaths, + source.RepositoryId, + source.RevisionSha, + model.Weights.RelativePath, + out string revalidatedModelPath, + out string revalidatedPartialPath, + out pathError) || + !string.Equals(modelPath, revalidatedModelPath, StringComparison.OrdinalIgnoreCase) || + !string.Equals(partialPath, revalidatedPartialPath, StringComparison.OrdinalIgnoreCase)) + { + throw new HuggingFaceModelInstallException( + string.IsNullOrWhiteSpace(pathError) + ? "The Local AI model paths changed before promotion." + : pathError); + } + + if (File.Exists(modelPath)) + { + throw new HuggingFaceModelInstallException( + "The Local AI model target appeared while the download was in progress."); + } + + File.Move(partialPath, modelPath); + promoted = true; + return new HuggingFaceModelInstallResult( + modelPath, + HuggingFaceModelInstallDisposition.Downloaded, + CreatedThisRun: true); + } + catch (OperationCanceledException) + { + preservePartial = File.Exists(partialPath); + throw; + } + catch (Exception exception) when ( + exception is IOException or HttpRequestException or TransientHuggingFaceModelInstallException) + { + preservePartial = File.Exists(partialPath); + throw; + } + finally + { + if (!promoted && !preservePartial) + TryDeletePartial(localDataDirectory, partialPath); + } + } + + public void RemoveInstalledModel(string localDataDirectory, HuggingFaceModelInstallResult install) + { + ArgumentNullException.ThrowIfNull(install); + if (!install.CreatedThisRun) + return; + + if (!LocalAiPathPolicy.TryValidateManagedDeleteTarget( + localDataDirectory, + install.ModelPath, + out string deletePath, + out string error)) + { + throw new InvalidDataException(error); + } + + if (File.Exists(deletePath)) + File.Delete(deletePath); + } + + public void RemovePartialModel( + string localDataDirectory, + LocalAiComponentIdentity component, + LocalModelInfo model) + { + ArgumentNullException.ThrowIfNull(component); + ArgumentNullException.ThrowIfNull(model); + if (model.Weights.Source is not HuggingFaceRevisionSource source) + throw new InvalidDataException("The Local AI model does not have immutable Hugging Face provenance."); + if (!LocalAiPathPolicy.TryResolve( + localDataDirectory, + component, + out LocalAiSetupPaths paths, + out string error) || + !LocalAiPathPolicy.TryGetModelPaths( + paths, + source.RepositoryId, + source.RevisionSha, + model.Weights.RelativePath, + out _, + out string partialPath, + out error)) + { + throw new InvalidDataException( + string.IsNullOrWhiteSpace(error) ? "The Local AI partial model path is invalid." : error); + } + + if (Directory.Exists(partialPath)) + throw new InvalidDataException("The Local AI partial model path is an existing directory."); + if (File.Exists(partialPath)) + { + if (!LocalAiPathPolicy.TryValidateManagedDeleteTarget( + localDataDirectory, + partialPath, + out string deletePath, + out error)) + { + throw new InvalidDataException(error); + } + File.Delete(deletePath); + } + } + + private async Task DownloadAndVerifyAsync( + PinnedArtifact artifact, + string localDataDirectory, + string partialPath, + IProgress? progress, + CancellationToken cancellationToken) + { + for (int attempt = 1; ; attempt++) + { + try + { + await DownloadAndVerifyAttemptAsync( + artifact, + localDataDirectory, + partialPath, + progress, + cancellationToken) + .ConfigureAwait(false); + return; + } + catch (Exception exception) when ( + exception is IOException or HttpRequestException or TransientHuggingFaceModelInstallException && + attempt < MaximumDownloadAttempts && + !cancellationToken.IsCancellationRequested) + { + TimeSpan delay = TimeSpan.FromSeconds(1 << (attempt - 1)); + await _retryDelay(delay, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task DownloadAndVerifyAttemptAsync( + PinnedArtifact artifact, + string localDataDirectory, + string partialPath, + IProgress? progress, + CancellationToken cancellationToken) + { + long resumeOffset = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0; + if (resumeOffset < 0 || resumeOffset >= artifact.SizeBytes) + { + TryDeletePartial(localDataDirectory, partialPath); + resumeOffset = 0; + } + + using HttpResponseMessage response = await SendWithValidatedRedirectsAsync( + artifact.DownloadUri, + resumeOffset, + cancellationToken) + .ConfigureAwait(false); + + bool append = resumeOffset > 0 && response.StatusCode == HttpStatusCode.PartialContent; + if (resumeOffset > 0 && !append && response.StatusCode != HttpStatusCode.OK) + { + throw new HuggingFaceModelInstallException( + $"The Hugging Face range request failed with HTTP status {(int)response.StatusCode} ({response.StatusCode})."); + } + if (resumeOffset == 0 && response.StatusCode != HttpStatusCode.OK) + { + throw new HuggingFaceModelInstallException( + $"The Hugging Face download failed with HTTP status {(int)response.StatusCode} ({response.StatusCode})."); + } + + if (append) + { + ContentRangeHeaderValue? range = response.Content.Headers.ContentRange; + if (range?.From != resumeOffset || range.To is null || range.Length != artifact.SizeBytes) + { + throw new HuggingFaceModelInstallException( + "The Hugging Face range response did not match the partial model file."); + } + } + else + { + resumeOffset = 0; + } + + long expectedBodyBytes = artifact.SizeBytes - resumeOffset; + if (response.Content.Headers.ContentLength is { } contentLength && contentLength != expectedBodyBytes) + { + throw new HuggingFaceModelInstallException( + $"The Hugging Face response declared {contentLength} bytes; expected {expectedBodyBytes} bytes."); + } + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + if (append) + await HashExistingPartialAsync(partialPath, hash, cancellationToken).ConfigureAwait(false); + + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var destination = new FileStream( + partialPath, + append ? FileMode.Append : FileMode.Create, + FileAccess.Write, + FileShare.None, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan | FileOptions.WriteThrough); + + long completed = resumeOffset; + long lastReported = completed; + Report(progress, completed, artifact.SizeBytes); + var buffer = new byte[BufferSize]; + while (true) + { + int read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + break; + + completed += read; + if (completed > artifact.SizeBytes) + throw new HuggingFaceModelInstallException("The Hugging Face response exceeded the pinned model size."); + hash.AppendData(buffer, 0, read); + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + + if (completed - lastReported >= ProgressIntervalBytes) + { + Report(progress, completed, artifact.SizeBytes); + lastReported = completed; + } + } + + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + destination.Flush(flushToDisk: true); + if (completed != artifact.SizeBytes) + { + throw new HuggingFaceModelInstallException( + $"The Hugging Face response contained {completed} bytes; expected {artifact.SizeBytes} bytes."); + } + + string actualHash = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + if (!CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(actualHash), + Convert.FromHexString(artifact.Sha256.Value))) + { + throw new HuggingFaceModelInstallException("The Hugging Face model SHA-256 digest did not match its pin."); + } + + Report(progress, completed, artifact.SizeBytes); + } + + private async Task SendWithValidatedRedirectsAsync( + Uri initialUri, + long resumeOffset, + CancellationToken cancellationToken) + { + ValidateDownloadUri(initialUri, initialRequest: true); + Uri current = initialUri; + for (int redirect = 0; redirect <= MaximumRedirects; redirect++) + { + using var request = new HttpRequestMessage(HttpMethod.Get, current); + if (resumeOffset > 0) + request.Headers.Range = new RangeHeaderValue(resumeOffset, null); + + HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + + Uri observed = response.RequestMessage?.RequestUri ?? current; + ValidateDownloadUri(observed, initialRequest: false); + if (!IsRedirect(response.StatusCode)) + { + if (IsTransientStatus(response.StatusCode)) + { + int statusCode = (int)response.StatusCode; + string reason = response.StatusCode.ToString(); + response.Dispose(); + throw new TransientHuggingFaceModelInstallException( + $"The Hugging Face download returned transient HTTP status {statusCode} ({reason})."); + } + + return response; + } + + if (redirect == MaximumRedirects || response.Headers.Location is null) + { + response.Dispose(); + throw new HuggingFaceModelInstallException("The Hugging Face download exceeded the redirect limit."); + } + + Uri next = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(observed, response.Headers.Location); + response.Dispose(); + ValidateDownloadUri(next, initialRequest: false); + current = next; + } + + throw new HuggingFaceModelInstallException("The Hugging Face download exceeded the redirect limit."); + } + + private static void ValidateDownloadUri(Uri uri, bool initialRequest) + { + if (!uri.IsAbsoluteUri || + uri.Scheme != Uri.UriSchemeHttps || + !string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Fragment)) + { + throw new HuggingFaceModelInstallException("The model download URI must be credential-free HTTPS."); + } + + bool allowed = string.Equals(uri.Host, "huggingface.co", StringComparison.OrdinalIgnoreCase) || + (!initialRequest && + (uri.Host.EndsWith(".huggingface.co", StringComparison.OrdinalIgnoreCase) || + uri.Host.EndsWith(".hf.co", StringComparison.OrdinalIgnoreCase))); + if (!allowed) + throw new HuggingFaceModelInstallException("The model download redirected to an untrusted host."); + } + + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently or + HttpStatusCode.Redirect or + HttpStatusCode.RedirectMethod or + HttpStatusCode.TemporaryRedirect or + HttpStatusCode.PermanentRedirect; + + private static bool IsTransientStatus(HttpStatusCode statusCode) => + statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || + (int)statusCode is >= 500 and <= 599; + + private static async Task HashExistingPartialAsync( + string partialPath, + IncrementalHash hash, + CancellationToken cancellationToken) + { + await using var stream = new FileStream( + partialPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var buffer = new byte[BufferSize]; + while (true) + { + int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + return; + hash.AppendData(buffer, 0, read); + } + } + + internal static async Task VerifyFileAsync( + string path, + PinnedArtifact artifact, + CancellationToken cancellationToken) + { + if (new FileInfo(path).Length != artifact.SizeBytes) + return false; + + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + byte[] actual = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + return CryptographicOperations.FixedTimeEquals(actual, Convert.FromHexString(artifact.Sha256.Value)); + } + + private void Report( + IProgress? progress, + long completed, + long total) + { + var value = new HuggingFaceModelInstallProgress(completed, total); + progress?.Report(value); + ProgressChanged?.Invoke(this, value); + } + + private static void TryDeletePartial(string localDataDirectory, string partialPath) + { + try + { + if (LocalAiPathPolicy.TryValidateManagedDeleteTarget( + localDataDirectory, + partialPath, + out string deletePath, + out _) && + File.Exists(deletePath)) + { + File.Delete(deletePath); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup must not mask the acquisition result. + } + } +} From 2e296649b0ae4891807bb9504d6e6220ac5742cd Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:51:44 -0700 Subject: [PATCH 09/23] feat(llama-server): install verified native CUDA runtime Install and inspect the pinned llama-server and CUDA runtime components. Safely reconcile exact orphan runtime paths so interrupted promotion can retry. Signed-off-by: Joel Fernandes --- .../LlamaRuntimeInstaller.cs | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 src/OpenClaw.SetupEngine/LlamaRuntimeInstaller.cs diff --git a/src/OpenClaw.SetupEngine/LlamaRuntimeInstaller.cs b/src/OpenClaw.SetupEngine/LlamaRuntimeInstaller.cs new file mode 100644 index 000000000..5c60216e8 --- /dev/null +++ b/src/OpenClaw.SetupEngine/LlamaRuntimeInstaller.cs @@ -0,0 +1,304 @@ +using OpenClaw.Shared.Inference.Catalog; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace OpenClaw.SetupEngine; + +internal enum LlamaRuntimeInstallDisposition +{ + Installed, + ReusedVerified, +} + +internal sealed record LlamaRuntimeInstallResult( + string InstallDirectory, + string ExecutablePath, + LlamaRuntimeInstallDisposition Disposition, + bool CreatedThisRun, + IReadOnlyList VerifiedArchives, + LocalAiArtifactRollbackMetadata? Rollback); + +internal sealed record LlamaRuntimeInspection(bool IsValid, string? VersionOutput, string? Error); + +internal interface ILlamaRuntimeInspector +{ + Task InspectAsync(string installDirectory, CancellationToken cancellationToken); +} + +internal interface ILlamaRuntimeAcquirer +{ + Task InstallAsync( + string localDataDirectory, + LlamaRuntimeVariant runtime, + IProgress? progress, + CancellationToken cancellationToken); + + void RemoveInstalledRuntime(string localDataDirectory, LlamaRuntimeInstallResult install); +} + +internal sealed class LlamaRuntimeInstaller : ILlamaRuntimeAcquirer +{ + private const int MaximumDeleteAttempts = 8; + private readonly LocalAiArtifactInstaller _artifactInstaller; + private readonly ILlamaRuntimeInspector _inspector; + + public LlamaRuntimeInstaller(HttpClient httpClient) + : this(new LocalAiArtifactInstaller(httpClient), new WindowsLlamaRuntimeInspector()) + { + } + + internal LlamaRuntimeInstaller( + LocalAiArtifactInstaller artifactInstaller, + ILlamaRuntimeInspector inspector) + { + _artifactInstaller = artifactInstaller ?? throw new ArgumentNullException(nameof(artifactInstaller)); + _inspector = inspector ?? throw new ArgumentNullException(nameof(inspector)); + } + + public event EventHandler? ProgressChanged + { + add => _artifactInstaller.ProgressChanged += value; + remove => _artifactInstaller.ProgressChanged -= value; + } + + public async Task InstallAsync( + string localDataDirectory, + LlamaRuntimeVariant runtime, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(runtime); + LocalAiComponentIdentity component = Component(runtime); + if (!LocalAiPathPolicy.TryResolve(localDataDirectory, component, out LocalAiSetupPaths paths, out string pathError)) + throw new LocalAiArtifactInstallException(pathError); + + if (Directory.Exists(paths.InstallDirectory) || File.Exists(paths.InstallDirectory)) + { + if (!LocalAiPathPolicy.TryDeleteManagedTree( + localDataDirectory, + paths.InstallDirectory, + allowRoot: false, + out string cleanupError)) + { + throw new LocalAiArtifactInstallException( + $"An unclaimed llama-server runtime could not be removed safely: {cleanupError}"); + } + } + + IReadOnlyList archives = runtime.Artifacts + .Select(artifact => new LocalAiPinnedArchive( + artifact.RelativePath, + artifact.DownloadUri, + artifact.SizeBytes, + artifact.Sha256.Value)) + .ToArray(); + LocalAiArtifactInstallResult installed = await _artifactInstaller.InstallAsync( + localDataDirectory, + component, + archives, + progress, + cancellationToken) + .ConfigureAwait(false); + + try + { + LlamaRuntimeInspection inspection = await _inspector.InspectAsync( + installed.InstallDirectory, + cancellationToken) + .ConfigureAwait(false); + if (!inspection.IsValid) + { + throw new LocalAiArtifactInstallException( + inspection.Error ?? "The installed llama-server runtime did not pass validation."); + } + + return new LlamaRuntimeInstallResult( + installed.InstallDirectory, + Path.Combine(installed.InstallDirectory, LlamaRuntimeCatalog.ServerExecutableName), + LlamaRuntimeInstallDisposition.Installed, + CreatedThisRun: true, + installed.VerifiedArchives, + installed.Rollback); + } + catch + { + DeleteCreatedInstall(localDataDirectory, installed.Rollback.CreatedDirectory); + throw; + } + } + + internal static LocalAiComponentIdentity Component(LlamaRuntimeVariant runtime) => + new( + "llama-server", + LlamaRuntimeCatalog.ReleaseTag, + runtime.Architecture switch + { + Architecture.X64 => "win-x64", + Architecture.Arm64 => "win-arm64", + _ => throw new InvalidOperationException("The llama-server runtime architecture is unsupported."), + }); + + public void RemoveInstalledRuntime(string localDataDirectory, LlamaRuntimeInstallResult install) + { + ArgumentNullException.ThrowIfNull(install); + if (!install.CreatedThisRun || install.Rollback is null) + return; + + if (!LocalAiPathPolicy.TryValidateManagedDeleteTarget( + localDataDirectory, + install.Rollback.CreatedDirectory, + out string deletePath, + out string error)) + { + throw new InvalidDataException(error); + } + + if ((Directory.Exists(deletePath) || File.Exists(deletePath)) && + !LocalAiPathPolicy.TryDeleteManagedTree( + localDataDirectory, + deletePath, + allowRoot: false, + out string cleanupError)) + { + throw new InvalidDataException(cleanupError); + } + } + + internal static void DeleteDirectoryWithRetry( + string deletePath, + Action? delete = null, + Action? delay = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(deletePath); + delete ??= path => Directory.Delete(path, recursive: true); + delay ??= Thread.Sleep; + + for (int attempt = 1; ; attempt++) + { + try + { + delete(deletePath); + return; + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException && + attempt < MaximumDeleteAttempts) + { + int delayMilliseconds = Math.Min(100 << (attempt - 1), 1_000); + delay(TimeSpan.FromMilliseconds(delayMilliseconds)); + } + } + } + + private static void DeleteCreatedInstall(string localDataDirectory, string createdDirectory) + { + try + { + if (LocalAiPathPolicy.TryDeleteManagedTree( + localDataDirectory, + createdDirectory, + allowRoot: false, + out _)) + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup must not replace the validation failure. + } + } +} + +internal sealed class WindowsLlamaRuntimeInspector : ILlamaRuntimeInspector +{ + private static readonly string[] RequiredFiles = + [ + LlamaRuntimeCatalog.ServerExecutableName, + "ggml-cuda.dll", + "cudart64_13.dll", + "cublas64_13.dll", + "cublasLt64_13.dll", + ]; + + public async Task InspectAsync( + string installDirectory, + CancellationToken cancellationToken) + { + foreach (string fileName in RequiredFiles) + { + string path = Path.Combine(installDirectory, fileName); + if (!File.Exists(path) || (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + return new LlamaRuntimeInspection(false, null, $"The llama-server runtime is missing required file '{fileName}'."); + } + + string executable = Path.Combine(installDirectory, LlamaRuntimeCatalog.ServerExecutableName); + var startInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = installDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("--version"); + + using var process = new Process { StartInfo = startInfo }; + try + { + if (!process.Start()) + return new LlamaRuntimeInspection(false, null, "llama-server --version did not start."); + + Task stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task stderr = process.StandardError.ReadToEndAsync(cancellationToken); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + await process.WaitForExitAsync(linked.Token).ConfigureAwait(false); + string output = (await stdout.ConfigureAwait(false)) + Environment.NewLine + + (await stderr.ConfigureAwait(false)); + if (process.ExitCode != 0) + return new LlamaRuntimeInspection(false, output, "llama-server --version returned a nonzero exit code."); + return ValidateVersionOutput(output); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + KillProcessTree(process); + throw; + } + catch (OperationCanceledException) + { + KillProcessTree(process); + return new LlamaRuntimeInspection(false, null, "llama-server --version timed out."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException) + { + return new LlamaRuntimeInspection(false, null, $"llama-server --version failed: {exception.Message}"); + } + } + + internal static LlamaRuntimeInspection ValidateVersionOutput(string output) + { + bool buildMatches = output.Contains("build 10488", StringComparison.OrdinalIgnoreCase); + bool commitMatches = output.Contains( + LlamaRuntimeCatalog.ReleaseCommitSha[..9], + StringComparison.OrdinalIgnoreCase); + return buildMatches && commitMatches + ? new LlamaRuntimeInspection(true, output, null) + : new LlamaRuntimeInspection( + false, + output, + "llama-server did not report the pinned b10488 build and source commit."); + } + + private static void KillProcessTree(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // Best-effort cleanup during cancellation or timeout. + } + } +} From ad785137585815d3a857580e1dc951ff8f754691 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:52:56 -0700 Subject: [PATCH 10/23] feat(llama-server): add request-driven managed router Launch llama-server on demand with an OS-assigned or validated fixed port. Prove listener ownership by child PID and start time before health checks or persistence. Signed-off-by: Joel Fernandes --- .../LocalAi/LlamaServerClient.cs | 302 +++++++ .../LocalAi/LlamaServerRouterConfiguration.cs | 151 ++++ .../LocalAi/LlamaServerRuntimeService.cs | 800 ++++++++++++++++++ .../LocalAi/LocalAiEndpointLifecycle.cs | 44 + .../LocalAiGatewayProviderDefinition.cs | 63 ++ .../LocalAi/LocalAiManifest.cs | 73 +- .../LocalAiPortLifecycleTests.cs | 487 +++++++++++ 7 files changed, 1904 insertions(+), 16 deletions(-) create mode 100644 src/OpenClaw.Connection/LocalAi/LlamaServerClient.cs create mode 100644 src/OpenClaw.Connection/LocalAi/LlamaServerRouterConfiguration.cs create mode 100644 src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs create mode 100644 src/OpenClaw.Connection/LocalAi/LocalAiEndpointLifecycle.cs create mode 100644 src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs create mode 100644 tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs diff --git a/src/OpenClaw.Connection/LocalAi/LlamaServerClient.cs b/src/OpenClaw.Connection/LocalAi/LlamaServerClient.cs new file mode 100644 index 000000000..eedec4699 --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LlamaServerClient.cs @@ -0,0 +1,302 @@ +using System.Text.Json; + +namespace OpenClaw.Connection.LocalAi; + +public sealed record LlamaServerRouterProbeResult( + bool IsHealthy, + LocalAiModelAvailabilityState ModelState, + string? ReportedModelPath, + string? Detail); + +public sealed record LlamaServerModelStatusEvidence( + LocalAiModelAvailabilityState State, + string ModelPath, + string ServerStatus); + +/// +/// Parses the router model metadata emitted by qualified llama-server builds. +/// Unloaded preset models in b10488 report their path in status.args, while a +/// loaded model may also expose the documented top-level path field. +/// +public static class LlamaServerModelStatusParser +{ + public static LlamaServerModelStatusEvidence? Parse( + JsonElement root, + string modelAlias, + string expectedModelPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modelAlias); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedModelPath); + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("data", out JsonElement models) || + models.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("The llama-server model status response has an invalid shape."); + } + + JsonElement? match = null; + foreach (JsonElement model in models.EnumerateArray()) + { + if (model.ValueKind != JsonValueKind.Object || + !model.TryGetProperty("id", out JsonElement id) || + id.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException("The llama-server model status contains an invalid entry."); + } + if (!string.Equals(id.GetString(), modelAlias, StringComparison.Ordinal)) + continue; + if (match is not null) + throw new InvalidDataException("The llama-server model status contains duplicate aliases."); + match = model; + } + + if (match is null) + return null; + + JsonElement selected = match.Value; + if (!selected.TryGetProperty("status", out JsonElement statusElement) || + statusElement.ValueKind != JsonValueKind.Object || + !statusElement.TryGetProperty("value", out JsonElement valueElement) || + valueElement.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(valueElement.GetString())) + { + throw new InvalidDataException("The llama-server model status does not contain a valid state."); + } + + string? topLevelPath = ReadOptionalTopLevelPath(selected); + string? argumentPath = ReadOptionalModelArgument(statusElement); + string reportedPath = topLevelPath ?? argumentPath + ?? throw new InvalidDataException("The llama-server model status does not identify the managed model path."); + if (!PathsEqual(reportedPath, expectedModelPath) || + (topLevelPath is not null && argumentPath is not null && !PathsEqual(topLevelPath, argumentPath))) + { + throw new InvalidDataException("The llama-server model status does not match the managed model."); + } + + string status = valueElement.GetString()!; + LocalAiModelAvailabilityState state = status switch + { + "loaded" => LocalAiModelAvailabilityState.Loaded, + "unloaded" or "loading" or "sleeping" => LocalAiModelAvailabilityState.Verified, + _ => LocalAiModelAvailabilityState.Unknown, + }; + return new(state, reportedPath, status); + } + + private static string? ReadOptionalTopLevelPath(JsonElement selected) + { + if (!selected.TryGetProperty("path", out JsonElement path)) + return null; + if (path.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(path.GetString())) + throw new InvalidDataException("The llama-server model path is invalid."); + return path.GetString(); + } + + private static string? ReadOptionalModelArgument(JsonElement status) + { + if (!status.TryGetProperty("args", out JsonElement args)) + return null; + if (args.ValueKind != JsonValueKind.Array) + throw new InvalidDataException("The llama-server model arguments are invalid."); + + string? modelPath = null; + JsonElement[] values = args.EnumerateArray().ToArray(); + for (int index = 0; index < values.Length; index++) + { + if (values[index].ValueKind != JsonValueKind.String) + throw new InvalidDataException("The llama-server model arguments contain a non-string value."); + string? value = values[index].GetString(); + if (value is not ("--model" or "-m")) + continue; + if (modelPath is not null || index + 1 >= values.Length || + values[index + 1].ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(values[index + 1].GetString())) + { + throw new InvalidDataException("The llama-server model arguments contain an invalid model path."); + } + modelPath = values[++index].GetString(); + } + return modelPath; + } + + private static bool PathsEqual(string left, string right) + { + try + { + return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + throw new InvalidDataException("The llama-server reported an invalid model path.", ex); + } + } +} + +internal interface ILlamaServerClient : IDisposable +{ + Task ProbeRouterAsync( + Uri endpoint, + string modelAlias, + string expectedModelPath, + CancellationToken cancellationToken = default); +} + +/// Bounded, loopback-only health and model-state client for the managed llama-server router. +public sealed class LlamaServerClient : ILlamaServerClient +{ + private const int MaxEvidenceResponseBytes = 1024 * 1024; + private readonly HttpClient _client; + + public LlamaServerClient() : this(new SocketsHttpHandler + { + UseProxy = false, + AllowAutoRedirect = false, + ConnectTimeout = TimeSpan.FromSeconds(2), + }) + { + } + + internal LlamaServerClient(HttpMessageHandler handler) + { + _client = new HttpClient(handler ?? throw new ArgumentNullException(nameof(handler)), disposeHandler: true) + { + Timeout = TimeSpan.FromSeconds(3), + }; + } + + public async Task ProbeRouterAsync( + Uri endpoint, + string modelAlias, + string expectedModelPath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(modelAlias); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedModelPath); + ValidateManagedEndpoint(endpoint); + + if (!await ProbeHealthAsync(endpoint, cancellationToken).ConfigureAwait(false)) + { + return new( + false, + LocalAiModelAvailabilityState.Unknown, + null, + "The llama-server router health check did not succeed."); + } + + try + { + return await ProbeModelAsync(endpoint, modelAlias, expectedModelPath, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new(true, LocalAiModelAvailabilityState.Unknown, null, "The model status check timed out."); + } + catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidDataException) + { + return new(true, LocalAiModelAvailabilityState.Unknown, null, "The model status response was invalid."); + } + } + + private async Task ProbeHealthAsync(Uri endpoint, CancellationToken cancellationToken) + { + try + { + using var response = await _client.GetAsync( + BuildEndpointUri(endpoint, "/health"), + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return false; + + byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken).ConfigureAwait(false); + using JsonDocument document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 }); + return document.RootElement.ValueKind == JsonValueKind.Object && + document.RootElement.TryGetProperty("status", out JsonElement status) && + status.ValueKind == JsonValueKind.String && + string.Equals(status.GetString(), "ok", StringComparison.Ordinal); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidDataException) + { + return false; + } + } + + private async Task ProbeModelAsync( + Uri endpoint, + string modelAlias, + string expectedModelPath, + CancellationToken cancellationToken) + { + using var response = await _client.GetAsync( + BuildEndpointUri(endpoint, "/models", "autoload=false"), + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"llama-server model status returned HTTP {(int)response.StatusCode}."); + + byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken).ConfigureAwait(false); + using JsonDocument document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 16 }); + LlamaServerModelStatusEvidence? evidence = LlamaServerModelStatusParser.Parse( + document.RootElement, + modelAlias, + expectedModelPath); + if (evidence is null) + return new(true, LocalAiModelAvailabilityState.NotInstalled, null, "The configured model is not registered."); + return new( + true, + evidence.State, + evidence.ModelPath, + $"llama-server reports the model as {evidence.ServerStatus}."); + } + + private static void ValidateManagedEndpoint(Uri endpoint) + { + if (!endpoint.IsAbsoluteUri || + endpoint.Scheme != Uri.UriSchemeHttp || + !string.Equals(endpoint.Host, "127.0.0.1", StringComparison.Ordinal) || + endpoint.Port is <= 0 or > 65535 || + endpoint.Port == 80 || + !string.Equals(endpoint.AbsolutePath, "/v1", StringComparison.Ordinal) || + !string.IsNullOrEmpty(endpoint.UserInfo) || + !string.IsNullOrEmpty(endpoint.Query) || + !string.IsNullOrEmpty(endpoint.Fragment)) + { + throw new ArgumentException("The llama-server endpoint must use an explicit IPv4 loopback port.", nameof(endpoint)); + } + } + + private static Uri BuildEndpointUri(Uri endpoint, string path, string? query = null) => + new UriBuilder(Uri.UriSchemeHttp, "127.0.0.1", endpoint.Port, path) + { + Query = query ?? string.Empty, + }.Uri; + + private static async Task ReadBoundedAsync(HttpContent content, CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > MaxEvidenceResponseBytes) + throw new InvalidDataException("The llama-server evidence response exceeds the size limit."); + + await using Stream input = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[16 * 1024]; + while (true) + { + int read = await input.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + if (read == 0) + return output.ToArray(); + if (output.Length + read > MaxEvidenceResponseBytes) + throw new InvalidDataException("The llama-server evidence response exceeds the size limit."); + output.Write(buffer, 0, read); + } + } + + public void Dispose() => _client.Dispose(); +} diff --git a/src/OpenClaw.Connection/LocalAi/LlamaServerRouterConfiguration.cs b/src/OpenClaw.Connection/LocalAi/LlamaServerRouterConfiguration.cs new file mode 100644 index 000000000..968be123f --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LlamaServerRouterConfiguration.cs @@ -0,0 +1,151 @@ +using OpenClaw.Shared.Inference.Catalog; +using System.Collections.Immutable; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; + +namespace OpenClaw.Connection.LocalAi; + +/// A deterministic lazy-load router configuration for a qualified local inference install. +public sealed record LlamaServerRouterLaunchPlan( + ImmutableArray Arguments, + ImmutableDictionary Environment, + string PresetPath, + string PresetContent, + string ModelAlias); + +public static class LlamaServerRouterConfiguration +{ + public static LlamaServerRouterLaunchPlan Build( + LocalAiPaths paths, + LocalAiResolvedInstall install, + int? listenPort = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(install); + + LocalAiInstallManifest manifest = install.Manifest; + int port = listenPort ?? manifest.RequestedPort; + LocalAiPortPolicy.Validate(port); + LlamaRuntimeVariant runtime = LlamaRuntimeCatalog.Variants.SingleOrDefault( + candidate => string.Equals(candidate.Id, manifest.RuntimeId, StringComparison.Ordinal)) + ?? throw new InvalidDataException("The managed llama-server runtime is no longer qualified."); + LocalModelInfo model = LocalModelCatalog.Find(manifest.ModelCatalogId) + ?? throw new InvalidDataException("The managed local AI model is no longer qualified."); + + ValidateQualifiedReceipt(manifest, runtime, model); + + string presetPath = paths.ResolveContainedPath( + Path.GetRelativePath(paths.RootDirectory, paths.RouterPresetPath), + nameof(paths.RouterPresetPath)); + var arguments = ImmutableArray.Create( + "--host", "127.0.0.1", + "--port", port.ToString(CultureInfo.InvariantCulture), + "--models-preset", presetPath, + "--models-max", "1", + "--models-autoload", + "--no-webui", + "--metrics", + "--offline", + "--cors-origins", "localhost", + "--log-verbosity", "4", + "--no-log-prefix", + "--no-log-timestamps"); + + return new LlamaServerRouterLaunchPlan( + arguments, + ImmutableDictionary.Empty + .WithComparers(StringComparer.OrdinalIgnoreCase) + .Add("CUDA_VISIBLE_DEVICES", manifest.SelectedGpuId), + presetPath, + BuildPreset(model, install.ModelPath), + model.Id); + } + + private static void ValidateQualifiedReceipt( + LocalAiInstallManifest manifest, + LlamaRuntimeVariant runtime, + LocalModelInfo model) + { + Architecture expectedArchitecture = manifest.Architecture switch + { + "x64" => Architecture.X64, + "arm64" => Architecture.Arm64, + _ => throw new InvalidDataException("The managed local AI architecture is invalid."), + }; + if (runtime.Architecture != expectedArchitecture) + { + throw new InvalidDataException("The managed local AI architecture and runtime receipt do not match."); + } + if (!string.Equals(manifest.EngineVersion, LlamaRuntimeCatalog.ReleaseTag, StringComparison.Ordinal) || + !string.Equals(manifest.ModelAlias, model.Id, StringComparison.Ordinal) || + manifest.ContextLength != model.Recipe.ContextTokens) + { + throw new InvalidDataException("The managed local AI model recipe receipt does not match the qualified catalog."); + } + + if (manifest.RuntimeAssets.Length != runtime.Artifacts.Count || + runtime.Artifacts.Any(artifact => !manifest.RuntimeAssets.Any(receipt => + string.Equals(receipt.FileName, Path.GetFileName(artifact.RelativePath), StringComparison.Ordinal) && + string.Equals(receipt.SourceUrl, artifact.DownloadUri.AbsoluteUri, StringComparison.Ordinal) && + receipt.SizeBytes == artifact.SizeBytes && + string.Equals(receipt.Sha256, artifact.Sha256.Value, StringComparison.Ordinal)))) + { + throw new InvalidDataException("The managed llama-server artifact receipts do not match the qualified catalog."); + } + + if (model.Weights.Source is not HuggingFaceRevisionSource source || + !string.Equals(manifest.ModelId, $"{source.RepositoryId}@{source.RevisionSha}", StringComparison.Ordinal) || + !string.Equals(manifest.ModelAsset.FileName, Path.GetFileName(model.Weights.RelativePath), StringComparison.Ordinal) || + manifest.ModelAsset.SizeBytes != model.Weights.SizeBytes || + !string.Equals(manifest.ModelAsset.Sha256, model.Weights.Sha256.Value, StringComparison.Ordinal) || + !string.Equals(manifest.ModelAsset.SourceUrl, model.Weights.DownloadUri.AbsoluteUri, StringComparison.Ordinal)) + { + throw new InvalidDataException("The managed model artifact receipt does not match the qualified catalog."); + } + } + + private static string BuildPreset(LocalModelInfo model, string modelPath) + { + if (modelPath.IndexOfAny(['\r', '\n']) >= 0) + throw new InvalidDataException("The managed model path cannot be represented safely in a llama-server preset."); + + LocalModelRunRecipe recipe = model.Recipe; + ModelSamplingPreset sampling = recipe.Sampling; + var preset = new StringBuilder(); + preset.AppendLine("version = 1"); + preset.AppendLine(); + preset.Append('[').Append(model.Id).AppendLine("]"); + preset.Append("model = ").AppendLine(modelPath); + preset.AppendLine("load-on-startup = false"); + preset.Append("ctx-size = ").AppendLine(Invariant(recipe.ContextTokens)); + preset.Append("parallel = ").AppendLine(Invariant(recipe.ParallelRequests)); + preset.AppendLine("cache-type-k = f16"); + preset.AppendLine("cache-type-v = f16"); + preset.Append("batch-size = ").AppendLine(Invariant(recipe.BatchTokens)); + preset.Append("ubatch-size = ").AppendLine(Invariant(recipe.MicroBatchTokens)); + preset.AppendLine("flash-attn = on"); + preset.AppendLine("gpu-layers = all"); + preset.AppendLine("split-mode = none"); + preset.AppendLine("main-gpu = 0"); + preset.AppendLine("fit = off"); + preset.AppendLine("load-mode = dio"); + preset.AppendLine("spec-type = draft-mtp"); + preset.Append("spec-draft-n-max = ").AppendLine(Invariant(recipe.SpeculativeDraftMaxTokens)); + preset.AppendLine("spec-draft-backend-sampling = true"); + preset.Append("temperature = ").AppendLine(Invariant(sampling.Temperature)); + preset.Append("top-k = ").AppendLine(Invariant(sampling.TopK)); + preset.Append("top-p = ").AppendLine(Invariant(sampling.TopP)); + preset.Append("min-p = ").AppendLine(Invariant(sampling.MinP)); + preset.Append("repeat-penalty = ").AppendLine(Invariant(sampling.RepetitionPenalty)); + preset.Append("presence-penalty = ").AppendLine(Invariant(sampling.PresencePenalty)); + preset.AppendLine("jinja = true"); + preset.AppendLine("reasoning = on"); + preset.AppendLine("reasoning-format = deepseek"); + preset.AppendLine("context-shift = true"); + return preset.ToString(); + } + + private static string Invariant(T value) where T : IFormattable => + value.ToString(null, CultureInfo.InvariantCulture); +} diff --git a/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs b/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs new file mode 100644 index 000000000..66470ce47 --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs @@ -0,0 +1,800 @@ +using OpenClaw.Shared; +using System.Net; +using System.Text; + +namespace OpenClaw.Connection.LocalAi; + +public sealed record LlamaServerRuntimeOptions +{ + public required LocalAiPaths Paths { get; init; } + public Uri InitialEndpoint { get; init; } = new("http://127.0.0.1:18803/v1"); + public ILocalAiEndpointLifecycle EndpointLifecycle { get; init; } = NullLocalAiEndpointLifecycle.Instance; + public TimeSpan StartupTimeout { get; init; } = TimeSpan.FromSeconds(15); + public TimeSpan HealthPollInterval { get; init; } = TimeSpan.FromMilliseconds(250); + public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(10); + public TimeSpan RestartDelay { get; init; } = TimeSpan.FromSeconds(2); + public int MaxRestartAttempts { get; init; } = 2; + public long MaxLogBytes { get; init; } = 8 * 1024 * 1024; + public int LogBackupCount { get; init; } = 2; + public int MaxLogLineCharacters { get; init; } = 16 * 1024; +} + +internal interface ILlamaServerRuntimePlatform +{ + DateTimeOffset UtcNow { get; } + WindowsTcpListenerSnapshotResult CaptureListeners(); + Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken); +} + +internal sealed class SystemLlamaServerRuntimePlatform : ILlamaServerRuntimePlatform +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + public WindowsTcpListenerSnapshotResult CaptureListeners() => WindowsTcpListenerSnapshot.Capture(); + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) => Task.Delay(delay, cancellationToken); +} + +/// +/// Owns the native llama-server router for the lifetime of the Windows companion. +/// The router starts without a model; the first inference request triggers the +/// model load defined by the verified preset. +/// +public sealed class LlamaServerRuntimeService : ILocalAiRuntime +{ + private readonly LlamaServerRuntimeOptions _options; + private readonly LocalAiManifestStore _manifestStore; + private readonly IOpenClawLogger _logger; + private readonly ILocalAiManagedProcessHost _processHost; + private readonly ILlamaServerRuntimePlatform _platform; + private readonly ILlamaServerClient _client; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _exitTasksGate = new(); + private readonly HashSet _exitTasks = []; + private readonly object _snapshotGate = new(); + private LocalAiRuntimeSnapshot _snapshot; + private ILocalAiManagedProcess? _managedProcess; + private LocalAiResolvedInstall? _install; + private long _generation; + private int _restartAttempts; + private bool _stopping; + private bool _disposed; + private bool _acceptExitTasks = true; + private int _disposeStarted; + + public LlamaServerRuntimeService(LlamaServerRuntimeOptions options, IOpenClawLogger? logger = null) + : this( + options, + logger ?? NullLogger.Instance, + new WindowsLocalAiManagedProcessHost(logger ?? NullLogger.Instance), + new SystemLlamaServerRuntimePlatform(), + new LlamaServerClient()) + { + } + + internal LlamaServerRuntimeService( + LlamaServerRuntimeOptions options, + IOpenClawLogger logger, + ILocalAiManagedProcessHost processHost, + ILlamaServerRuntimePlatform platform, + ILlamaServerClient client) + { + _options = ValidateOptions(options); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _processHost = processHost ?? throw new ArgumentNullException(nameof(processHost)); + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _client = client ?? throw new ArgumentNullException(nameof(client)); + _manifestStore = new LocalAiManifestStore(options.Paths); + _snapshot = LocalAiRuntimeSnapshot.Initial(options.InitialEndpoint, platform.UtcNow); + } + + public event EventHandler? StateChanged; + + public LocalAiRuntimeSnapshot Snapshot + { + get { lock (_snapshotGate) return _snapshot; } + } + + public async Task EnsureStartedAsync(CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + _restartAttempts = 0; + return await EnsureStartedCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + return await RefreshCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + return await StopCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task RestartAsync(CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + LocalAiRuntimeSnapshot stopped = await StopCoreAsync(cancellationToken).ConfigureAwait(false); + if (_managedProcess is not null || stopped.State == LocalAiRuntimeState.Failed) + return stopped; + _restartAttempts = 0; + return await EnsureStartedCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + private async Task EnsureStartedCoreAsync(CancellationToken cancellationToken) + { + if (!await TryLoadInstallAsync(cancellationToken).ConfigureAwait(false)) + return Snapshot; + + LocalAiResolvedInstall install = _install!; + if (_managedProcess is { HasExited: false }) + return await RefreshCoreAsync(cancellationToken).ConfigureAwait(false); + if (_managedProcess is not null) + await DisposeManagedProcessAsync(CancellationToken.None).ConfigureAwait(false); + + LlamaServerRouterLaunchPlan launchPlan; + try + { + ValidateInstalledFiles(install); + LocalAiPortPolicy.Validate(install.Manifest.RequestedPort); + launchPlan = LlamaServerRouterConfiguration.Build( + _options.Paths, + install, + install.Manifest.RequestedPort); + await WritePresetAtomicallyAsync(launchPlan, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + _logger.Error("Could not prepare the managed llama-server router.", ex); + return Publish(LocalAiRuntimeState.Failed, LocalAiOwnership.None, Sanitize(ex.Message)); + } + + WindowsTcpListenerSnapshotResult beforeStart = _platform.CaptureListeners(); + if (!beforeStart.Ipv4Complete) + return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "TCP listener ownership could not be determined."); + if (install.Manifest.RequestedPort != LocalAiPortPolicy.Automatic && + FindLoopbackListeners(beforeStart, install.Manifest.RequestedPort).Count > 0) + { + return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "The configured llama-server port is already in use."); + } + + LocalAiEndpointLifecycleResult quiesced = await _options.EndpointLifecycle + .QuiesceAsync(install, cancellationToken) + .ConfigureAwait(false); + if (!quiesced.Success) + { + return Publish( + LocalAiRuntimeState.Failed, + LocalAiOwnership.None, + quiesced.Detail ?? "The Local AI gateway provider could not be safely disabled."); + } + + long generation = ++_generation; + Publish(LocalAiRuntimeState.Starting, LocalAiOwnership.CompanionManaged, "Starting the local AI router."); + var spec = new LocalAiProcessStartSpec( + install.ExecutablePath, + Path.GetDirectoryName(install.ExecutablePath)!, + launchPlan.Arguments, + launchPlan.Environment, + _options.Paths.StandardOutputLogPath, + _options.Paths.StandardErrorLogPath, + _options.MaxLogBytes, + _options.LogBackupCount, + _options.MaxLogLineCharacters); + + try + { + _managedProcess = await _processHost.StartProcessAsync( + spec, + exit => OnManagedProcessExited(generation, exit), + cancellationToken) + .ConfigureAwait(false); + + DateTimeOffset deadline = _platform.UtcNow + _options.StartupTimeout; + while (_platform.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_managedProcess.HasExited) + throw new InvalidOperationException("Managed llama-server exited during startup."); + + EndpointOwnershipObservation ownership = DiscoverOwnedEndpoint(install, _managedProcess); + if (!ownership.IsComplete) + return await FailStartupAsync(LocalAiRuntimeState.Conflict, "TCP listener ownership could not be determined.").ConfigureAwait(false); + if (ownership.ConflictDetail is not null) + { + return await FailStartupAsync(LocalAiRuntimeState.Conflict, ownership.ConflictDetail) + .ConfigureAwait(false); + } + if (ownership.Endpoint is not null) + { + LlamaServerRouterProbeResult probe = await _client.ProbeRouterAsync( + ownership.Endpoint, + install.Manifest.ModelAlias, + install.ModelPath, + cancellationToken) + .ConfigureAwait(false); + if (probe.IsHealthy) + { + LocalAiInstallManifest verifiedManifest = install.Manifest with + { + Endpoint = ownership.Endpoint.AbsoluteUri, + }; + await _manifestStore.SaveAsync(verifiedManifest, cancellationToken).ConfigureAwait(false); + _install = _manifestStore.ResolveAndValidate(verifiedManifest); + + LocalAiEndpointLifecycleResult published = await _options.EndpointLifecycle + .PublishAsync(_install, cancellationToken) + .ConfigureAwait(false); + if (!published.Success) + { + return await FailStartupAsync( + LocalAiRuntimeState.Failed, + published.Detail ?? "The Local AI gateway provider could not be safely published.") + .ConfigureAwait(false); + } + + return PublishHealthy(probe); + } + } + + await _platform.DelayAsync(_options.HealthPollInterval, cancellationToken).ConfigureAwait(false); + } + + return await FailStartupAsync( + LocalAiRuntimeState.Failed, + "The local AI router did not become healthy before the startup timeout.") + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + ++_generation; + await DisposeManagedProcessAsync(CancellationToken.None).ConfigureAwait(false); + Publish(LocalAiRuntimeState.Stopped, LocalAiOwnership.None, "Local AI startup was canceled."); + throw; + } + catch (Exception ex) + { + ++_generation; + await DisposeManagedProcessAsync(CancellationToken.None).ConfigureAwait(false); + _logger.Error("Managed llama-server startup failed.", ex); + return Publish(LocalAiRuntimeState.Failed, LocalAiOwnership.None, Sanitize(ex.Message)); + } + } + + private async Task RefreshCoreAsync(CancellationToken cancellationToken) + { + if (!await TryLoadInstallAsync(cancellationToken).ConfigureAwait(false)) + return Snapshot; + + try + { + ValidateInstalledFiles(_install!); + } + catch (InvalidDataException ex) + { + return Publish(LocalAiRuntimeState.Failed, LocalAiOwnership.None, Sanitize(ex.Message)); + } + + if (_managedProcess is null || _managedProcess.HasExited) + { + if (_install!.Endpoint is { } persistedEndpoint) + { + WindowsTcpListenerSnapshotResult snapshot = _platform.CaptureListeners(); + if (!snapshot.Ipv4Complete) + return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "TCP listener ownership could not be determined."); + if (FindLoopbackListeners(snapshot, persistedEndpoint.Port).Count > 0) + { + return Publish( + LocalAiRuntimeState.Conflict, + LocalAiOwnership.None, + "A process not owned by this companion is using the last verified Local AI endpoint."); + } + } + + return Publish( + LocalAiRuntimeState.Stopped, + LocalAiOwnership.None, + null, + modelState: LocalAiModelAvailabilityState.Verified); + } + + LocalAiResolvedInstall install = _install!; + EndpointOwnershipObservation ownership = DiscoverOwnedEndpoint(install, _managedProcess); + if (!ownership.IsComplete) + return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "TCP listener ownership could not be determined."); + if (ownership.ConflictDetail is not null) + return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, ownership.ConflictDetail); + if (ownership.Endpoint is null) + return Publish(LocalAiRuntimeState.Starting, LocalAiOwnership.CompanionManaged, "The local AI router has not opened its endpoint yet.", _managedProcess.ProcessId, _managedProcess.StartedAtUtc); + + LlamaServerRouterProbeResult probe = await _client.ProbeRouterAsync( + ownership.Endpoint, + install.Manifest.ModelAlias, + install.ModelPath, + cancellationToken) + .ConfigureAwait(false); + return probe.IsHealthy + ? PublishHealthy(probe) + : Publish( + LocalAiRuntimeState.Starting, + LocalAiOwnership.CompanionManaged, + "The local AI router is not healthy yet.", + _managedProcess.ProcessId, + _managedProcess.StartedAtUtc); + } + + private async Task TryLoadInstallAsync(CancellationToken cancellationToken) + { + try + { + _install = await _manifestStore.LoadAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + _logger.Error("Could not load the local AI installation manifest.", ex); + Publish(LocalAiRuntimeState.Failed, LocalAiOwnership.None, Sanitize(ex.Message)); + return false; + } + + if (_install is not null) + return true; + Publish(LocalAiRuntimeState.NotInstalled, LocalAiOwnership.None, "Local AI is not installed."); + return false; + } + + private static void ValidateInstalledFiles(LocalAiResolvedInstall install) + { + if (!File.Exists(install.ExecutablePath)) + throw new InvalidDataException("The managed llama-server executable is missing."); + var model = new FileInfo(install.ModelPath); + if (!model.Exists || model.Length != install.Manifest.ModelAsset.SizeBytes) + throw new InvalidDataException("The managed GGUF model is missing or has an unexpected size."); + } + + private async Task WritePresetAtomicallyAsync( + LlamaServerRouterLaunchPlan plan, + CancellationToken cancellationToken) + { + _options.Paths.EnsureDirectories(); + string temporaryPath = Path.Combine( + _options.Paths.RootDirectory, + $".{Path.GetFileName(plan.PresetPath)}.{Guid.NewGuid():N}.tmp"); + try + { + _ = _options.Paths.ResolveContainedPath(Path.GetFileName(temporaryPath), nameof(temporaryPath)); + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + byte[] content = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(plan.PresetContent); + await stream.WriteAsync(content, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + _ = _options.Paths.ResolveContainedPath( + Path.GetRelativePath(_options.Paths.RootDirectory, plan.PresetPath), + nameof(plan.PresetPath)); + File.Move(temporaryPath, plan.PresetPath, overwrite: true); + } + finally + { + try { File.Delete(temporaryPath); } + catch { } + } + } + + private async Task StopCoreAsync(CancellationToken cancellationToken) + { + if (_install is null && !await TryLoadInstallAsync(cancellationToken).ConfigureAwait(false)) + return Snapshot; + + LocalAiEndpointLifecycleResult quiesced = await _options.EndpointLifecycle + .QuiesceAsync(_install!, cancellationToken) + .ConfigureAwait(false); + if (!quiesced.Success) + { + return Publish( + LocalAiRuntimeState.Failed, + _managedProcess is null ? LocalAiOwnership.None : LocalAiOwnership.CompanionManaged, + quiesced.Detail ?? "The Local AI gateway provider could not be safely disabled.", + _managedProcess?.ProcessId, + _managedProcess?.StartedAtUtc); + } + + if (_managedProcess is null) + { + return Publish( + LocalAiRuntimeState.Stopped, + LocalAiOwnership.None, + null, + modelState: LocalAiModelAvailabilityState.Verified); + } + + _stopping = true; + ++_generation; + Publish(LocalAiRuntimeState.Stopping, LocalAiOwnership.CompanionManaged, "Stopping the local AI router.", _managedProcess.ProcessId, _managedProcess.StartedAtUtc); + try + { + await DisposeManagedProcessAsync(cancellationToken).ConfigureAwait(false); + return Publish( + LocalAiRuntimeState.Stopped, + LocalAiOwnership.None, + null, + modelState: LocalAiModelAvailabilityState.Verified); + } + finally + { + _stopping = false; + } + } + + private async Task FailStartupAsync(LocalAiRuntimeState state, string detail) + { + ++_generation; + await DisposeManagedProcessAsync(CancellationToken.None).ConfigureAwait(false); + return Publish(state, LocalAiOwnership.None, detail); + } + + private async Task DisposeManagedProcessAsync(CancellationToken cancellationToken) + { + ILocalAiManagedProcess? process = _managedProcess; + _managedProcess = null; + if (process is null) + return; + try + { + await process.StopAsync(_options.ShutdownTimeout, cancellationToken).ConfigureAwait(false); + } + finally + { + await process.DisposeAsync().ConfigureAwait(false); + } + } + + private void OnManagedProcessExited(long generation, LocalAiManagedProcessExit exit) + { + Task exitTask; + lock (_exitTasksGate) + { + if (!_acceptExitTasks) + return; + exitTask = Task.Run(() => HandleManagedProcessExitedAsync(generation, exit)); + _exitTasks.Add(exitTask); + } + _ = RemoveCompletedExitTaskAsync(exitTask); + } + + private async Task HandleManagedProcessExitedAsync(long generation, LocalAiManagedProcessExit exit) + { + try + { + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed || _stopping || generation != _generation) + return; + ILocalAiManagedProcess? exited = _managedProcess; + _managedProcess = null; + if (exited is not null) + await exited.DisposeAsync().ConfigureAwait(false); + + if (_install is not null) + { + LocalAiEndpointLifecycleResult quiesced = await _options.EndpointLifecycle + .QuiesceAsync(_install, CancellationToken.None) + .ConfigureAwait(false); + if (!quiesced.Success) + { + Publish( + LocalAiRuntimeState.Failed, + LocalAiOwnership.None, + quiesced.Detail ?? "The Local AI gateway provider could not be safely disabled after the router exited."); + return; + } + } + Publish( + LocalAiRuntimeState.Failed, + LocalAiOwnership.None, + $"Managed llama-server exited unexpectedly{(exit.ExitCode.HasValue ? $" with code {exit.ExitCode.Value}" : string.Empty)}."); + if (_restartAttempts >= _options.MaxRestartAttempts) + return; + _restartAttempts++; + } + finally + { + _operationGate.Release(); + } + + await _platform.DelayAsync(_options.RestartDelay, CancellationToken.None).ConfigureAwait(false); + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + if (!_disposed && !_stopping && generation == _generation) + await EnsureStartedCoreAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + catch (Exception ex) + { + _logger.Error("Managed llama-server automatic restart failed.", ex); + } + } + + private async Task RemoveCompletedExitTaskAsync(Task exitTask) + { + await exitTask.ConfigureAwait(false); + lock (_exitTasksGate) + _exitTasks.Remove(exitTask); + } + + private EndpointOwnershipObservation DiscoverOwnedEndpoint( + LocalAiResolvedInstall install, + ILocalAiManagedProcess process) + { + WindowsTcpListenerSnapshotResult snapshot = _platform.CaptureListeners(); + if (!snapshot.Ipv4Complete) + return new(false, null, null); + + WindowsTcpListenerInfo[] loopbackListeners = snapshot.Listeners + .Where(IsIpv4LoopbackListener) + .ToArray(); + WindowsTcpListenerInfo[] processListeners = loopbackListeners + .Where(listener => listener.ProcessId == process.ProcessId) + .ToArray(); + WindowsTcpListenerInfo[] ownedListeners = processListeners + .Where(listener => IsManagedListener(listener, process)) + .ToArray(); + if (processListeners.Length != ownedListeners.Length) + { + return new( + true, + null, + "A llama-server listener was found, but its process start time could not be verified."); + } + + int requestedPort = install.Manifest.RequestedPort; + if (requestedPort != LocalAiPortPolicy.Automatic) + { + IReadOnlyList requestedListeners = FindLoopbackListeners(snapshot, requestedPort); + if (requestedListeners.Any(listener => !IsManagedListener(listener, process))) + return new(true, null, "Another process owns the configured llama-server endpoint."); + if (ownedListeners.Any(listener => listener.Port != requestedPort)) + return new(true, null, "Managed llama-server did not bind the requested fixed port."); + if (requestedListeners.Count == 0) + return new(true, null, null); + return new(true, BuildEndpoint(requestedPort), null); + } + + int[] ownedPorts = ownedListeners.Select(listener => listener.Port).Distinct().ToArray(); + if (ownedPorts.Length == 0) + return new(true, null, null); + if (ownedPorts.Length != 1) + return new(true, null, "Managed llama-server opened more than one candidate loopback endpoint."); + + int selectedPort = ownedPorts[0]; + if (FindLoopbackListeners(snapshot, selectedPort).Any(listener => !IsManagedListener(listener, process))) + return new(true, null, "Another process shares the managed llama-server endpoint."); + return new(true, BuildEndpoint(selectedPort), null); + } + + private static IReadOnlyList FindLoopbackListeners( + WindowsTcpListenerSnapshotResult snapshot, + int port) => snapshot.Listeners + .Where(listener => listener.Port == port && IsIpv4LoopbackListener(listener)) + .ToArray(); + + private static bool IsIpv4LoopbackListener(WindowsTcpListenerInfo listener) => + listener.Address.Equals(IPAddress.Loopback) || listener.Address.Equals(IPAddress.Any); + + private static Uri BuildEndpoint(int port) => + new UriBuilder(Uri.UriSchemeHttp, "127.0.0.1", port, "/v1").Uri; + + private LocalAiRuntimeSnapshot PublishHealthy(LlamaServerRouterProbeResult probe) => + Publish( + LocalAiRuntimeState.Healthy, + LocalAiOwnership.CompanionManaged, + probe.Detail, + _managedProcess?.ProcessId, + _managedProcess?.StartedAtUtc, + probe.ModelState); + + private static bool IsManagedListener( + WindowsTcpListenerInfo listener, + ILocalAiManagedProcess process) => + listener.ProcessId == process.ProcessId && + listener.ProcessStartTimeUtc is { } started && + Math.Abs((started - process.StartedAtUtc.UtcDateTime).TotalSeconds) < 1; + + private LocalAiRuntimeSnapshot Publish( + LocalAiRuntimeState state, + LocalAiOwnership ownership, + string? detail, + int? processId = null, + DateTimeOffset? processStartedAtUtc = null, + LocalAiModelAvailabilityState modelState = LocalAiModelAvailabilityState.Unknown) + { + DateTimeOffset now = _platform.UtcNow; + if (state == LocalAiRuntimeState.NotInstalled) + modelState = LocalAiModelAvailabilityState.NotInstalled; + LocalAiModelEvidence evidence = BuildModelEvidence(modelState, now); + var value = new LocalAiRuntimeSnapshot( + state, + ownership, + _install?.Endpoint ?? _options.InitialEndpoint, + _install?.Manifest.EngineVersion, + _install?.Manifest.ModelCatalogId, + evidence, + processId, + processStartedAtUtc, + detail, + now); + lock (_snapshotGate) + _snapshot = value; + + EventHandler? handler = StateChanged; + if (handler is not null) + { + foreach (EventHandler subscriber in handler.GetInvocationList()) + { + try { subscriber(this, new(value)); } + catch (Exception ex) { _logger.Warn($"A local AI state observer failed: {Sanitize(ex.Message)}"); } + } + } + return value; + } + + private LocalAiModelEvidence BuildModelEvidence( + LocalAiModelAvailabilityState state, + DateTimeOffset now) => state switch + { + LocalAiModelAvailabilityState.NotInstalled => LocalAiModelEvidence.NotInstalled(now), + LocalAiModelAvailabilityState.Verified when _install is not null => new( + state, + now, + _install.Manifest.ModelAsset.Sha256, + _install.Manifest.ModelAsset.SizeBytes), + LocalAiModelAvailabilityState.Loaded when _install is not null => new( + state, + now, + _install.Manifest.ModelAsset.Sha256, + _install.Manifest.ModelAsset.SizeBytes, + _install.Manifest.ModelAlias), + _ => LocalAiModelEvidence.Unknown(now), + }; + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; + + Task[] exitTasks; + lock (_exitTasksGate) + { + _acceptExitTasks = false; + exitTasks = [.. _exitTasks]; + } + + try + { + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) + return; + _stopping = true; + ++_generation; + if (_install is not null) + { + try + { + LocalAiEndpointLifecycleResult quiesced = await _options.EndpointLifecycle + .QuiesceAsync(_install, CancellationToken.None) + .ConfigureAwait(false); + if (!quiesced.Success) + _logger.Warn(quiesced.Detail ?? "The Local AI gateway provider could not be disabled during shutdown."); + } + catch (Exception ex) + { + _logger.Warn($"The Local AI gateway provider could not be disabled during shutdown: {Sanitize(ex.Message)}"); + } + } + await DisposeManagedProcessAsync(CancellationToken.None).ConfigureAwait(false); + _disposed = true; + _client.Dispose(); + } + finally + { + _stopping = false; + _operationGate.Release(); + } + } + finally + { + await Task.WhenAll(exitTasks).ConfigureAwait(false); + _operationGate.Dispose(); + } + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + + private static LlamaServerRuntimeOptions ValidateOptions(LlamaServerRuntimeOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options.Paths); + ArgumentNullException.ThrowIfNull(options.EndpointLifecycle); + if (!options.InitialEndpoint.IsAbsoluteUri || + options.InitialEndpoint.Scheme != Uri.UriSchemeHttp || + !string.Equals(options.InitialEndpoint.Host, "127.0.0.1", StringComparison.Ordinal) || + options.InitialEndpoint.Port is <= 0 or > 65535 || + options.InitialEndpoint.Port == 80 || + !string.Equals(options.InitialEndpoint.AbsolutePath, "/v1", StringComparison.Ordinal) || + !string.IsNullOrEmpty(options.InitialEndpoint.Query) || + !string.IsNullOrEmpty(options.InitialEndpoint.Fragment) || + !string.IsNullOrEmpty(options.InitialEndpoint.UserInfo)) + { + throw new ArgumentException("The initial local AI endpoint must use an explicit IPv4 loopback port.", nameof(options)); + } + if (options.StartupTimeout <= TimeSpan.Zero || + options.HealthPollInterval <= TimeSpan.Zero || + options.ShutdownTimeout <= TimeSpan.Zero || + options.RestartDelay < TimeSpan.Zero) + { + throw new ArgumentException("Runtime timeouts must be positive.", nameof(options)); + } + if (options.MaxRestartAttempts < 0 || + options.MaxLogBytes <= 0 || + options.LogBackupCount < 0 || + options.MaxLogLineCharacters <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "Runtime limits are invalid."); + } + return options; + } + + private static string Sanitize(string value) => TokenSanitizer.SanitizeLogMessage(value); + + private sealed record EndpointOwnershipObservation( + bool IsComplete, + Uri? Endpoint, + string? ConflictDetail); +} diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiEndpointLifecycle.cs b/src/OpenClaw.Connection/LocalAi/LocalAiEndpointLifecycle.cs new file mode 100644 index 000000000..4c17b2b63 --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LocalAiEndpointLifecycle.cs @@ -0,0 +1,44 @@ +namespace OpenClaw.Connection.LocalAi; + +public sealed record LocalAiEndpointLifecycleResult(bool Success, string? Detail = null) +{ + public static LocalAiEndpointLifecycleResult Ok() => new(true); + public static LocalAiEndpointLifecycleResult Failed(string detail) => new(false, detail); +} + +/// +/// Coordinates consumers of the app-owned endpoint with native process changes. +/// Implementations must remove managed routing before a listener can disappear, +/// and publish routing only after the replacement endpoint is proven healthy. +/// +public interface ILocalAiEndpointLifecycle +{ + Task QuiesceAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default); + + Task PublishAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default); +} + +internal sealed class NullLocalAiEndpointLifecycle : ILocalAiEndpointLifecycle +{ + public static NullLocalAiEndpointLifecycle Instance { get; } = new(); + + public Task QuiesceAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(LocalAiEndpointLifecycleResult.Ok()); + } + + public Task PublishAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(LocalAiEndpointLifecycleResult.Ok()); + } +} diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs b/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs new file mode 100644 index 000000000..ce18b2423 --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs @@ -0,0 +1,63 @@ +using OpenClaw.Shared.Inference.Catalog; +using System.Text.Json; + +namespace OpenClaw.Connection.LocalAi; + +/// Canonical gateway configuration for the companion-owned llama.cpp provider. +public static class LocalAiGatewayProviderDefinition +{ + public const string ProviderPath = "models.providers.llamacpp"; + public const string PrimaryModelPath = "agents.defaults.model.primary"; + public const int ProviderTimeoutSeconds = 300; + public const int MaximumOutputTokens = 8_192; + + public static string BuildProviderJson(LocalAiResolvedInstall install) + { + ArgumentNullException.ThrowIfNull(install); + Uri endpoint = install.Endpoint + ?? throw new InvalidOperationException("The verified Local AI endpoint is required."); + LocalModelInfo model = LocalModelCatalog.Find(install.Manifest.ModelCatalogId) + ?? throw new InvalidDataException("The managed Local AI model is no longer qualified."); + if (!string.Equals(model.Id, install.Manifest.ModelAlias, StringComparison.Ordinal)) + throw new InvalidDataException("The managed Local AI model alias does not match the qualified catalog."); + + var value = new + { + baseUrl = endpoint.AbsoluteUri.TrimEnd('/'), + api = "openai-completions", + apiKey = "llama-local", + timeoutSeconds = ProviderTimeoutSeconds, + models = new[] + { + new + { + id = install.Manifest.ModelAlias, + name = model.DisplayName, + reasoning = true, + input = new[] { "text" }, + cost = new { input = 0, output = 0, cacheRead = 0, cacheWrite = 0 }, + contextWindow = install.Manifest.ContextLength, + contextTokens = install.Manifest.ContextLength, + maxTokens = MaximumOutputTokens, + compat = new { supportsTools = true, supportsUsageInStreaming = true }, + }, + }, + }; + return JsonSerializer.Serialize(value); + } + + public static string BuildPrimaryModel(LocalAiResolvedInstall install) + { + ArgumentNullException.ThrowIfNull(install); + return $"llamacpp/{install.Manifest.ModelAlias}"; + } + + public static string BuildProviderBatchJson(LocalAiResolvedInstall install) + { + using JsonDocument provider = JsonDocument.Parse(BuildProviderJson(install)); + return JsonSerializer.Serialize(new[] + { + new { path = ProviderPath, value = (object)provider.RootElement.Clone() }, + }); + } +} diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs b/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs index 82b273874..edb42ee91 100644 --- a/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs +++ b/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Net; using System.Text.Json; using System.Text.Json.Serialization; @@ -19,6 +18,7 @@ public LocalAiPaths(string localDataDirectory) DownloadsDirectory = Path.Combine(RootDirectory, "downloads"); StagingDirectory = Path.Combine(RootDirectory, "staging"); LogsDirectory = Path.Combine(RootDirectory, "logs"); + RouterPresetPath = Path.Combine(RootDirectory, "llama-server-models.ini"); StandardOutputLogPath = Path.Combine(LogsDirectory, "llama-server.stdout.log"); StandardErrorLogPath = Path.Combine(LogsDirectory, "llama-server.stderr.log"); } @@ -31,6 +31,7 @@ public LocalAiPaths(string localDataDirectory) public string DownloadsDirectory { get; } public string StagingDirectory { get; } public string LogsDirectory { get; } + public string RouterPresetPath { get; } public string StandardOutputLogPath { get; } public string StandardErrorLogPath { get; } @@ -118,7 +119,7 @@ public sealed record LocalAiAssetReceipt public sealed record LocalAiInstallManifest { - public const int CurrentSchemaVersion = 2; + public const int CurrentSchemaVersion = 3; public const string SupportedEngine = "llama-server"; public int SchemaVersion { get; init; } = CurrentSchemaVersion; @@ -140,7 +141,17 @@ public sealed record LocalAiInstallManifest public required string ModelId { get; init; } public required string ModelAlias { get; init; } public required LocalAiAssetReceipt ModelAsset { get; init; } - public required string Endpoint { get; init; } + /// + /// The requested listener port. Zero delegates allocation to llama-server so + /// the child owns the port continuously from bind through startup. + /// + public int RequestedPort { get; init; } + + /// + /// The last endpoint whose listener ownership and health were verified. It is + /// intentionally absent while an automatic-port runtime has not started yet. + /// + public string? Endpoint { get; init; } /// /// The non-Local-AI primary model that was active before setup selected the /// managed llama.cpp model. Null means no prior primary model was configured. @@ -154,7 +165,30 @@ public sealed record LocalAiResolvedInstall( LocalAiInstallManifest Manifest, string ExecutablePath, string ModelPath, - Uri Endpoint); + Uri? Endpoint); + +/// Shared validation for setup, manifests, and runtime launch. +public static class LocalAiPortPolicy +{ + public const int Automatic = 0; + + public static bool TryValidate(int requestedPort, out string? error) + { + error = requestedPort switch + { + 80 => "Port 80 is reserved and cannot be used for Local AI.", + < 0 or > 65_535 => "The Local AI port must be zero (automatic) or between 1 and 65535.", + _ => null, + }; + return error is null; + } + + public static void Validate(int requestedPort) + { + if (!TryValidate(requestedPort, out string? error)) + throw new InvalidDataException(error); + } +} /// Validation for the non-managed gateway route retained in a manifest. public static class LocalAiGatewayModelPolicy @@ -320,18 +354,28 @@ public LocalAiResolvedInstall ResolveAndValidate(LocalAiInstallManifest manifest if (!string.Equals(Path.GetFileName(model), manifest.ModelAsset.FileName, StringComparison.Ordinal)) throw new InvalidDataException("The managed model path must match its asset receipt filename."); + LocalAiPortPolicy.Validate(manifest.RequestedPort); LocalAiGatewayModelPolicy.ValidateFallbackModel(manifest.GatewayFallbackModel); - if (!Uri.TryCreate(manifest.Endpoint, UriKind.Absolute, out var endpoint) || - endpoint.Scheme != Uri.UriSchemeHttp || - !IsLoopback(endpoint) || - endpoint.IsDefaultPort || - endpoint.Port is <= 0 or > 65535 || - !string.IsNullOrEmpty(endpoint.UserInfo) || - !string.IsNullOrEmpty(endpoint.Query) || - !string.IsNullOrEmpty(endpoint.Fragment)) + Uri? endpoint = null; + if (manifest.Endpoint is not null) { - throw new InvalidDataException("The local AI endpoint must be an HTTP loopback address with an explicit port."); + if (!Uri.TryCreate(manifest.Endpoint, UriKind.Absolute, out endpoint) || + endpoint.Scheme != Uri.UriSchemeHttp || + !string.Equals(endpoint.Host, "127.0.0.1", StringComparison.Ordinal) || + endpoint.IsDefaultPort || + endpoint.Port is <= 0 or > 65535 || + endpoint.Port == 80 || + !string.IsNullOrEmpty(endpoint.UserInfo) || + !string.IsNullOrEmpty(endpoint.Query) || + !string.IsNullOrEmpty(endpoint.Fragment) || + !string.Equals(endpoint.AbsolutePath, "/v1", StringComparison.Ordinal)) + { + throw new InvalidDataException("The local AI endpoint must be an HTTP IPv4 loopback /v1 address with an explicit non-reserved port."); + } + + if (manifest.RequestedPort != LocalAiPortPolicy.Automatic && endpoint.Port != manifest.RequestedPort) + throw new InvalidDataException("The verified Local AI endpoint does not match its requested fixed port."); } return new LocalAiResolvedInstall(manifest, executable, model, endpoint); @@ -411,7 +455,4 @@ source.Query is not ("" or "?download=true")) } } - private static bool IsLoopback(Uri endpoint) => - string.Equals(endpoint.Host, "localhost", StringComparison.OrdinalIgnoreCase) || - (IPAddress.TryParse(endpoint.Host, out var address) && IPAddress.IsLoopback(address)); } diff --git a/tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs b/tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs new file mode 100644 index 000000000..c3a05678a --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs @@ -0,0 +1,487 @@ +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared; +using OpenClaw.Shared.Inference.Catalog; +using OpenClaw.TestSupport; +using System.Collections.Immutable; +using System.Net; + +namespace OpenClaw.Connection.Tests; + +public sealed class LocalAiPortLifecycleTests +{ + [Theory] + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(80, false)] + [InlineData(65_535, true)] + [InlineData(65_536, false)] + public void PortPolicy_IsConsistent(int port, bool accepted) + { + Assert.Equal(accepted, LocalAiPortPolicy.TryValidate(port, out _)); + } + + [Fact] + public async Task Manifest_RoundTripsValidatedGatewayFallbackModel() + { + using var temp = new TempDirectory("local-ai-manifest-"); + var store = new LocalAiManifestStore(new LocalAiPaths(temp.Path)); + + await store.SaveAsync(ValidManifest() with { GatewayFallbackModel = "openai/gpt-5" }); + + LocalAiResolvedInstall saved = (await store.LoadAsync())!; + Assert.Equal("openai/gpt-5", saved.Manifest.GatewayFallbackModel); + } + + [Fact] + public async Task Manifest_AcceptsAndIgnoresLegacyHardwareProfileId() + { + using var temp = new TempDirectory("local-ai-manifest-"); + var paths = new LocalAiPaths(temp.Path); + var store = new LocalAiManifestStore(paths); + await store.SaveAsync(ValidManifest() with { HardwareProfileId = "retired-profile-id" }); + + LocalAiResolvedInstall saved = (await store.LoadAsync())!; + LlamaServerRouterLaunchPlan launch = LlamaServerRouterConfiguration.Build(paths, saved); + + Assert.Equal("retired-profile-id", saved.Manifest.HardwareProfileId); + Assert.Equal(LocalModelCatalog.Qwen35BModelId, launch.ModelAlias); + } + + [Fact] + public async Task Manifest_OmitsLegacyHardwareProfileIdFromNewWrites() + { + using var temp = new TempDirectory("local-ai-manifest-"); + var paths = new LocalAiPaths(temp.Path); + await new LocalAiManifestStore(paths).SaveAsync(ValidManifest()); + + string json = await File.ReadAllTextAsync(paths.ManifestPath); + + Assert.DoesNotContain("hardwareProfileId", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Router_RejectsRuntimeArchitectureMismatchWithoutHardwareProfile() + { + using var temp = new TempDirectory("local-ai-manifest-"); + var paths = new LocalAiPaths(temp.Path); + var store = new LocalAiManifestStore(paths); + await store.SaveAsync(ValidManifest() with { Architecture = "x64" }); + LocalAiResolvedInstall saved = (await store.LoadAsync())!; + + InvalidDataException error = Assert.Throws( + () => LlamaServerRouterConfiguration.Build(paths, saved)); + + Assert.Contains("architecture and runtime", error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData("llamacpp/other-model")] + [InlineData("missing-provider-separator")] + [InlineData("provider/model/extra")] + public async Task Manifest_RejectsUnsafeGatewayFallbackModel(string fallbackModel) + { + using var temp = new TempDirectory("local-ai-manifest-"); + var store = new LocalAiManifestStore(new LocalAiPaths(temp.Path)); + + await Assert.ThrowsAsync(() => + store.SaveAsync(ValidManifest() with { GatewayFallbackModel = fallbackModel })); + } + + [Fact] + public async Task AutomaticPort_IsBoundByChildAndPersistedOnlyAfterOwnedHealth() + { + using var temp = new TempDirectory("local-ai-port-"); + LocalAiPaths paths = await PrepareInstallAsync(temp); + var events = new List(); + var platform = new FakePlatform(); + var host = new FakeProcessHost(platform, events, selectedPort: 28_765); + var client = new FakeClient(events); + var lifecycle = new FakeLifecycle(events); + await using var runtime = CreateRuntime(paths, host, platform, client, lifecycle); + + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(); + + Assert.Equal(LocalAiRuntimeState.Healthy, snapshot.State); + Assert.Equal(28_765, snapshot.Endpoint.Port); + Assert.Equal("0", ArgumentAfter(host.LastSpec!.Arguments, "--port")); + Assert.Equal(["quiesce", "start", "probe:28765", "publish:28765"], events); + Assert.Equal([28_765], client.ProbedPorts); + LocalAiResolvedInstall? saved = await new LocalAiManifestStore(paths).LoadAsync(); + Assert.Equal(0, saved!.Manifest.RequestedPort); + Assert.Equal(28_765, saved.Endpoint!.Port); + } + + [Fact] + public async Task AutomaticPort_NeverProbesListenerWithoutMatchingProcessStartTime() + { + using var temp = new TempDirectory("local-ai-port-"); + LocalAiPaths paths = await PrepareInstallAsync(temp); + var platform = new FakePlatform(); + var host = new FakeProcessHost( + platform, + [], + selectedPort: 28_766, + listenerStartOffset: TimeSpan.FromMinutes(-1)); + var client = new FakeClient([]); + await using var runtime = CreateRuntime( + paths, + host, + platform, + client, + new FakeLifecycle([])); + + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(); + + Assert.Equal(LocalAiRuntimeState.Conflict, snapshot.State); + Assert.Empty(client.ProbedPorts); + Assert.True(host.Process!.StopCount > 0); + LocalAiResolvedInstall? saved = await new LocalAiManifestStore(paths).LoadAsync(); + Assert.Null(saved!.Endpoint); + } + + [Fact] + public async Task FixedPortConflict_QuiescesEndpointConsumerBeforeReturning() + { + using var temp = new TempDirectory("local-ai-port-"); + LocalAiPaths paths = await PrepareInstallAsync(temp); + const int fixedPort = 28_770; + var store = new LocalAiManifestStore(paths); + LocalAiResolvedInstall install = (await store.LoadAsync())!; + await store.SaveAsync(install.Manifest with + { + RequestedPort = fixedPort, + Endpoint = $"http://127.0.0.1:{fixedPort}/v1", + }); + var events = new List(); + var platform = new FakePlatform(); + platform.Listeners.Add(new WindowsTcpListenerInfo( + IPAddress.Loopback, + fixedPort, + 9001, + "other-process", + @"C:\other\server.exe", + platform.UtcNow.UtcDateTime)); + var host = new FakeProcessHost(platform, events, selectedPort: fixedPort); + await using var runtime = CreateRuntime( + paths, + host, + platform, + new FakeClient(events), + new FakeLifecycle(events)); + + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(); + + Assert.Equal(LocalAiRuntimeState.Conflict, snapshot.State); + Assert.Equal(["quiesce"], events); + Assert.Null(host.LastSpec); + } + + [Fact] + public async Task PreparationFailure_QuiescesEndpointConsumerBeforeReturning() + { + using var temp = new TempDirectory("local-ai-port-"); + LocalAiPaths paths = await PrepareInstallAsync(temp); + LocalAiResolvedInstall install = (await new LocalAiManifestStore(paths).LoadAsync())!; + File.Delete(install.ExecutablePath); + var events = new List(); + var platform = new FakePlatform(); + var host = new FakeProcessHost(platform, events, selectedPort: 28_772); + await using var runtime = CreateRuntime( + paths, + host, + platform, + new FakeClient(events), + new FakeLifecycle(events)); + + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(); + + Assert.Equal(LocalAiRuntimeState.Failed, snapshot.State); + Assert.Equal(["quiesce"], events); + Assert.Null(host.LastSpec); + } + + [Fact] + public async Task AutomaticPort_RejectsWildcardChildListenerWithoutProbing() + { + using var temp = new TempDirectory("local-ai-port-"); + LocalAiPaths paths = await PrepareInstallAsync(temp); + var events = new List(); + var platform = new FakePlatform(); + var host = new FakeProcessHost( + platform, + events, + selectedPort: 28_771, + listenerAddress: IPAddress.Any); + var client = new FakeClient(events); + await using var runtime = CreateRuntime( + paths, + host, + platform, + client, + new FakeLifecycle(events)); + + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(); + + Assert.Equal(LocalAiRuntimeState.Conflict, snapshot.State); + Assert.Empty(client.ProbedPorts); + Assert.Equal(["quiesce", "start", "stop"], events); + LocalAiResolvedInstall? saved = await new LocalAiManifestStore(paths).LoadAsync(); + Assert.Null(saved!.Endpoint); + } + + [Fact] + public async Task Stop_QuiescesEndpointConsumerBeforeListenerDisappears() + { + using var temp = new TempDirectory("local-ai-port-"); + LocalAiPaths paths = await PrepareInstallAsync(temp); + var events = new List(); + var platform = new FakePlatform(); + var host = new FakeProcessHost(platform, events, selectedPort: 28_769); + await using var runtime = CreateRuntime( + paths, + host, + platform, + new FakeClient(events), + new FakeLifecycle(events)); + await runtime.EnsureStartedAsync(); + events.Clear(); + + LocalAiRuntimeSnapshot stopped = await runtime.StopAsync(); + + Assert.Equal(LocalAiRuntimeState.Stopped, stopped.State); + Assert.Equal(["quiesce", "stop"], events); + } + + [Fact] + public async Task PublishFailure_StopsChildAndLeavesEndpointConsumerQuiesced() + { + using var temp = new TempDirectory("local-ai-port-"); + LocalAiPaths paths = await PrepareInstallAsync(temp); + var events = new List(); + var platform = new FakePlatform(); + var host = new FakeProcessHost(platform, events, selectedPort: 28_767); + var lifecycle = new FakeLifecycle(events) { FailPublish = true }; + await using var runtime = CreateRuntime(paths, host, platform, new FakeClient(events), lifecycle); + + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(); + + Assert.Equal(LocalAiRuntimeState.Failed, snapshot.State); + Assert.Equal(1, host.Process!.StopCount); + Assert.Equal(["quiesce", "start", "probe:28767", "publish:28767", "stop"], events); + + // The endpoint receipt is already durable but the provider is still absent. + // A later tray start must safely allocate again and complete publication. + await runtime.DisposeAsync(); + var retryPlatform = new FakePlatform(); + var retryHost = new FakeProcessHost(retryPlatform, [], selectedPort: 28_768); + await using var retry = CreateRuntime( + paths, + retryHost, + retryPlatform, + new FakeClient([]), + new FakeLifecycle([])); + LocalAiRuntimeSnapshot recovered = await retry.EnsureStartedAsync(); + + Assert.Equal(LocalAiRuntimeState.Healthy, recovered.State); + Assert.Equal(28_768, recovered.Endpoint.Port); + } + + private static LlamaServerRuntimeService CreateRuntime( + LocalAiPaths paths, + FakeProcessHost host, + FakePlatform platform, + FakeClient client, + ILocalAiEndpointLifecycle lifecycle) => new( + new LlamaServerRuntimeOptions + { + Paths = paths, + EndpointLifecycle = lifecycle, + HealthPollInterval = TimeSpan.FromMilliseconds(1), + StartupTimeout = TimeSpan.FromSeconds(1), + RestartDelay = TimeSpan.Zero, + }, + NullLogger.Instance, + host, + platform, + client); + + private static async Task PrepareInstallAsync(TempDirectory temp) + { + var paths = new LocalAiPaths(temp.Path); + LocalAiInstallManifest manifest = ValidManifest(); + string executable = paths.ResolveContainedPath(manifest.ExecutablePath, nameof(manifest.ExecutablePath)); + string model = paths.ResolveContainedPath(manifest.ModelPath, nameof(manifest.ModelPath)); + Directory.CreateDirectory(Path.GetDirectoryName(executable)!); + Directory.CreateDirectory(Path.GetDirectoryName(model)!); + await File.WriteAllTextAsync(executable, "test executable"); + await using (var stream = new FileStream(model, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + stream.SetLength(manifest.ModelAsset.SizeBytes); + await new LocalAiManifestStore(paths).SaveAsync(manifest); + return paths; + } + + private static string ArgumentAfter(IReadOnlyList arguments, string name) + { + int index = Array.IndexOf(arguments.ToArray(), name); + Assert.InRange(index, 0, arguments.Count - 2); + return arguments[index + 1]; + } + + private static LocalAiInstallManifest ValidManifest() => new() + { + EngineVersion = "b10488", + Architecture = "arm64", + RuntimeId = "b10488-cuda13-arm64", + ModelCatalogId = "qwen3.6-35b-a3b-mtp-q4-k-m", + SelectedGpuId = "GPU-01234567-89ab-cdef-0123-456789abcdef", + ExecutablePath = Path.Combine("engines", "llama-b10488", "llama-server.exe"), + RuntimeAssets = + [ + new LocalAiAssetReceipt + { + FileName = "llama-b10488-bin-win-cuda-13.4-arm64.zip", + SourceUrl = "https://github.com/ggml-org/llama.cpp/releases/download/b10488/llama-b10488-bin-win-cuda-13.4-arm64.zip", + SizeBytes = 140_379_054, + Sha256 = "75554d62f4af8f4150d3b4b0cca7df62d44105e98fb7cd92ab2d177e382b441d", + }, + new LocalAiAssetReceipt + { + FileName = "cudart-llama-bin-win-cuda-13.4-arm64.zip", + SourceUrl = "https://github.com/ggml-org/llama.cpp/releases/download/b10488/cudart-llama-bin-win-cuda-13.4-arm64.zip", + SizeBytes = 153_318_797, + Sha256 = "5a40dc7c5fa3d0a80ceeba4f16f9e8d25d87bcf1399c9233588953c43436c33c", + }, + ], + ModelPath = Path.Combine("models", "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"), + ModelId = "unsloth/Qwen3.6-35B-A3B-MTP-GGUF@5bc3e238d916f48a861bac2f8a1990a0e9b7e98d", + ModelAlias = "qwen3.6-35b-a3b-mtp-q4-k-m", + ModelAsset = new LocalAiAssetReceipt + { + FileName = "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", + SourceUrl = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-MTP-GGUF/resolve/5bc3e238d916f48a861bac2f8a1990a0e9b7e98d/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf?download=true", + SizeBytes = 22_663_387_424, + Sha256 = "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", + }, + RequestedPort = 0, + Endpoint = null, + ContextLength = 262_144, + InstalledAtUtc = DateTimeOffset.Parse("2026-08-18T12:00:00Z"), + }; + + private sealed class FakePlatform : ILlamaServerRuntimePlatform + { + public DateTimeOffset UtcNow { get; private set; } = DateTimeOffset.Parse("2026-08-18T12:00:00Z"); + public List Listeners { get; } = []; + + public WindowsTcpListenerSnapshotResult CaptureListeners() => + new([.. Listeners], Ipv4Complete: true, Ipv6Complete: true); + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + UtcNow += delay; + return Task.CompletedTask; + } + } + + private sealed class FakeProcessHost( + FakePlatform platform, + List events, + int selectedPort, + TimeSpan? listenerStartOffset = null, + IPAddress? listenerAddress = null) : ILocalAiManagedProcessHost + { + public LocalAiProcessStartSpec? LastSpec { get; private set; } + public FakeProcess? Process { get; private set; } + + public Task StartProcessAsync( + LocalAiProcessStartSpec spec, + Action exited, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add("start"); + LastSpec = spec; + Process = new FakeProcess(4201, platform.UtcNow, platform, events); + platform.Listeners.Add(new WindowsTcpListenerInfo( + listenerAddress ?? IPAddress.Loopback, + selectedPort, + Process.ProcessId, + "llama-server", + @"C:\managed\llama-server.exe", + (Process.StartedAtUtc + (listenerStartOffset ?? TimeSpan.Zero)).UtcDateTime)); + return Task.FromResult(Process); + } + } + + private sealed class FakeProcess( + int processId, + DateTimeOffset startedAtUtc, + FakePlatform platform, + List events) : ILocalAiManagedProcess + { + public int ProcessId { get; } = processId; + public DateTimeOffset StartedAtUtc { get; } = startedAtUtc; + public bool HasExited { get; private set; } + public int StopCount { get; private set; } + + public Task StopAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add("stop"); + StopCount++; + HasExited = true; + platform.Listeners.Clear(); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class FakeClient(List events) : ILlamaServerClient + { + public List ProbedPorts { get; } = []; + + public Task ProbeRouterAsync( + Uri endpoint, + string modelAlias, + string expectedModelPath, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ProbedPorts.Add(endpoint.Port); + events.Add($"probe:{endpoint.Port}"); + return Task.FromResult(new LlamaServerRouterProbeResult( + true, + LocalAiModelAvailabilityState.Verified, + expectedModelPath, + null)); + } + + public void Dispose() { } + } + + private sealed class FakeLifecycle(List events) : ILocalAiEndpointLifecycle + { + public bool FailPublish { get; init; } + + public Task QuiesceAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default) + { + events.Add("quiesce"); + return Task.FromResult(LocalAiEndpointLifecycleResult.Ok()); + } + + public Task PublishAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default) + { + events.Add($"publish:{install.Endpoint!.Port}"); + return Task.FromResult(FailPublish + ? LocalAiEndpointLifecycleResult.Failed("publish failed") + : LocalAiEndpointLifecycleResult.Ok()); + } + } +} From 5fe25ab3f81375d74a8941c5e6fe83fe83b20585 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:53:34 -0700 Subject: [PATCH 11/23] feat(setup): select and acquire qualified Local AI plan Make Local AI opt-in and inspect hardware plus WSL viability without mutation. Acquire verified native inference before WSL provisioning and resolve bundled defaults reliably from RID-specific output. Signed-off-by: Joel Fernandes --- .../LocalAiAvailabilityReasons.cs | 24 + src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs | 437 ++++++++++++++++++ src/OpenClaw.SetupEngine/PreflightWslStep.cs | 270 ++++++++--- src/OpenClaw.SetupEngine/SetupContext.cs | 22 + src/OpenClaw.SetupEngine/SetupPipeline.cs | 11 + .../WslGlobalConfigManager.cs | 9 +- src/OpenClaw.SetupEngine/default-config.json | 13 + .../SetupConfigTests.cs | 30 ++ .../SetupPipelineTests.cs | 53 ++- .../SetupStepsTests.cs | 104 +++++ 10 files changed, 901 insertions(+), 72 deletions(-) create mode 100644 src/OpenClaw.SetupEngine/LocalAiAvailabilityReasons.cs create mode 100644 src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs diff --git a/src/OpenClaw.SetupEngine/LocalAiAvailabilityReasons.cs b/src/OpenClaw.SetupEngine/LocalAiAvailabilityReasons.cs new file mode 100644 index 000000000..a62648a0f --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiAvailabilityReasons.cs @@ -0,0 +1,24 @@ +namespace OpenClaw.SetupEngine; + +internal static class LocalAiAvailabilityReasons +{ + public static string? Build( + string? hardwareReason, + WslViabilityResult wslViability, + string? wslNetworkingReason) + { + ArgumentNullException.ThrowIfNull(wslViability); + var reasons = new List(capacity: 3); + + if (!string.IsNullOrWhiteSpace(hardwareReason)) + reasons.Add($"Hardware: {hardwareReason.Trim()}"); + if (wslViability.BlocksSetup) + reasons.Add($"WSL: {wslViability.Description}"); + if (!string.IsNullOrWhiteSpace(wslNetworkingReason)) + reasons.Add($"WSL networking: {wslNetworkingReason.Trim()}"); + + return reasons.Count == 0 + ? null + : string.Join(Environment.NewLine + Environment.NewLine, reasons); + } +} diff --git a/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs b/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs new file mode 100644 index 000000000..8676c0784 --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs @@ -0,0 +1,437 @@ +using System.Net; +using System.Net.Sockets; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; + +namespace OpenClaw.SetupEngine; + +internal interface ILocalAiPortSelector +{ + bool TrySelect(int requestedPort, out int selectedPort, out string? error); +} + +internal sealed class LoopbackLocalAiPortSelector : ILocalAiPortSelector +{ + public bool TrySelect(int requestedPort, out int selectedPort, out string? error) + { + selectedPort = 0; + error = null; + if (requestedPort is < 0 or > 65_535) + { + error = "The local inference port must be zero or between 1 and 65535."; + return false; + } + + try + { + var listener = new TcpListener(IPAddress.Loopback, requestedPort) + { + ExclusiveAddressUse = true, + }; + listener.Start(); + selectedPort = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return true; + } + catch (Exception ex) when (ex is SocketException or UnauthorizedAccessException) + { + error = requestedPort == 0 + ? "Windows could not allocate a loopback port for local inference." + : $"Loopback port {requestedPort} is not available for local inference."; + return false; + } + } +} + +/// +/// Selects one qualified NVIDIA GPU/runtime/model plan before setup mutates WSL, +/// downloads artifacts, or changes gateway configuration. +/// +public sealed class PreflightLocalAiHardwareStep : SetupStep +{ + private readonly IHostHardwareProbe _hardwareProbe; + private readonly ILocalAiPortSelector _portSelector; + + public PreflightLocalAiHardwareStep() + : this(new NvmlHostHardwareProbe(), new LoopbackLocalAiPortSelector()) + { + } + + internal PreflightLocalAiHardwareStep( + IHostHardwareProbe hardwareProbe, + ILocalAiPortSelector portSelector) + { + _hardwareProbe = hardwareProbe ?? throw new ArgumentNullException(nameof(hardwareProbe)); + _portSelector = portSelector ?? throw new ArgumentNullException(nameof(portSelector)); + } + + public override string Id => "preflight-local-ai-hardware"; + public override string DisplayName => "Checking Local AI compatibility"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + HostHardwareInfo hardware; + try + { + hardware = _hardwareProbe.Probe(); + } + catch (Exception ex) + { + return Task.FromResult(StepResult.Terminal( + "Local AI hardware detection failed. No setup changes were made.", + ex)); + } + + LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate( + hardware, + ctx.Config.LocalAi.SelectedModelId); + ctx.LocalAiHardware = hardware; + ctx.LocalAiEligibility = eligibility; + + if (eligibility.Status == LocalInferenceEligibilityStatus.Unsupported) + { + return Task.FromResult(StepResult.Terminal( + $"This system does not meet the Local AI requirements " + + $"({eligibility.FailureCode}, {eligibility.SelectionFailureCode}).")); + } + + if (eligibility.Status == LocalInferenceEligibilityStatus.EligibleButBusy) + { + long requiredMiB = eligibility.RequiredFreeMemoryBytes / (1024 * 1024); + long availableMiB = (eligibility.AvailableFreeMemoryBytes ?? 0) / (1024 * 1024); + return Task.FromResult(StepResult.Terminal( + $"The selected GPU is supported but currently busy. Local AI needs {requiredMiB:N0} MiB free; " + + $"{availableMiB:N0} MiB is available. Close GPU applications and retry.")); + } + + if (eligibility.Plan is null || eligibility.SelectedGpu is null) + { + return Task.FromResult(StepResult.Terminal( + "Local AI compatibility was inconclusive. No setup changes were made.")); + } + + if (!_portSelector.TrySelect(ctx.Config.LocalAi.Port, out int port, out string? portError)) + return Task.FromResult(StepResult.Terminal(portError ?? "Local inference port selection failed.")); + + ctx.LocalAiPort = port; + ctx.Logger.Info( + "Selected qualified Local AI plan", + new + { + runtime = eligibility.Plan.Runtime.Id, + model = eligibility.Plan.Model.Id, + selection = eligibility.Plan.ModelSelectionOrigin.ToString(), + gpu = eligibility.SelectedGpu.StableId, + port, + }); + + return Task.FromResult(StepResult.Ok( + $"Selected {eligibility.Plan.Model.DisplayName} for {eligibility.SelectedGpu.Name}.")); + } +} + +/// +/// Enables mirrored WSL networking only with explicit consent. This is the +/// sole Local AI setup step allowed to issue a global WSL shutdown. +/// +public sealed class ConfigureLocalAiWslNetworkingStep : SetupStep +{ + private readonly Func _managerFactory; + + public ConfigureLocalAiWslNetworkingStep() + : this(CreateManager) + { + } + + internal ConfigureLocalAiWslNetworkingStep( + Func managerFactory) => + _managerFactory = managerFactory ?? throw new ArgumentNullException(nameof(managerFactory)); + + public override string Id => "configure-local-ai-wsl-networking"; + public override string DisplayName => "Configuring Local AI access from WSL"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + IWslGlobalConfigManager manager = _managerFactory(ctx); + WslGlobalConfigStatus status; + try + { + status = manager.Inspect(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + return StepResult.Terminal( + $"The WSL configuration could not be safely inspected: {ex.Message}", + ex); + } + + if (status.IsMirrored) + return StepResult.Skip("WSL mirrored networking is already enabled."); + + if (!ctx.Config.LocalAi.WslMirroredNetworkingConsent) + { + return StepResult.Terminal( + "Local AI requires WSL mirrored networking. Consent is required because applying it stops all running WSL distributions once; no distributions are deleted."); + } + + WslGlobalConfigApplyResult apply; + try + { + apply = manager.ApplyMirroredNetworking(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + return StepResult.Terminal( + $"WSL mirrored networking could not be configured: {ex.Message}", + ex); + } + + if (!apply.Changed) + return StepResult.Skip("WSL mirrored networking is already enabled."); + + try + { + CommandResult shutdown = await ShutdownWslAsync(ctx, ct); + if (shutdown.ExitCode != 0 || shutdown.TimedOut) + { + RestoreAfterFailedApply(manager, ctx); + return StepResult.Fail( + "WSL mirrored networking was restored because WSL could not be stopped to apply it."); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + if (manager.RestoreIfUnchanged() == WslGlobalConfigRestoreResult.Restored) + await ShutdownWslAsync(ctx, CancellationToken.None); + throw; + } + + return StepResult.Ok("WSL mirrored networking is enabled."); + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + IWslGlobalConfigManager manager = _managerFactory(ctx); + WslGlobalConfigRestoreResult restore = manager.RestoreIfUnchanged(); + switch (restore) + { + case WslGlobalConfigRestoreResult.NoBackup: + return; + case WslGlobalConfigRestoreResult.UserModified: + ctx.Logger.Warn("Preserving the user's newer .wslconfig instead of restoring the setup backup."); + return; + case WslGlobalConfigRestoreResult.InvalidBackup: + throw new InvalidDataException("The Local AI WSL configuration backup is invalid."); + case WslGlobalConfigRestoreResult.Restored: + CommandResult shutdown = await ShutdownWslAsync(ctx, ct); + if (shutdown.ExitCode != 0 || shutdown.TimedOut) + throw new InvalidOperationException("WSL could not be stopped to apply the restored configuration."); + return; + default: + throw new InvalidOperationException($"Unknown WSL configuration restore result: {restore}."); + } + } + + private static IWslGlobalConfigManager CreateManager(SetupContext ctx) + { + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string configPath = Path.Combine(userProfile, ".wslconfig"); + string backupDirectory = Path.Combine( + new LocalAiPaths(ctx.LocalDataDir).RootDirectory, + "wsl-networking"); + return new WslGlobalConfigManager(configPath, backupDirectory); + } + + private static Task ShutdownWslAsync(SetupContext ctx, CancellationToken ct) => + ctx.Commands.RunAsync( + WslConstants.WslExePath, + ["--shutdown"], + TimeSpan.FromSeconds(60), + ct: ct); + + private static void RestoreAfterFailedApply(IWslGlobalConfigManager manager, SetupContext ctx) + { + WslGlobalConfigRestoreResult restore = manager.RestoreIfUnchanged(); + if (restore != WslGlobalConfigRestoreResult.Restored) + { + ctx.Logger.Error( + $"Failed to restore .wslconfig after WSL shutdown failed: {restore}."); + } + } +} + +/// Installs the two pinned llama.cpp runtime archives as one atomic component. +public sealed class AcquireLocalAiRuntimeStep : SetupStep +{ + private static readonly HttpClient s_httpClient = new(new SocketsHttpHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.All, + }) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + private readonly ILlamaRuntimeAcquirer _acquirer; + + public AcquireLocalAiRuntimeStep() + : this(new LlamaRuntimeInstaller(s_httpClient)) + { + } + + internal AcquireLocalAiRuntimeStep(ILlamaRuntimeAcquirer acquirer) => + _acquirer = acquirer ?? throw new ArgumentNullException(nameof(acquirer)); + + public override string Id => "acquire-local-ai-runtime"; + public override string DisplayName => "Installing llama-server"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiEligibility?.Plan is not { } plan) + return StepResult.Terminal("Local AI runtime installation requires a qualified hardware plan."); + if (ctx.Config.LocalAi.AcquisitionTimeoutSeconds <= 0) + return StepResult.Terminal("The Local AI acquisition timeout must be greater than zero."); + + using var timeout = new CancellationTokenSource( + TimeSpan.FromSeconds(ctx.Config.LocalAi.AcquisitionTimeoutSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + try + { + LlamaRuntimeInstallResult install = await _acquirer.InstallAsync( + ctx.LocalDataDir, + plan.Runtime, + progress: null, + linked.Token); + ctx.LocalAiRuntimeInstall = install; + return StepResult.Ok($"Installed llama-server {LlamaRuntimeCatalog.ReleaseTag}."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException ex) + { + return StepResult.Fail("The llama-server download timed out.", ex); + } + catch (Exception ex) when ( + ex is LocalAiArtifactInstallException + or IOException + or UnauthorizedAccessException + or HttpRequestException) + { + return StepResult.Fail($"llama-server installation failed: {ex.Message}", ex); + } + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (ctx.LocalAiRuntimeInstall is { } install) + { + _acquirer.RemoveInstalledRuntime(ctx.LocalDataDir, install); + ctx.LocalAiRuntimeInstall = null; + } + + return Task.CompletedTask; + } +} + +/// Downloads one immutable, recipe-selected GGUF directly from Hugging Face. +public sealed class AcquireLocalAiModelStep : SetupStep +{ + private static readonly HttpClient s_httpClient = new(new SocketsHttpHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = System.Net.DecompressionMethods.None, + }) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + private readonly IHuggingFaceModelAcquirer _acquirer; + + public AcquireLocalAiModelStep() + : this(new HuggingFaceModelInstaller(s_httpClient)) + { + } + + internal AcquireLocalAiModelStep(IHuggingFaceModelAcquirer acquirer) => + _acquirer = acquirer ?? throw new ArgumentNullException(nameof(acquirer)); + + public override string Id => "acquire-local-ai-model"; + public override string DisplayName => "Downloading Local AI model from Hugging Face"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiEligibility?.Plan is not { } plan) + return StepResult.Terminal("Local AI model download requires a qualified hardware plan."); + if (ctx.LocalAiRuntimeInstall is null) + return StepResult.Terminal("Local AI model download requires the pinned llama-server runtime."); + if (ctx.Config.LocalAi.AcquisitionTimeoutSeconds <= 0) + return StepResult.Terminal("The Local AI acquisition timeout must be greater than zero."); + + using var timeout = new CancellationTokenSource( + TimeSpan.FromSeconds(ctx.Config.LocalAi.AcquisitionTimeoutSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + try + { + HuggingFaceModelInstallResult install = await _acquirer.InstallAsync( + ctx.LocalDataDir, + LlamaRuntimeInstaller.Component(plan.Runtime), + plan.Model, + progress: null, + linked.Token); + ctx.LocalAiModelInstall = install; + string action = install.Disposition == HuggingFaceModelInstallDisposition.ReusedVerified + ? "Verified existing" + : "Downloaded"; + return StepResult.Ok($"{action} {plan.Model.DisplayName} from its pinned Hugging Face revision."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException ex) + { + return StepResult.Fail("The Hugging Face model download timed out.", ex); + } + catch (Exception ex) when ( + ex is HuggingFaceModelInstallException + or IOException + or UnauthorizedAccessException + or HttpRequestException) + { + return StepResult.Fail($"Hugging Face model installation failed: {ex.Message}", ex); + } + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (ctx.LocalAiModelInstall is { } install) + { + _acquirer.RemoveInstalledModel(ctx.LocalDataDir, install); + ctx.LocalAiModelInstall = null; + } + + return Task.CompletedTask; + } +} diff --git a/src/OpenClaw.SetupEngine/PreflightWslStep.cs b/src/OpenClaw.SetupEngine/PreflightWslStep.cs index 9b45d6a8a..1be2a05ad 100644 --- a/src/OpenClaw.SetupEngine/PreflightWslStep.cs +++ b/src/OpenClaw.SetupEngine/PreflightWslStep.cs @@ -1,61 +1,183 @@ using System.Diagnostics; -using System.Net; -using System.Net.Http; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text.Json; -using OpenClaw.Connection; using OpenClaw.Shared; namespace OpenClaw.SetupEngine; -public sealed class PreflightWslStep : SetupStep +internal enum WslViabilityKind { - public override string Id => "preflight-wsl"; - public override string DisplayName => "Verify WSL available"; - public override bool CanRetry => false; + Ready, + Installable, + UpdateRequired, + EnvironmentBlocked, + InspectionFailed, +} - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) +internal sealed record WslViabilityResult( + WslViabilityKind Kind, + string Summary, + string Remediation) +{ + public bool BlocksSetup => Kind is + WslViabilityKind.UpdateRequired or + WslViabilityKind.EnvironmentBlocked or + WslViabilityKind.InspectionFailed; + + public string Description => string.IsNullOrWhiteSpace(Remediation) + ? Summary + : $"{Summary} {Remediation}"; +} + +/// +/// Performs a read-only WSL inspection. This type never installs WSL, changes +/// optional Windows features, updates .wslconfig, or stops a distribution. +/// +internal static class WslViabilityInspector +{ + public static async Task InspectAsync( + ICommandRunner commands, + SetupLogger logger, + CancellationToken ct) { - var versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); - if (versionResult.ExitCode != 0 && LooksUnavailable(versionResult)) - { - var installResult = await InstallWslPlatformAsync(ctx, ct); - if (!installResult.IsSuccess) - return installResult; + ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(logger); - versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); + CommandResult versionResult; + try + { + versionResult = await commands.RunAsync( + WslConstants.WslExePath, + ["--version"], + TimeSpan.FromSeconds(5), + ct: ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.Warn($"WSL version inspection failed: {ex.Message}"); + return InspectionFailed(); } if (versionResult.ExitCode != 0) { + if (LooksUnavailable(versionResult)) + { + return new( + WslViabilityKind.Installable, + "WSL is not installed yet.", + "Setup can request administrator approval to install it after Local AI is verified."); + } + if (LooksTooOldForVersionCommand(versionResult)) - return StepResult.Terminal($"WSL is installed but too old for clean app-owned gateway setup. {WslInstallSupport.UpdateInstructions}"); + { + return new( + WslViabilityKind.UpdateRequired, + "The installed WSL version is too old for a clean app-owned gateway.", + WslInstallSupport.UpdateInstructions); + } - return StepResult.Terminal($"WSL is not available. {FirstUsefulLine(versionResult)}"); + logger.Warn($"WSL version inspection returned exit code {versionResult.ExitCode}: " + + NormalizeWslOutput($"{versionResult.Stdout}\n{versionResult.Stderr}").Trim()); + return InspectionFailed(); } var versionOutput = NormalizeWslOutput($"{versionResult.Stdout}\n{versionResult.Stderr}"); if (!WslInstallSupport.TryParseWslVersion(versionOutput, out var wslVersion)) - return StepResult.Terminal($"WSL version output did not include a parseable WSL version. {WslInstallSupport.UpdateInstructions}"); + { + return new( + WslViabilityKind.UpdateRequired, + "The installed WSL version could not be verified.", + WslInstallSupport.UpdateInstructions); + } if (!WslInstallSupport.SupportsDirectNamedInstall(wslVersion)) - return StepResult.Terminal($"WSL {wslVersion} cannot create a clean app-owned OpenClaw gateway distro. {WslInstallSupport.UpdateInstructions}"); + { + return new( + WslViabilityKind.UpdateRequired, + $"WSL {wslVersion} cannot create a clean app-owned OpenClaw gateway.", + WslInstallSupport.UpdateInstructions); + } + + logger.Info($"WSL version output: {NormalizeWslOutput(versionResult.Stdout).Trim()}"); + logger.Info($"WSL direct named install is supported (version {wslVersion})"); + + CommandResult status; + try + { + status = await commands.RunAsync( + WslConstants.WslExePath, + ["--status"], + TimeSpan.FromSeconds(10), + ct: ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.Warn($"WSL status inspection failed: {ex.Message}"); + return InspectionFailed(); + } + + var combined = $"{status.Stdout}\n{status.Stderr}"; + if (WslInstallSupport.TryGetEnvironmentIssue(combined, out var message)) + { + logger.Warn($"WSL environment issue detected: {NormalizeWslOutput(combined).Trim()}"); + return new( + WslViabilityKind.EnvironmentBlocked, + "Windows cannot currently start WSL2.", + message); + } + + if (status.ExitCode != 0 || status.TimedOut) + { + logger.Warn($"WSL status inspection returned exit code {status.ExitCode}: " + + NormalizeWslOutput(combined).Trim()); + return InspectionFailed(); + } + + return new( + WslViabilityKind.Ready, + $"WSL {wslVersion} is ready.", + string.Empty); + } + + private static WslViabilityResult InspectionFailed() => new( + WslViabilityKind.InspectionFailed, + "OpenClaw could not safely verify the WSL2 environment.", + "Run wsl --status in PowerShell, resolve the reported problem, and try setup again."); + + internal static bool LooksUnavailable(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); + return text.Contains("aka.ms/wslinstall", StringComparison.OrdinalIgnoreCase) + || text.Contains("Windows Subsystem for Linux has no installed distributions", StringComparison.OrdinalIgnoreCase) + || text.Contains("not recognized", StringComparison.OrdinalIgnoreCase) + || text.Contains("not installed", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksTooOldForVersionCommand(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); + return text.Contains("Invalid command line option", StringComparison.OrdinalIgnoreCase) + || text.Contains("unrecognized option", StringComparison.OrdinalIgnoreCase) + || text.Contains("unknown option", StringComparison.OrdinalIgnoreCase); + } - ctx.Logger.Info($"WSL version output: {NormalizeWslOutput(versionResult.Stdout).Trim()}"); - ctx.Logger.Info($"WSL direct named install is supported (version {wslVersion})"); + internal static string NormalizeWslOutput(string value) => WslInstallSupport.Normalize(value); +} - // wsl --version can succeed even when the WSL2 platform itself is - // unusable (Virtual Machine Platform component disabled, hardware - // virtualization off in firmware, Hyper-V missing, ...). Surface - // that diagnostic now so the user gets an actionable message - // before pipeline reaches the actual `wsl --install` step. - var statusIssue = await DetectEnvironmentIssueAsync(ctx, ct); - if (statusIssue != null) - return StepResult.Terminal(statusIssue); +public sealed class PreflightWslStep : SetupStep +{ + public override string Id => "preflight-wsl"; + public override string DisplayName => "Inspect WSL compatibility"; + public override bool CanRetry => false; - return StepResult.Ok("WSL available"); + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + WslViabilityResult viability = await WslViabilityInspector.InspectAsync( + ctx.Commands, + ctx.Logger, + ct); + ctx.WslViability = viability; + return viability.BlocksSetup + ? StepResult.Terminal(viability.Description) + : StepResult.Ok(viability.Description); } internal static async Task DetectEnvironmentIssueAsync(SetupContext ctx, CancellationToken ct) @@ -65,18 +187,15 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati ["--status"], TimeSpan.FromSeconds(10), ct: ct); - var combined = $"{status.Stdout}\n{status.Stderr}"; - if (WslInstallSupport.TryGetEnvironmentIssue(combined, out var message)) - { - ctx.Logger.Warn($"WSL environment issue detected: {NormalizeWslOutput(combined).Trim()}"); - return message; - } + if (!WslInstallSupport.TryGetEnvironmentIssue(combined, out var message)) + return null; - return null; + ctx.Logger.Warn($"WSL environment issue detected: {WslViabilityInspector.NormalizeWslOutput(combined).Trim()}"); + return message; } - private static async Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct) + internal static async Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct) { ctx.Logger.Warn("WSL platform appears to be missing; launching elevated WSL platform install"); try @@ -104,9 +223,16 @@ private static async Task InstallWslPlatformAsync(SetupContext ctx, if (process.ExitCode != 0) return StepResult.Fail($"WSL platform install failed with exit code {process.ExitCode}."); - var probe = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); - if (probe.ExitCode != 0 || LooksUnavailable(probe)) - return StepResult.Terminal("WSL platform install completed, but Windows still reports WSL unavailable. Reboot Windows, then run setup again."); + var probe = await ctx.Commands.RunAsync( + WslConstants.WslExePath, + ["--version"], + TimeSpan.FromSeconds(5), + ct: ct); + if (probe.ExitCode != 0 || WslViabilityInspector.LooksUnavailable(probe)) + { + return StepResult.Terminal( + "WSL platform install completed, but Windows still reports WSL unavailable. Reboot Windows, then run setup again."); + } return StepResult.Ok("WSL platform installed"); } @@ -119,31 +245,47 @@ private static async Task InstallWslPlatformAsync(SetupContext ctx, return StepResult.Fail($"WSL platform install failed: {ex.Message}", ex); } } +} - private static bool LooksUnavailable(CommandResult result) - { - var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); - return text.Contains("aka.ms/wslinstall", StringComparison.OrdinalIgnoreCase) - || text.Contains("Windows Subsystem for Linux has no installed distributions", StringComparison.OrdinalIgnoreCase) - || text.Contains("not recognized", StringComparison.OrdinalIgnoreCase) - || text.Contains("not installed", StringComparison.OrdinalIgnoreCase); - } +/// Performs the first WSL mutation, after native Local AI verification. +public sealed class EnsureWslPlatformStep : SetupStep +{ + private readonly Func> _installer; - private static bool LooksTooOldForVersionCommand(CommandResult result) + public EnsureWslPlatformStep() + : this(PreflightWslStep.InstallWslPlatformAsync) { - var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); - return text.Contains("Invalid command line option", StringComparison.OrdinalIgnoreCase) - || text.Contains("unrecognized option", StringComparison.OrdinalIgnoreCase) - || text.Contains("unknown option", StringComparison.OrdinalIgnoreCase); } - private static string NormalizeWslOutput(string value) - => WslInstallSupport.Normalize(value); + internal EnsureWslPlatformStep( + Func> installer) => + _installer = installer ?? throw new ArgumentNullException(nameof(installer)); - private static string FirstUsefulLine(CommandResult result) + public override string Id => "ensure-wsl-platform"; + public override string DisplayName => "Prepare WSL platform"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { - var text = NormalizeWslOutput($"{result.Stderr}\n{result.Stdout}"); - return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() - ?? "Run wsl --install from an elevated terminal and retry setup."; + WslViabilityResult viability = await WslViabilityInspector.InspectAsync( + ctx.Commands, + ctx.Logger, + ct); + ctx.WslViability = viability; + + if (viability.Kind == WslViabilityKind.Ready) + return StepResult.Ok("WSL platform is ready."); + if (viability.BlocksSetup) + return StepResult.Terminal(viability.Description); + + StepResult install = await _installer(ctx, ct); + if (!install.IsSuccess) + return install; + + viability = await WslViabilityInspector.InspectAsync(ctx.Commands, ctx.Logger, ct); + ctx.WslViability = viability; + return viability.Kind == WslViabilityKind.Ready + ? StepResult.Ok("WSL platform installed and verified.") + : StepResult.Terminal(viability.Description); } } diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index dea2a4379..10b2f3b6e 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization; using OpenClaw.Connection; using OpenClaw.Shared; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; namespace OpenClaw.SetupEngine; @@ -38,6 +40,7 @@ public sealed class SetupConfig public PairingConfig Pairing { get; set; } = new(); public WindowsNodeContextConfig WindowsNodeContext { get; set; } = new(); public TailscaleConfig Tailscale { get; set; } = new(); + public LocalAiConfig LocalAi { get; set; } = new(); public string EffectiveGatewayUrl => GatewayUrl ?? $"ws://localhost:{GatewayPort}"; @@ -130,6 +133,19 @@ public SetupConfig ApplyUiDefaults(bool rollbackOnFailure = true) // ─── WSL Configuration ─── +// Native local inference is disabled for programmatic and backward-compatible +// configs. The bundled product config explicitly enables it for onboarding. +public sealed class LocalAiConfig +{ + public bool Enabled { get; set; } + public string? SelectedModelId { get; set; } + /// Managed llama-server port. Zero selects a free loopback port during setup. + public int Port { get; set; } + public bool WslMirroredNetworkingConsent { get; set; } + public int HealthTimeoutSeconds { get; set; } = 15; + public int AcquisitionTimeoutSeconds { get; set; } = 7_200; +} + public sealed class WslConfig { private static readonly System.Text.RegularExpressions.Regex s_linuxUserNamePattern = @@ -461,6 +477,12 @@ public sealed class SetupContext public IExternalAuthorizationPresenter? ExternalAuthorizationPresenter { get; set; } public Func>? EndpointProvenanceProbe { get; set; } + internal WslViabilityResult? WslViability { get; set; } + public HostHardwareInfo? LocalAiHardware { get; set; } + public LocalInferenceEligibilityResult? LocalAiEligibility { get; set; } + public int? LocalAiPort { get; set; } + internal LlamaRuntimeInstallResult? LocalAiRuntimeInstall { get; set; } + internal HuggingFaceModelInstallResult? LocalAiModelInstall { get; set; } // Data directory for gateway registry and identity files public string DataDir { get; } diff --git a/src/OpenClaw.SetupEngine/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs index 21e9c5676..916b7be7a 100644 --- a/src/OpenClaw.SetupEngine/SetupPipeline.cs +++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs @@ -54,8 +54,18 @@ public static List BuildDefaultSteps() [ new ValidateDistroInstallPathStep(), new PreflightOsStep(), + new PreflightLocalAiHardwareStep(), new PreflightWslStep(), new PreflightWindowsTailscaleStep(), + new AcquireLocalAiRuntimeStep(), + new AcquireLocalAiModelStep(), + new PersistLocalAiManifestStep(), + new StartLocalAiRuntimeStep(), + new CaptureLocalAiGpuBaselineStep(), + new VerifyLocalAiInferenceStep(), + new VerifyLocalAiGpuLoadStep(), + new EnsureWslPlatformStep(), + new ConfigureLocalAiWslNetworkingStep(), new CleanupStaleDistroStep(), new CleanupStaleGatewayStep(), new PreflightPortStep(), @@ -63,6 +73,7 @@ public static List BuildDefaultSteps() new ConfigureWslInstanceStep(), new ValidateWslLockdownStep(), new InstallCliStep(), + new VerifyLocalAiWslStep(), new InstallTailscaleStep(), new AuthorizeTailscaleStep(), new ConfigureGatewayStep(), diff --git a/src/OpenClaw.SetupEngine/WslGlobalConfigManager.cs b/src/OpenClaw.SetupEngine/WslGlobalConfigManager.cs index c410eb5c1..0f589fbdf 100644 --- a/src/OpenClaw.SetupEngine/WslGlobalConfigManager.cs +++ b/src/OpenClaw.SetupEngine/WslGlobalConfigManager.cs @@ -4,12 +4,19 @@ namespace OpenClaw.SetupEngine; +internal interface IWslGlobalConfigManager +{ + WslGlobalConfigStatus Inspect(); + WslGlobalConfigApplyResult ApplyMirroredNetworking(); + WslGlobalConfigRestoreResult RestoreIfUnchanged(); +} + /// /// Applies the global WSL mirrored-networking prerequisite without replacing /// unrelated user configuration. The exact original bytes are retained so a /// rollback can restore them when the user has not edited the file meanwhile. /// -internal sealed class WslGlobalConfigManager +internal sealed class WslGlobalConfigManager : IWslGlobalConfigManager { private const string Wsl2Section = "wsl2"; private const string NetworkingModeKey = "networkingMode"; diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index 85445d551..10f1ce340 100644 --- a/src/OpenClaw.SetupEngine/default-config.json +++ b/src/OpenClaw.SetupEngine/default-config.json @@ -100,6 +100,19 @@ "ExtraConfig": null }, + // Native Windows llama-server and immutable Hugging Face model acquisition. + "LocalAi": { + "Enabled": false, + // null selects the qualified default. Alternatives are chosen explicitly in onboarding. + "SelectedModelId": null, + // 0 allocates one free IPv4 loopback port and persists it for later restarts. + "Port": 0, + // The setup UI sets this only after explaining that applying mirrored mode stops running WSL distros once. + "WslMirroredNetworkingConsent": false, + "HealthTimeoutSeconds": 15, + "AcquisitionTimeoutSeconds": 7200 + }, + // ─── Node Capabilities ─── // Which capabilities to advertise to the gateway during node pairing. // The tray handles actual command execution; setup just registers the declarations. diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index 9c3de8de8..7dcfd9305 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -43,6 +43,19 @@ public void Defaults_AreReasonable() Assert.Equal(TailscaleAuthMode.Browser, config.Tailscale.AuthMode); Assert.Equal(300, config.Tailscale.AuthTimeoutSeconds); Assert.Equal(300, config.Tailscale.ServeApprovalTimeoutSeconds); + Assert.False(config.LocalAi.Enabled); + } + + [Fact] + public void BundledConfig_RequiresExplicitLocalAiOptIn() + { + var config = SetupConfig.LoadFromFile(Path.Combine( + RepositoryRoot(), + "src", + "OpenClaw.SetupEngine", + "default-config.json")); + + Assert.False(config.LocalAi.Enabled); } [Fact] @@ -610,4 +623,21 @@ public void PipelineResult_ExitCodes() Assert.Equal(1, new PipelineResult(PipelineOutcome.Failed).ExitCode); Assert.Equal(3, new PipelineResult(PipelineOutcome.Cancelled).ExitCode); } + + private static string RepositoryRoot() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT") is { Length: > 0 } configured) + return configured; + + var directory = AppContext.BaseDirectory; + while (!string.IsNullOrWhiteSpace(directory)) + { + if (File.Exists(Path.Combine(directory, "src", "OpenClaw.SetupEngine", "default-config.json"))) + return directory; + + directory = Directory.GetParent(directory)?.FullName; + } + + throw new DirectoryNotFoundException("Could not locate repository root for default-config.json."); + } } diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs index 7e3da4a1e..0abebd9f6 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs @@ -79,20 +79,32 @@ public void BuildDefaultSteps_IncludesCurrentSetupFlow() { var steps = SetupStepFactory.BuildDefaultSteps(); - Assert.Equal(24, steps.Count); + Assert.Equal(36, steps.Count); Assert.IsType(steps[0]); Assert.IsType(steps[1]); - Assert.IsType(steps[2]); - Assert.IsType(steps[3]); - Assert.IsType(steps[4]); - Assert.IsType(steps[5]); + Assert.IsType(steps[2]); + Assert.IsType(steps[3]); + Assert.IsType(steps[4]); + Assert.IsType(steps[5]); + Assert.IsType(steps[6]); + Assert.IsType(steps[7]); + Assert.IsType(steps[8]); + Assert.IsType(steps[9]); + Assert.IsType(steps[10]); + Assert.IsType(steps[11]); + Assert.IsType(steps[12]); + Assert.IsType(steps[13]); + Assert.IsType(steps[14]); + Assert.IsType(steps[15]); Assert.Contains(steps, s => s is ValidateWslLockdownStep); var lockdownIndex = steps.FindIndex(s => s is ValidateWslLockdownStep); var cliInstallIndex = steps.FindIndex(s => s is InstallCliStep); Assert.Equal(lockdownIndex + 1, cliInstallIndex); - Assert.IsType(steps[cliInstallIndex + 1]); - Assert.IsType(steps[cliInstallIndex + 2]); + Assert.IsType(steps[cliInstallIndex + 1]); + Assert.IsType(steps[cliInstallIndex + 2]); + Assert.IsType(steps[cliInstallIndex + 3]); var installServiceIndex = steps.FindIndex(s => s is InstallGatewayServiceStep); + Assert.IsType(steps[installServiceIndex - 1]); Assert.IsType(steps[installServiceIndex + 1]); Assert.IsType(steps[installServiceIndex + 2]); Assert.Contains(steps, s => s is RunGatewayWizardStep); @@ -103,6 +115,33 @@ public void BuildDefaultSteps_IncludesCurrentSetupFlow() Assert.IsType(steps[^1]); } + [Fact] + public void LocalAiDisabled_SkipsEveryLocalAiMutation() + { + var ctx = CreateContext(new SetupConfig + { + LocalAi = new LocalAiConfig { Enabled = false } + }); + var steps = SetupStepFactory.BuildDefaultSteps(); + SetupStep[] localAiSteps = + [ + steps.Single(step => step is PreflightLocalAiHardwareStep), + steps.Single(step => step is ReconcileLocalAiInstallationStep), + steps.Single(step => step is AcquireLocalAiRuntimeStep), + steps.Single(step => step is AcquireLocalAiModelStep), + steps.Single(step => step is PersistLocalAiManifestStep), + steps.Single(step => step is StartLocalAiRuntimeStep), + steps.Single(step => step is CaptureLocalAiGpuBaselineStep), + steps.Single(step => step is VerifyLocalAiInferenceStep), + steps.Single(step => step is VerifyLocalAiGpuLoadStep), + steps.Single(step => step is ConfigureLocalAiWslNetworkingStep), + steps.Single(step => step is VerifyLocalAiWslStep), + steps.Single(step => step is ConfigureLocalAiGatewayStep), + ]; + + Assert.All(localAiSteps, step => Assert.True(step.CanSkip(ctx), step.Id)); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 299c85c36..48711b9b6 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -1220,6 +1220,110 @@ args is ["--version"] Assert.Contains(WslInstallSupport.UpdateUrl, result.Message); } + [Fact] + public async Task PreflightWsl_MissingPlatformIsInstallableWithoutMutation() + { + var commands = new FakeCommandRunner(args => + args is ["--version"] + ? new CommandResult( + 1, + "", + "Windows Subsystem for Linux is not installed. See https://aka.ms/wslinstall", + TimeSpan.Zero, + TimedOut: false) + : Fail($"unexpected args: {string.Join(' ', args)}")); + var ctx = CreateContext(commands: commands); + + var result = await new PreflightWslStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Success, result.Outcome); + Assert.Equal(WslViabilityKind.Installable, ctx.WslViability?.Kind); + Assert.Contains("after Local AI is verified", result.Message); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--install")); + Assert.Single(commands.Calls); + } + + [Fact] + public async Task EnsureWslPlatform_InstallsOnlyAfterReadOnlyPreflight() + { + var installed = false; + var installCalls = 0; + var commands = new FakeCommandRunner(args => args switch + { + ["--version"] when !installed => new CommandResult( + 1, + "", + "Windows Subsystem for Linux is not installed. See https://aka.ms/wslinstall", + TimeSpan.Zero, + TimedOut: false), + ["--version"] => Ok("WSL version: 2.7.3.0\n"), + ["--status"] => Ok("Default Version: 2\n"), + _ => Fail($"unexpected args: {string.Join(' ', args)}"), + }); + var ctx = CreateContext(commands: commands); + var step = new EnsureWslPlatformStep((_, _) => + { + installCalls++; + installed = true; + return Task.FromResult(StepResult.Ok("installed")); + }); + + var result = await step.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Success, result.Outcome); + Assert.Equal(1, installCalls); + Assert.Equal(WslViabilityKind.Ready, ctx.WslViability?.Kind); + Assert.Equal(3, commands.Calls.Count); + } + + [Fact] + public async Task PreflightWsl_UnclassifiedStatusFailureFailsClosed() + { + var commands = new FakeCommandRunner(args => args switch + { + ["--version"] => Ok("WSL version: 2.7.3.0\n"), + ["--status"] => Fail("Access denied"), + _ => Fail($"unexpected args: {string.Join(' ', args)}"), + }); + var ctx = CreateContext(commands: commands); + + var result = await new PreflightWslStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.FailedTerminal, result.Outcome); + Assert.Equal(WslViabilityKind.InspectionFailed, ctx.WslViability?.Kind); + Assert.Contains("could not safely verify", result.Message); + } + + [Fact] + public void LocalAiAvailabilityReasons_CombinesHardwareWslAndNetworkingFailures() + { + var wsl = new WslViabilityResult( + WslViabilityKind.EnvironmentBlocked, + "Windows cannot currently start WSL2.", + "Enable virtualization and Virtual Machine Platform."); + + var result = LocalAiAvailabilityReasons.Build( + "No qualified NVIDIA GPU was detected.", + wsl, + "The global .wslconfig file is unreadable."); + + Assert.NotNull(result); + Assert.Contains("Hardware: No qualified NVIDIA GPU was detected.", result); + Assert.Contains("WSL: Windows cannot currently start WSL2.", result); + Assert.Contains("WSL networking: The global .wslconfig file is unreadable.", result); + } + + [Fact] + public void LocalAiAvailabilityReasons_DoesNotBlockForInstallableWsl() + { + var wsl = new WslViabilityResult( + WslViabilityKind.Installable, + "WSL is not installed yet.", + "Setup can install it later."); + + Assert.Null(LocalAiAvailabilityReasons.Build(null, wsl, null)); + } + [Fact] public async Task CreateWslInstance_UsesDirectFreshInstallAndDoesNotExportBaseDistro() { From 1edad16fd861975db3e080b1fb04262c4c641bae Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:53:35 -0700 Subject: [PATCH 12/23] feat(setup): persist and start managed Local AI Reconcile and reuse only exact manifest-owned runtime and model artifacts after interruption. Persist a proven healthy endpoint and clean durable app-owned state on fresh uninstall. Signed-off-by: Joel Fernandes --- .../LocalAiInstallReconciler.cs | 166 +++++ src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs | 605 ++++++++++++++++-- src/OpenClaw.SetupEngine/SetupContext.cs | 11 + src/OpenClaw.SetupEngine/SetupPipeline.cs | 4 + .../LocalAiInstallRecoveryTests.cs | 582 +++++++++++++++++ .../LocalAiPortHandoffTests.cs | 172 +++++ .../SetupPipelineTests.cs | 25 +- 7 files changed, 1500 insertions(+), 65 deletions(-) create mode 100644 src/OpenClaw.SetupEngine/LocalAiInstallReconciler.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/LocalAiInstallRecoveryTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/LocalAiPortHandoffTests.cs diff --git a/src/OpenClaw.SetupEngine/LocalAiInstallReconciler.cs b/src/OpenClaw.SetupEngine/LocalAiInstallReconciler.cs new file mode 100644 index 000000000..100a6130d --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiInstallReconciler.cs @@ -0,0 +1,166 @@ +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference.Catalog; + +namespace OpenClaw.SetupEngine; + +internal sealed record LocalAiReconcileResult( + bool Reused, + LocalAiResolvedInstall? ResolvedInstall, + LlamaRuntimeInstallResult? RuntimeInstall, + HuggingFaceModelInstallResult? ModelInstall) +{ + public static LocalAiReconcileResult NotInstalled { get; } = new(false, null, null, null); +} + +internal interface ILocalAiModelFileVerifier +{ + Task VerifyAsync(string path, PinnedArtifact artifact, CancellationToken cancellationToken); +} + +internal sealed class LocalAiModelFileVerifier : ILocalAiModelFileVerifier +{ + public Task VerifyAsync( + string path, + PinnedArtifact artifact, + CancellationToken cancellationToken) => + HuggingFaceModelInstaller.VerifyFileAsync(path, artifact, cancellationToken); +} + +/// +/// Reuses only an installation claimed by a complete manifest that still +/// matches the selected immutable catalog recipe and passes on-disk checks. +/// Unclaimed paths remain the responsibility of the individual acquirers. +/// +internal sealed class LocalAiInstallReconciler +{ + private readonly ILlamaRuntimeInspector _runtimeInspector; + private readonly ILocalAiModelFileVerifier _modelVerifier; + + public LocalAiInstallReconciler() + : this(new WindowsLlamaRuntimeInspector(), new LocalAiModelFileVerifier()) + { + } + + internal LocalAiInstallReconciler( + ILlamaRuntimeInspector runtimeInspector, + ILocalAiModelFileVerifier modelVerifier) + { + _runtimeInspector = runtimeInspector ?? throw new ArgumentNullException(nameof(runtimeInspector)); + _modelVerifier = modelVerifier ?? throw new ArgumentNullException(nameof(modelVerifier)); + } + + public async Task ReconcileAsync( + string localDataDirectory, + LocalInferencePlan plan, + string selectedGpuId, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(localDataDirectory); + ArgumentNullException.ThrowIfNull(plan); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedGpuId); + + var paths = new LocalAiPaths(localDataDirectory); + LocalAiResolvedInstall? install = await new LocalAiManifestStore(paths) + .LoadAsync(cancellationToken) + .ConfigureAwait(false); + if (install is null) + return LocalAiReconcileResult.NotInstalled; + + ValidateRecipeMatch(install, plan, selectedGpuId, localDataDirectory); + + LlamaRuntimeInspection inspection = await _runtimeInspector + .InspectAsync(Path.GetDirectoryName(install.ExecutablePath)!, cancellationToken) + .ConfigureAwait(false); + if (!inspection.IsValid) + { + throw new InvalidDataException( + inspection.Error ?? "The managed llama-server runtime no longer passes validation."); + } + + if (!await _modelVerifier + .VerifyAsync(install.ModelPath, plan.Model.Weights, cancellationToken) + .ConfigureAwait(false)) + { + throw new InvalidDataException( + "The managed Local AI model no longer matches its pinned size and SHA-256 digest."); + } + + IReadOnlyList verifiedArchives = install.Manifest.RuntimeAssets + .Select(asset => new LocalAiVerifiedArchive(asset.FileName, asset.SizeBytes, asset.Sha256)) + .ToArray(); + var runtimeInstall = new LlamaRuntimeInstallResult( + Path.GetDirectoryName(install.ExecutablePath)!, + install.ExecutablePath, + LlamaRuntimeInstallDisposition.ReusedVerified, + CreatedThisRun: false, + verifiedArchives, + Rollback: null); + var modelInstall = new HuggingFaceModelInstallResult( + install.ModelPath, + HuggingFaceModelInstallDisposition.ReusedVerified, + CreatedThisRun: false); + return new LocalAiReconcileResult(true, install, runtimeInstall, modelInstall); + } + + private static void ValidateRecipeMatch( + LocalAiResolvedInstall install, + LocalInferencePlan plan, + string selectedGpuId, + string localDataDirectory) + { + LocalAiInstallManifest manifest = install.Manifest; + string expectedArchitecture = plan.Runtime.Architecture switch + { + System.Runtime.InteropServices.Architecture.X64 => "x64", + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + _ => throw new InvalidDataException("The selected Local AI runtime architecture is unsupported."), + }; + if (!string.Equals(manifest.EngineVersion, LlamaRuntimeCatalog.ReleaseTag, StringComparison.Ordinal) || + !string.Equals(manifest.Architecture, expectedArchitecture, StringComparison.Ordinal) || + !string.Equals(manifest.RuntimeId, plan.Runtime.Id, StringComparison.Ordinal) || + !string.Equals(manifest.ModelCatalogId, plan.Model.Id, StringComparison.Ordinal) || + !string.Equals(manifest.SelectedGpuId, selectedGpuId, StringComparison.Ordinal)) + { + throw new InvalidDataException( + "The existing managed Local AI installation does not match the selected runtime, GPU, and model recipe."); + } + + // This performs the complete catalog receipt comparison, including + // runtime and model URLs, sizes, hashes, revision, alias, and context. + _ = LlamaServerRouterConfiguration.Build(new LocalAiPaths(localDataDirectory), install); + + LocalAiComponentIdentity component = LlamaRuntimeInstaller.Component(plan.Runtime); + if (!LocalAiPathPolicy.TryResolve( + localDataDirectory, + component, + out LocalAiSetupPaths setupPaths, + out string error) || + !string.Equals( + Path.GetDirectoryName(install.ExecutablePath), + setupPaths.InstallDirectory, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + string.IsNullOrWhiteSpace(error) + ? "The managed llama-server path does not match the selected catalog recipe." + : error); + } + + if (plan.Model.Weights.Source is not HuggingFaceRevisionSource source || + !LocalAiPathPolicy.TryGetModelPaths( + setupPaths, + source.RepositoryId, + source.RevisionSha, + plan.Model.Weights.RelativePath, + out string expectedModelPath, + out _, + out error) || + !string.Equals(install.ModelPath, expectedModelPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + string.IsNullOrWhiteSpace(error) + ? "The managed model path does not match the selected catalog recipe." + : error); + } + } +} diff --git a/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs b/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs index 8676c0784..1bbc92b31 100644 --- a/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs +++ b/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs @@ -1,49 +1,13 @@ -using System.Net; -using System.Net.Sockets; +using System.Collections.Immutable; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; using OpenClaw.Connection.LocalAi; using OpenClaw.Shared.Inference; using OpenClaw.Shared.Inference.Catalog; namespace OpenClaw.SetupEngine; -internal interface ILocalAiPortSelector -{ - bool TrySelect(int requestedPort, out int selectedPort, out string? error); -} - -internal sealed class LoopbackLocalAiPortSelector : ILocalAiPortSelector -{ - public bool TrySelect(int requestedPort, out int selectedPort, out string? error) - { - selectedPort = 0; - error = null; - if (requestedPort is < 0 or > 65_535) - { - error = "The local inference port must be zero or between 1 and 65535."; - return false; - } - - try - { - var listener = new TcpListener(IPAddress.Loopback, requestedPort) - { - ExclusiveAddressUse = true, - }; - listener.Start(); - selectedPort = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return true; - } - catch (Exception ex) when (ex is SocketException or UnauthorizedAccessException) - { - error = requestedPort == 0 - ? "Windows could not allocate a loopback port for local inference." - : $"Loopback port {requestedPort} is not available for local inference."; - return false; - } - } -} - /// /// Selects one qualified NVIDIA GPU/runtime/model plan before setup mutates WSL, /// downloads artifacts, or changes gateway configuration. @@ -51,20 +15,14 @@ public bool TrySelect(int requestedPort, out int selectedPort, out string? error public sealed class PreflightLocalAiHardwareStep : SetupStep { private readonly IHostHardwareProbe _hardwareProbe; - private readonly ILocalAiPortSelector _portSelector; public PreflightLocalAiHardwareStep() - : this(new NvmlHostHardwareProbe(), new LoopbackLocalAiPortSelector()) + : this(new NvmlHostHardwareProbe()) { } - internal PreflightLocalAiHardwareStep( - IHostHardwareProbe hardwareProbe, - ILocalAiPortSelector portSelector) - { + internal PreflightLocalAiHardwareStep(IHostHardwareProbe hardwareProbe) => _hardwareProbe = hardwareProbe ?? throw new ArgumentNullException(nameof(hardwareProbe)); - _portSelector = portSelector ?? throw new ArgumentNullException(nameof(portSelector)); - } public override string Id => "preflight-local-ai-hardware"; public override string DisplayName => "Checking Local AI compatibility"; @@ -117,10 +75,10 @@ public override Task ExecuteAsync(SetupContext ctx, CancellationToke "Local AI compatibility was inconclusive. No setup changes were made.")); } - if (!_portSelector.TrySelect(ctx.Config.LocalAi.Port, out int port, out string? portError)) + if (!LocalAiPortPolicy.TryValidate(ctx.Config.LocalAi.Port, out string? portError)) return Task.FromResult(StepResult.Terminal(portError ?? "Local inference port selection failed.")); - ctx.LocalAiPort = port; + ctx.LocalAiPort = ctx.Config.LocalAi.Port; ctx.Logger.Info( "Selected qualified Local AI plan", new @@ -129,7 +87,7 @@ public override Task ExecuteAsync(SetupContext ctx, CancellationToke model = eligibility.Plan.Model.Id, selection = eligibility.Plan.ModelSelectionOrigin.ToString(), gpu = eligibility.SelectedGpu.StableId, - port, + requestedPort = ctx.Config.LocalAi.Port, }); return Task.FromResult(StepResult.Ok( @@ -271,6 +229,62 @@ private static void RestoreAfterFailedApply(IWslGlobalConfigManager manager, Set } } +/// Reuses a complete manifest-owned installation after current catalog verification. +public sealed class ReconcileLocalAiInstallationStep : SetupStep +{ + private readonly LocalAiInstallReconciler _reconciler; + + public ReconcileLocalAiInstallationStep() + : this(new LocalAiInstallReconciler()) + { + } + + internal ReconcileLocalAiInstallationStep(LocalAiInstallReconciler reconciler) => + _reconciler = reconciler ?? throw new ArgumentNullException(nameof(reconciler)); + + public override string Id => "reconcile-local-ai-installation"; + public override string DisplayName => "Checking for an existing Local AI installation"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiEligibility?.Plan is not { } plan || + ctx.LocalAiEligibility.SelectedGpu?.StableId is not { Length: > 0 } selectedGpuId) + { + return StepResult.Terminal( + "Local AI installation recovery requires a qualified hardware plan."); + } + + try + { + LocalAiReconcileResult result = await _reconciler + .ReconcileAsync(ctx.LocalDataDir, plan, selectedGpuId, ct) + .ConfigureAwait(false); + if (!result.Reused) + return StepResult.Skip("No completed managed Local AI installation was found."); + + ctx.LocalAiResolvedInstall = result.ResolvedInstall; + ctx.LocalAiRuntimeInstall = result.RuntimeInstall; + ctx.LocalAiModelInstall = result.ModelInstall; + ctx.LocalAiPort = result.ResolvedInstall!.Manifest.RequestedPort; + return StepResult.Ok("Reused the verified managed Local AI installation."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + return StepResult.Terminal( + $"The existing Local AI installation could not be reused safely: {ex.Message} " + + "Run uninstall to remove it before retrying setup.", + ex); + } + } +} + /// Installs the two pinned llama.cpp runtime archives as one atomic component. public sealed class AcquireLocalAiRuntimeStep : SetupStep { @@ -301,6 +315,8 @@ internal AcquireLocalAiRuntimeStep(ILlamaRuntimeAcquirer acquirer) => public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { + if (ctx.LocalAiRuntimeInstall is { CreatedThisRun: false }) + return StepResult.Skip("Reusing the verified managed llama-server runtime."); if (ctx.LocalAiEligibility?.Plan is not { } plan) return StepResult.Terminal("Local AI runtime installation requires a qualified hardware plan."); if (ctx.Config.LocalAi.AcquisitionTimeoutSeconds <= 0) @@ -311,10 +327,29 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); try { + var progress = new SynchronousProgress(value => + { + string archive = string.IsNullOrWhiteSpace(value.ArchiveFileName) + ? "llama-server runtime" + : value.ArchiveFileName; + string detail = value.ArchiveCount > 1 + ? $"{value.Phase}: {archive} ({value.ArchiveNumber}/{value.ArchiveCount})" + : $"{value.Phase}: {archive}"; + ctx.DetailProgress?.Report(new SetupDetailProgressEvent( + Id, + detail, + value.Completed, + value.Total, + value.Unit == LocalAiArtifactProgressUnit.Bytes + ? SetupDetailProgressUnit.Bytes + : value.Unit == LocalAiArtifactProgressUnit.Entries + ? SetupDetailProgressUnit.Items + : SetupDetailProgressUnit.None)); + }); LlamaRuntimeInstallResult install = await _acquirer.InstallAsync( ctx.LocalDataDir, plan.Runtime, - progress: null, + progress, linked.Token); ctx.LocalAiRuntimeInstall = install; return StepResult.Ok($"Installed llama-server {LlamaRuntimeCatalog.ReleaseTag}."); @@ -381,6 +416,8 @@ internal AcquireLocalAiModelStep(IHuggingFaceModelAcquirer acquirer) => public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { + if (ctx.LocalAiModelInstall is { CreatedThisRun: false }) + return StepResult.Skip("Reusing the verified managed Local AI model."); if (ctx.LocalAiEligibility?.Plan is not { } plan) return StepResult.Terminal("Local AI model download requires a qualified hardware plan."); if (ctx.LocalAiRuntimeInstall is null) @@ -393,11 +430,18 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); try { + var progress = new SynchronousProgress(value => + ctx.DetailProgress?.Report(new SetupDetailProgressEvent( + Id, + $"Downloading {plan.Model.Weights.RelativePath}", + value.CompletedBytes, + value.TotalBytes, + SetupDetailProgressUnit.Bytes))); HuggingFaceModelInstallResult install = await _acquirer.InstallAsync( ctx.LocalDataDir, LlamaRuntimeInstaller.Component(plan.Runtime), plan.Model, - progress: null, + progress, linked.Token); ctx.LocalAiModelInstall = install; string action = install.Disposition == HuggingFaceModelInstallDisposition.ReusedVerified @@ -431,7 +475,462 @@ public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) _acquirer.RemoveInstalledModel(ctx.LocalDataDir, install); ctx.LocalAiModelInstall = null; } + if (ctx.LocalAiEligibility?.Plan is { } plan) + { + _acquirer.RemovePartialModel( + ctx.LocalDataDir, + LlamaRuntimeInstaller.Component(plan.Runtime), + plan.Model); + } return Task.CompletedTask; } } + +/// Persists one immutable ownership and qualification receipt. +public sealed class PersistLocalAiManifestStep : SetupStep +{ + public override string Id => "persist-local-ai-manifest"; + public override string DisplayName => "Recording Local AI installation"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiResolvedInstall is not null && !ctx.LocalAiManifestCreatedThisRun) + return StepResult.Skip("Reusing the matching managed Local AI installation receipt."); + if (ctx.LocalAiEligibility?.Plan is not { } plan || + ctx.LocalAiEligibility.SelectedGpu is not { StableId: { Length: > 0 } gpuId } || + ctx.LocalAiPort is not { } requestedPort || + ctx.LocalAiRuntimeInstall is not { } runtimeInstall || + ctx.LocalAiModelInstall is not { } modelInstall) + { + return StepResult.Terminal( + "The Local AI installation receipt requires completed hardware, runtime, and model steps."); + } + + if (plan.Model.Weights.Source is not HuggingFaceRevisionSource modelSource) + return StepResult.Terminal("The selected Local AI model does not have immutable Hugging Face provenance."); + if (!LocalAiPortPolicy.TryValidate(requestedPort, out string? portError)) + return StepResult.Terminal(portError ?? "The requested Local AI port is invalid."); + + var paths = new LocalAiPaths(ctx.LocalDataDir); + if (File.Exists(paths.ManifestPath)) + return StepResult.Terminal("A managed Local AI installation receipt already exists."); + + ImmutableArray runtimeAssets; + try + { + runtimeAssets = BuildRuntimeReceipts(plan.Runtime, runtimeInstall); + } + catch (InvalidDataException ex) + { + return StepResult.Terminal(ex.Message, ex); + } + + var manifest = new LocalAiInstallManifest + { + EngineVersion = LlamaRuntimeCatalog.ReleaseTag, + Architecture = plan.Runtime.Architecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new InvalidDataException("The selected Local AI runtime architecture is unsupported."), + }, + RuntimeId = plan.Runtime.Id, + ModelCatalogId = plan.Model.Id, + SelectedGpuId = gpuId, + ExecutablePath = Path.GetRelativePath(paths.RootDirectory, runtimeInstall.ExecutablePath), + RuntimeAssets = runtimeAssets, + ModelPath = Path.GetRelativePath(paths.RootDirectory, modelInstall.ModelPath), + ModelId = $"{modelSource.RepositoryId}@{modelSource.RevisionSha}", + ModelAlias = plan.Model.Id, + ModelAsset = new LocalAiAssetReceipt + { + FileName = Path.GetFileName(plan.Model.Weights.RelativePath), + SourceUrl = plan.Model.Weights.DownloadUri.AbsoluteUri, + SizeBytes = plan.Model.Weights.SizeBytes, + Sha256 = plan.Model.Weights.Sha256.Value, + }, + RequestedPort = requestedPort, + Endpoint = null, + ContextLength = plan.Model.Recipe.ContextTokens, + }; + + var store = new LocalAiManifestStore(paths); + try + { + await store.SaveAsync(manifest, ct); + ctx.LocalAiResolvedInstall = store.ResolveAndValidate(manifest); + ctx.LocalAiManifestCreatedThisRun = true; + return StepResult.Ok("Recorded the verified llama-server and Hugging Face installation."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + return StepResult.Fail($"The Local AI installation receipt could not be saved: {ex.Message}", ex); + } + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.IsUninstalling) + { + ct.ThrowIfCancellationRequested(); + string root = new LocalAiPaths(ctx.LocalDataDir).RootDirectory; + if (!LocalAiPathPolicy.TryDeleteManagedTree( + ctx.LocalDataDir, + root, + allowRoot: true, + out string error)) + { + throw new InvalidDataException( + $"Managed Local AI files could not be removed safely: {error} " + + "Close the OpenClaw companion and retry uninstall."); + } + + ctx.LocalAiRuntimeInstall = null; + ctx.LocalAiModelInstall = null; + ctx.LocalAiResolvedInstall = null; + ctx.LocalAiManifestCreatedThisRun = false; + return; + } + + if (!ctx.LocalAiManifestCreatedThisRun) + return; + + var paths = new LocalAiPaths(ctx.LocalDataDir); + await new LocalAiManifestStore(paths).DeleteAsync(ct); + ct.ThrowIfCancellationRequested(); + File.Delete(paths.RouterPresetPath); + ctx.LocalAiResolvedInstall = null; + ctx.LocalAiManifestCreatedThisRun = false; + } + + private static ImmutableArray BuildRuntimeReceipts( + LlamaRuntimeVariant runtime, + LlamaRuntimeInstallResult install) + { + if (install.VerifiedArchives.Count != runtime.Artifacts.Count) + throw new InvalidDataException("The installed llama-server archive receipt set is incomplete."); + + var receipts = ImmutableArray.CreateBuilder(runtime.Artifacts.Count); + foreach (PinnedArtifact artifact in runtime.Artifacts) + { + string fileName = Path.GetFileName(artifact.RelativePath); + LocalAiVerifiedArchive verified = install.VerifiedArchives.SingleOrDefault( + candidate => string.Equals(candidate.FileName, fileName, StringComparison.Ordinal)) + ?? throw new InvalidDataException( + $"The installed llama-server archive receipt for '{fileName}' is missing."); + if (verified.SizeBytes != artifact.SizeBytes || + !string.Equals(verified.Sha256, artifact.Sha256.Value, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"The installed llama-server archive receipt for '{fileName}' does not match its pin."); + } + + receipts.Add(new LocalAiAssetReceipt + { + FileName = fileName, + SourceUrl = artifact.DownloadUri.AbsoluteUri, + SizeBytes = verified.SizeBytes, + Sha256 = verified.Sha256, + }); + } + + return receipts.MoveToImmutable(); + } +} + +/// Starts the companion-owned llama-server router without preloading a model. +public sealed class StartLocalAiRuntimeStep : SetupStep +{ + private readonly Func _runtimeFactory; + + public StartLocalAiRuntimeStep() + : this(CreateRuntime) + { + } + + internal StartLocalAiRuntimeStep(Func runtimeFactory) => + _runtimeFactory = runtimeFactory ?? throw new ArgumentNullException(nameof(runtimeFactory)); + + public override string Id => "start-local-ai-runtime"; + public override string DisplayName => "Starting llama-server router"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiResolvedInstall is null) + return StepResult.Terminal("llama-server startup requires a verified installation receipt."); + if (ctx.LocalAiRuntime is not null) + return StepResult.Terminal("A Local AI runtime is already attached to this setup transaction."); + + ILocalAiRuntime runtime = _runtimeFactory(ctx); + ctx.LocalAiRuntime = runtime; + try + { + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(ct); + if (snapshot.State != LocalAiRuntimeState.Healthy || + snapshot.Ownership != LocalAiOwnership.CompanionManaged || + snapshot.ProcessId is null || + snapshot.ModelEvidence.State != LocalAiModelAvailabilityState.Verified) + { + await DisposeRuntimeAsync(ctx); + return StepResult.Fail( + snapshot.Detail ?? "The managed llama-server router did not become healthy."); + } + + LocalAiResolvedInstall? verifiedInstall = await new LocalAiManifestStore( + new LocalAiPaths(ctx.LocalDataDir)) + .LoadAsync(ct); + if (verifiedInstall?.Endpoint is null || verifiedInstall.Endpoint != snapshot.Endpoint) + { + await DisposeRuntimeAsync(ctx); + return StepResult.Fail( + "llama-server became healthy without committing its verified endpoint receipt."); + } + ctx.LocalAiResolvedInstall = verifiedInstall; + + return StepResult.Ok( + "The companion-owned llama-server router is healthy. The model remains unloaded until the first request."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + await DisposeRuntimeAsync(ctx); + throw; + } + catch (Exception ex) + { + await DisposeRuntimeAsync(ctx); + return StepResult.Fail($"llama-server startup failed: {ex.Message}", ex); + } + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) => + DisposeRuntimeAsync(ctx).AsTask(); + + private static ILocalAiRuntime CreateRuntime(SetupContext ctx) + { + _ = ctx.LocalAiResolvedInstall + ?? throw new InvalidOperationException("The Local AI installation receipt is unavailable."); + return new LlamaServerRuntimeService(new LlamaServerRuntimeOptions + { + Paths = new LocalAiPaths(ctx.LocalDataDir), + StartupTimeout = TimeSpan.FromSeconds(ctx.Config.LocalAi.HealthTimeoutSeconds), + }); + } + + private static async ValueTask DisposeRuntimeAsync(SetupContext ctx) + { + if (ctx.LocalAiRuntime is null) + return; + + ILocalAiRuntime runtime = ctx.LocalAiRuntime; + ctx.LocalAiRuntime = null; + await runtime.DisposeAsync(); + } +} + +/// +/// Sends the setup-time first request and proves the exact model loaded. The +/// following GPU verification step restarts the router empty after collecting evidence. +/// +public sealed class VerifyLocalAiInferenceStep : SetupStep +{ + private readonly Func _clientFactory; + + public VerifyLocalAiInferenceStep() + : this(() => new LlamaServerInferenceClient()) + { + } + + internal VerifyLocalAiInferenceStep(Func clientFactory) => + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + + public override string Id => "verify-local-ai-inference"; + public override string DisplayName => "Verifying Local AI model load"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiRuntime is not { } runtime || + ctx.LocalAiResolvedInstall is not { Endpoint: { } endpoint } install || + ctx.LocalAiEligibility?.Plan is not { } plan) + { + return StepResult.Terminal( + "Local AI inference verification requires the managed router and qualified installation."); + } + if (runtime.Snapshot.State != LocalAiRuntimeState.Healthy || + runtime.Snapshot.Ownership != LocalAiOwnership.CompanionManaged) + { + return StepResult.Terminal("The managed llama-server router is not healthy."); + } + if (ctx.Config.LocalAi.InferenceTimeoutSeconds <= 0) + return StepResult.Terminal("The Local AI inference timeout must be greater than zero."); + + using ILlamaServerInferenceClient client = _clientFactory(); + using var timeout = new CancellationTokenSource( + TimeSpan.FromSeconds(ctx.Config.LocalAi.InferenceTimeoutSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + + LlamaServerInferenceVerification verification; + LocalAiRuntimeSnapshot loaded; + try + { + verification = await client.VerifyAsync( + endpoint, + plan.Model.Id, + linked.Token); + loaded = await runtime.RefreshAsync(linked.Token); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + await ResetRouterAsync(runtime); + throw; + } + catch (OperationCanceledException ex) + { + await ResetRouterAsync(runtime); + return StepResult.Fail("The first Local AI model load timed out.", ex); + } + catch (Exception ex) when ( + ex is HttpRequestException + or IOException + or InvalidDataException) + { + await ResetRouterAsync(runtime); + return StepResult.Fail($"Local AI inference verification failed: {ex.Message}", ex); + } + + if (loaded.State != LocalAiRuntimeState.Healthy || + loaded.Ownership != LocalAiOwnership.CompanionManaged || + loaded.ModelEvidence.State != LocalAiModelAvailabilityState.Loaded || + !string.Equals(loaded.ModelEvidence.ServerModelId, plan.Model.Id, StringComparison.Ordinal)) + { + return StepResult.Fail("llama-server completed a request but did not report the selected model as loaded."); + } + ctx.LocalAiInferenceVerification = verification; + return StepResult.Ok( + $"Verified {verification.CompletionTokens} generated tokens with the selected model."); + } + + internal static async Task ResetRouterAsync(ILocalAiRuntime runtime) + { + try + { + return await runtime.RestartAsync(CancellationToken.None); + } + catch + { + return runtime.Snapshot; + } + } +} + +/// Proves the app-owned WSL distro can reach the native loopback router. +public sealed class VerifyLocalAiWslStep : SetupStep +{ + private const string HealthMarker = "OPENCLAW_LOCAL_AI_HEALTH_B64="; + private const string ModelsMarker = "OPENCLAW_LOCAL_AI_MODELS_B64="; + private const int MaximumEvidenceBytes = 1024 * 1024; + + public override string Id => "verify-local-ai-wsl"; + public override string DisplayName => "Verifying Local AI access from WSL"; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiResolvedInstall is not { Endpoint: { } endpoint } install || + ctx.LocalAiRuntime is not { Snapshot.State: LocalAiRuntimeState.Healthy } || + ctx.LocalAiEligibility?.Plan is not { } plan || + string.IsNullOrWhiteSpace(ctx.DistroName)) + { + return StepResult.Terminal( + "WSL Local AI verification requires the healthy managed router and app-owned distro."); + } + + string script = BuildProbeScript(endpoint.Port); + CommandResult result = await ctx.Commands.RunInWslAsync( + ctx.DistroName, + script, + TimeSpan.FromSeconds(45), + ct: ct, + user: ctx.Config.Wsl.User, + inputViaStdin: true); + if (result.TimedOut) + return StepResult.Fail("The WSL Local AI reachability check timed out."); + if (result.ExitCode != 0) + return StepResult.Fail("The app-owned WSL distro could not reach the native llama-server router."); + + try + { + using JsonDocument health = DecodeMarker(result.Stdout, HealthMarker); + using JsonDocument models = DecodeMarker(result.Stdout, ModelsMarker); + ValidateHealth(health.RootElement); + ValidateModel(models.RootElement, plan.Model.Id, install.ModelPath); + } + catch (Exception ex) when (ex is FormatException or JsonException or InvalidDataException) + { + return StepResult.Fail($"The WSL Local AI evidence was invalid: {ex.Message}", ex); + } + + return StepResult.Ok( + $"The app-owned WSL distro can reach llama-server on 127.0.0.1:{endpoint.Port}."); + } + + internal static string BuildProbeScript(int port) + { + if (port is <= 0 or > 65_535 || port == 80) + throw new ArgumentOutOfRangeException(nameof(port)); + + return $$""" + set -euo pipefail + base_url='http://127.0.0.1:{{port}}' + health_json="$(curl --fail --silent --show-error --max-time 15 "$base_url/health")" + models_json="$(curl --fail --silent --show-error --max-time 15 "$base_url/models?autoload=false")" + printf '{{HealthMarker}}%s\n' "$(printf '%s' "$health_json" | base64 -w0)" + printf '{{ModelsMarker}}%s\n' "$(printf '%s' "$models_json" | base64 -w0)" + """; + } + + private static JsonDocument DecodeMarker(string stdout, string marker) + { + string? encoded = stdout + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .SingleOrDefault(line => line.StartsWith(marker, StringComparison.Ordinal))? + [marker.Length..]; + if (string.IsNullOrWhiteSpace(encoded) || encoded.Length > MaximumEvidenceBytes * 2) + throw new InvalidDataException($"Missing or oversized evidence marker '{marker}'."); + + byte[] payload = Convert.FromBase64String(encoded); + if (payload.Length > MaximumEvidenceBytes) + throw new InvalidDataException($"Evidence marker '{marker}' exceeded the size limit."); + return JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 24 }); + } + + private static void ValidateHealth(JsonElement health) + { + if (health.ValueKind != JsonValueKind.Object || + !health.TryGetProperty("status", out JsonElement status) || + status.ValueKind != JsonValueKind.String || + !string.Equals(status.GetString(), "ok", StringComparison.Ordinal)) + { + throw new InvalidDataException("llama-server did not report healthy status to WSL."); + } + } + + private static void ValidateModel(JsonElement root, string alias, string expectedPath) + { + if (LlamaServerModelStatusParser.Parse(root, alias, expectedPath) is null) + throw new InvalidDataException("llama-server did not expose the selected managed model to WSL."); + } +} diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index 10b2f3b6e..3c11adc8e 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; using OpenClaw.Shared; using OpenClaw.Shared.Inference; using OpenClaw.Shared.Inference.Catalog; @@ -144,6 +145,7 @@ public sealed class LocalAiConfig public bool WslMirroredNetworkingConsent { get; set; } public int HealthTimeoutSeconds { get; set; } = 15; public int AcquisitionTimeoutSeconds { get; set; } = 7_200; + public int InferenceTimeoutSeconds { get; set; } = 600; } public sealed class WslConfig @@ -475,6 +477,7 @@ public sealed class SetupContext public string? WindowsTailnetDnsSuffix { get; set; } public string? TailscaleDnsName { get; set; } public IExternalAuthorizationPresenter? ExternalAuthorizationPresenter { get; set; } + public IProgress? DetailProgress { get; set; } public Func>? EndpointProvenanceProbe { get; set; } internal WslViabilityResult? WslViability { get; set; } @@ -483,6 +486,14 @@ public Func>? public int? LocalAiPort { get; set; } internal LlamaRuntimeInstallResult? LocalAiRuntimeInstall { get; set; } internal HuggingFaceModelInstallResult? LocalAiModelInstall { get; set; } + internal LocalAiResolvedInstall? LocalAiResolvedInstall { get; set; } + internal bool LocalAiManifestCreatedThisRun { get; set; } + internal ILocalAiRuntime? LocalAiRuntime { get; set; } + internal HostHardwareInfo? LocalAiGpuBaseline { get; set; } + internal LlamaServerInferenceVerification? LocalAiInferenceVerification { get; set; } + internal LocalAiGpuLoadEvidence? LocalAiGpuLoadEvidence { get; set; } + internal LocalAiGatewayPriorState? LocalAiGatewayPriorState { get; set; } + internal bool IsUninstalling { get; set; } // Data directory for gateway registry and identity files public string DataDir { get; } diff --git a/src/OpenClaw.SetupEngine/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs index 916b7be7a..2449357d0 100644 --- a/src/OpenClaw.SetupEngine/SetupPipeline.cs +++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs @@ -57,6 +57,7 @@ public static List BuildDefaultSteps() new PreflightLocalAiHardwareStep(), new PreflightWslStep(), new PreflightWindowsTailscaleStep(), + new ReconcileLocalAiInstallationStep(), new AcquireLocalAiRuntimeStep(), new AcquireLocalAiModelStep(), new PersistLocalAiManifestStep(), @@ -77,6 +78,7 @@ public static List BuildDefaultSteps() new InstallTailscaleStep(), new AuthorizeTailscaleStep(), new ConfigureGatewayStep(), + new ConfigureLocalAiGatewayStep(), new InstallGatewayServiceStep(), new StartGatewayStep(), new FinalizeTailscaleServeStep(), @@ -321,6 +323,8 @@ public async Task UninstallAsync(SetupContext ctx) return new PipelineResult(PipelineOutcome.Failed, Message: "Safety gate: --confirm-destructive required for live uninstall"); } + ctx.IsUninstalling = true; + ctx.Journal.RecordPipelineEvent("uninstall_started", $"steps={_steps.Count}, dry_run={ctx.Config.DryRun}"); ctx.Logger.Info($"Uninstall starting — {_steps.Count} steps in reverse order (dry_run={ctx.Config.DryRun})"); diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiInstallRecoveryTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiInstallRecoveryTests.cs new file mode 100644 index 000000000..ae4f27edc --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiInstallRecoveryTests.cs @@ -0,0 +1,582 @@ +using System.Collections.Immutable; +using System.IO.Compression; +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference.Catalog; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class LocalAiInstallRecoveryTests +{ + [Fact] + public async Task ModelInstall_ResumesExactPartialWithValidatedRange() + { + using var temp = new TempDirectory(); + byte[] modelBytes = "verified-model"u8.ToArray(); + LocalModelInfo model = CreateModel(modelBytes); + LocalAiComponentIdentity component = TestComponent(); + (string modelPath, string partialPath) = ResolveModelPaths(temp.Path, component, model); + Directory.CreateDirectory(Path.GetDirectoryName(partialPath)!); + await File.WriteAllBytesAsync(partialPath, modelBytes[..4]); + RangeHeaderValue? observedRange = null; + using var client = new HttpClient(new DelegateHandler(request => + { + observedRange = request.Headers.Range; + var response = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(modelBytes[4..]), + }; + response.Content.Headers.ContentRange = new ContentRangeHeaderValue( + 4, + modelBytes.Length - 1, + modelBytes.Length); + return response; + })); + + var result = await new HuggingFaceModelInstaller(client).InstallAsync( + temp.Path, + component, + model, + progress: null, + CancellationToken.None); + + Assert.Equal("bytes=4-", observedRange?.ToString()); + Assert.Equal(modelPath, result.ModelPath); + Assert.Equal(modelBytes, await File.ReadAllBytesAsync(modelPath)); + Assert.False(File.Exists(partialPath)); + } + + [Fact] + public async Task ModelInstall_ServerIgnoringRangeRestartsFromZero() + { + using var temp = new TempDirectory(); + byte[] modelBytes = "verified-model"u8.ToArray(); + LocalModelInfo model = CreateModel(modelBytes); + LocalAiComponentIdentity component = TestComponent(); + (string modelPath, string partialPath) = ResolveModelPaths(temp.Path, component, model); + Directory.CreateDirectory(Path.GetDirectoryName(partialPath)!); + await File.WriteAllBytesAsync(partialPath, "old"u8.ToArray()); + using var client = new HttpClient(new DelegateHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(modelBytes), + })); + + await new HuggingFaceModelInstaller(client).InstallAsync( + temp.Path, + component, + model, + progress: null, + CancellationToken.None); + + Assert.Equal(modelBytes, await File.ReadAllBytesAsync(modelPath)); + } + + [Fact] + public async Task ModelInstall_InvalidRangeDeletesUntrustedPartial() + { + using var temp = new TempDirectory(); + byte[] modelBytes = "verified-model"u8.ToArray(); + LocalModelInfo model = CreateModel(modelBytes); + LocalAiComponentIdentity component = TestComponent(); + (_, string partialPath) = ResolveModelPaths(temp.Path, component, model); + Directory.CreateDirectory(Path.GetDirectoryName(partialPath)!); + await File.WriteAllBytesAsync(partialPath, modelBytes[..4]); + using var client = new HttpClient(new DelegateHandler(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(modelBytes[4..]), + }; + response.Content.Headers.ContentRange = new ContentRangeHeaderValue( + 3, + modelBytes.Length - 2, + modelBytes.Length); + return response; + })); + + await Assert.ThrowsAsync(() => + new HuggingFaceModelInstaller(client).InstallAsync( + temp.Path, + component, + model, + progress: null, + CancellationToken.None)); + + Assert.False(File.Exists(partialPath)); + } + + [Fact] + public async Task ModelInstall_TransientFailurePreservesResumablePartial() + { + using var temp = new TempDirectory(); + byte[] modelBytes = "a-model-large-enough-to-retry"u8.ToArray(); + LocalModelInfo model = CreateModel(modelBytes); + LocalAiComponentIdentity component = TestComponent(); + (_, string partialPath) = ResolveModelPaths(temp.Path, component, model); + using var client = new HttpClient(new DelegateHandler(request => + { + long offset = request.Headers.Range?.Ranges.Single().From ?? 0; + var content = new StreamContent(new ThrowAfterPrefixStream(modelBytes[(int)offset..], 2)); + var response = new HttpResponseMessage( + offset == 0 ? HttpStatusCode.OK : HttpStatusCode.PartialContent) + { + Content = content, + }; + if (offset > 0) + { + response.Content.Headers.ContentRange = new ContentRangeHeaderValue( + offset, + modelBytes.Length - 1, + modelBytes.Length); + } + return response; + })); + var installer = new HuggingFaceModelInstaller( + client, + (_, _) => Task.CompletedTask); + + await Assert.ThrowsAsync(() => installer.InstallAsync( + temp.Path, + component, + model, + progress: null, + CancellationToken.None)); + + Assert.True(File.Exists(partialPath)); + Assert.InRange(new FileInfo(partialPath).Length, 1, modelBytes.Length - 1); + } + + [Fact] + public async Task ModelInstall_VerifiedCompletePartialPromotesWithoutHttp() + { + using var temp = new TempDirectory(); + byte[] modelBytes = "verified-model"u8.ToArray(); + LocalModelInfo model = CreateModel(modelBytes); + LocalAiComponentIdentity component = TestComponent(); + (string modelPath, string partialPath) = ResolveModelPaths(temp.Path, component, model); + Directory.CreateDirectory(Path.GetDirectoryName(partialPath)!); + await File.WriteAllBytesAsync(partialPath, modelBytes); + using var client = new HttpClient(new DelegateHandler(_ => + throw new InvalidOperationException("HTTP must not be used for a verified complete partial."))); + + await new HuggingFaceModelInstaller(client).InstallAsync( + temp.Path, + component, + model, + progress: null, + CancellationToken.None); + + Assert.Equal(modelBytes, await File.ReadAllBytesAsync(modelPath)); + Assert.False(File.Exists(partialPath)); + } + + [Fact] + public async Task RuntimeInstall_ReplacesExactUnclaimedCatalogDirectory() + { + using var temp = new TempDirectory(); + byte[] binaryZip = CreateZip(("llama-server.exe", "server"u8.ToArray())); + byte[] dependencyZip = CreateZip(("cudart64_13.dll", "cuda"u8.ToArray())); + LlamaRuntimeVariant runtime = CreateRuntime(binaryZip, dependencyZip); + LocalAiComponentIdentity component = LlamaRuntimeInstaller.Component(runtime); + Assert.True(LocalAiPathPolicy.TryResolve(temp.Path, component, out LocalAiSetupPaths paths, out _)); + Directory.CreateDirectory(paths.InstallDirectory); + await File.WriteAllTextAsync(Path.Combine(paths.InstallDirectory, "orphan.txt"), "orphan"); + using var client = new HttpClient(new DelegateHandler(request => + { + byte[] bytes = request.RequestUri!.AbsolutePath.EndsWith("runtime.zip", StringComparison.Ordinal) + ? binaryZip + : dependencyZip; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }; + })); + var installer = new LlamaRuntimeInstaller( + new LocalAiArtifactInstaller(client), + new ValidRuntimeInspector()); + + LlamaRuntimeInstallResult result = await installer.InstallAsync( + temp.Path, + runtime, + progress: null, + CancellationToken.None); + + Assert.True(result.CreatedThisRun); + Assert.False(File.Exists(Path.Combine(paths.InstallDirectory, "orphan.txt"))); + Assert.True(File.Exists(Path.Combine(paths.InstallDirectory, "llama-server.exe"))); + } + + [Fact] + public async Task Reconciler_ReusesOnlyMatchingManifestWithoutMutation() + { + using var temp = new TempDirectory(); + LocalInferencePlan plan = CatalogPlan(); + const string gpuId = "GPU-0"; + LocalAiInstallManifest manifest = CreateManifest(temp.Path, plan, gpuId); + var paths = new LocalAiPaths(temp.Path); + await new LocalAiManifestStore(paths).SaveAsync(manifest); + byte[] original = await File.ReadAllBytesAsync(paths.ManifestPath); + var reconciler = new LocalAiInstallReconciler( + new ValidRuntimeInspector(), + new AcceptingModelVerifier()); + + LocalAiReconcileResult result = await reconciler.ReconcileAsync( + temp.Path, + plan, + gpuId, + CancellationToken.None); + + Assert.True(result.Reused); + Assert.False(result.RuntimeInstall!.CreatedThisRun); + Assert.False(result.ModelInstall!.CreatedThisRun); + Assert.Equal(original, await File.ReadAllBytesAsync(paths.ManifestPath)); + } + + [Fact] + public async Task Reconciler_RejectsDifferentGpuWithoutDeletingManifest() + { + using var temp = new TempDirectory(); + LocalInferencePlan plan = CatalogPlan(); + var paths = new LocalAiPaths(temp.Path); + await new LocalAiManifestStore(paths).SaveAsync(CreateManifest(temp.Path, plan, "GPU-0")); + + await Assert.ThrowsAsync(() => + new LocalAiInstallReconciler(new ValidRuntimeInspector(), new AcceptingModelVerifier()) + .ReconcileAsync(temp.Path, plan, "GPU-1", CancellationToken.None)); + + Assert.True(File.Exists(paths.ManifestPath)); + } + + [Fact] + public async Task FreshProcessUninstall_RemovesCanonicalLocalAiRoot() + { + using var temp = new TempDirectory(); + string root = new LocalAiPaths(temp.Path).RootDirectory; + Directory.CreateDirectory(Path.Combine(root, "engines", "runtime")); + await File.WriteAllTextAsync(Path.Combine(root, "state.json"), "corrupt but app-owned"); + await File.WriteAllTextAsync(Path.Combine(root, "engines", "runtime", "file.bin"), "data"); + SetupContext context = CreateContext(temp.Path, confirmDestructive: true); + + PipelineResult result = await new SetupPipeline([new PersistLocalAiManifestStep()]) + .UninstallAsync(context); + + Assert.Equal(PipelineOutcome.Success, result.Outcome); + Assert.False(Directory.Exists(root)); + } + + [Fact] + public async Task FreshProcessUninstall_RejectsDescendantJunctionBeforeDeletingAnything() + { + using var temp = new TempDirectory(); + using var outside = new TempDirectory(); + string root = new LocalAiPaths(temp.Path).RootDirectory; + Directory.CreateDirectory(root); + string retained = Path.Combine(root, "retained.txt"); + string outsideFile = Path.Combine(outside.Path, "outside.txt"); + await File.WriteAllTextAsync(retained, "retain"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string junction = Path.Combine(root, "linked"); + CreateJunction(junction, outside.Path); + try + { + PipelineResult result = await new SetupPipeline([new PersistLocalAiManifestStep()]) + .UninstallAsync(CreateContext(temp.Path, confirmDestructive: true)); + + Assert.Equal(PipelineOutcome.Failed, result.Outcome); + Assert.True(File.Exists(retained)); + Assert.True(File.Exists(outsideFile)); + } + finally + { + if (Directory.Exists(junction)) + Directory.Delete(junction); + } + } + + [Fact] + public async Task NormalRollback_DoesNotRemoveExistingLocalAiRoot() + { + using var temp = new TempDirectory(); + string root = new LocalAiPaths(temp.Path).RootDirectory; + Directory.CreateDirectory(root); + string retained = Path.Combine(root, "retained.txt"); + await File.WriteAllTextAsync(retained, "retain"); + SetupContext context = CreateContext(temp.Path, confirmDestructive: false); + + await new PersistLocalAiManifestStep().RollbackAsync(context, CancellationToken.None); + + Assert.True(File.Exists(retained)); + } + + private static SetupContext CreateContext(string localDataDirectory, bool confirmDestructive) + { + var config = new SetupConfig { ConfirmDestructive = confirmDestructive }; + var logger = new SetupLogger(filePath: null, LogLevel.Trace); + return new SetupContext( + config, + logger, + new TransactionJournal(filePath: null), + new CommandRunner(logger), + CancellationToken.None, + dataDir: Path.Combine(localDataDirectory, "roaming"), + localDataDir: localDataDirectory); + } + + private static LocalInferencePlan CatalogPlan() + { + LlamaRuntimeVariant runtime = LlamaRuntimeCatalog.Find( + System.Runtime.InteropServices.Architecture.X64)!; + return new LocalInferencePlan( + runtime, + LocalModelCatalog.Default, + LocalInferenceModelSelectionOrigin.Default); + } + + private static LocalAiInstallManifest CreateManifest( + string localDataDirectory, + LocalInferencePlan plan, + string gpuId) + { + LocalAiComponentIdentity component = LlamaRuntimeInstaller.Component(plan.Runtime); + Assert.True(LocalAiPathPolicy.TryResolve( + localDataDirectory, + component, + out LocalAiSetupPaths setupPaths, + out string error), error); + var paths = new LocalAiPaths(localDataDirectory); + var source = Assert.IsType(plan.Model.Weights.Source); + Assert.True(LocalAiPathPolicy.TryGetModelPaths( + setupPaths, + source.RepositoryId, + source.RevisionSha, + plan.Model.Weights.RelativePath, + out string modelPath, + out _, + out error), error); + string executable = Path.Combine(setupPaths.InstallDirectory, LlamaRuntimeCatalog.ServerExecutableName); + return new LocalAiInstallManifest + { + EngineVersion = LlamaRuntimeCatalog.ReleaseTag, + Architecture = "x64", + RuntimeId = plan.Runtime.Id, + ModelCatalogId = plan.Model.Id, + SelectedGpuId = gpuId, + ExecutablePath = Path.GetRelativePath(paths.RootDirectory, executable), + RuntimeAssets = plan.Runtime.Artifacts.Select(artifact => new LocalAiAssetReceipt + { + FileName = Path.GetFileName(artifact.RelativePath), + SourceUrl = artifact.DownloadUri.AbsoluteUri, + SizeBytes = artifact.SizeBytes, + Sha256 = artifact.Sha256.Value, + }).ToImmutableArray(), + ModelPath = Path.GetRelativePath(paths.RootDirectory, modelPath), + ModelId = $"{source.RepositoryId}@{source.RevisionSha}", + ModelAlias = plan.Model.Id, + ModelAsset = new LocalAiAssetReceipt + { + FileName = Path.GetFileName(plan.Model.Weights.RelativePath), + SourceUrl = plan.Model.Weights.DownloadUri.AbsoluteUri, + SizeBytes = plan.Model.Weights.SizeBytes, + Sha256 = plan.Model.Weights.Sha256.Value, + }, + Endpoint = "http://127.0.0.1:18803/v1", + ContextLength = plan.Model.Recipe.ContextTokens, + }; + } + + private static LocalModelInfo CreateModel(byte[] bytes) + { + var source = new HuggingFaceRevisionSource("owner/repo", new string('a', 40)); + var artifact = new PinnedArtifact( + "test-model", + ArtifactRole.ModelWeights, + source, + "model.gguf", + bytes.Length, + new Sha256Digest(Sha256(bytes))); + return new LocalModelInfo( + "test-model", + "Test model", + "Test", + "Q4", + artifact, + new LocalModelRunRecipe( + 1024, + KvCachePrecision.F16, + KvCachePrecision.F16, + 128, + 128, + 1, + true, + true, + SpeculativeDecodingMode.DraftMtp, + 1, + new ModelSamplingPreset(0.6, 20, 0.95, 0, 1, 0)), + IsDefault: true, + IsExplicitAlternative: false, + SupportsVision: false); + } + + private static LlamaRuntimeVariant CreateRuntime(byte[] binaryZip, byte[] dependencyZip) + { + var source = new GitHubReleaseSource("owner/repo", "v1", new string('b', 40)); + return new LlamaRuntimeVariant( + "test-runtime", + Architecture.X64, + new Version(13, 0), + [ + new PinnedArtifact( + "test-runtime-bin", + ArtifactRole.RuntimeBinary, + source, + "runtime.zip", + binaryZip.Length, + new Sha256Digest(Sha256(binaryZip))), + new PinnedArtifact( + "test-runtime-dep", + ArtifactRole.RuntimeDependency, + source, + "dependency.zip", + dependencyZip.Length, + new Sha256Digest(Sha256(dependencyZip))), + ]); + } + + private static (string ModelPath, string PartialPath) ResolveModelPaths( + string localDataDirectory, + LocalAiComponentIdentity component, + LocalModelInfo model) + { + var source = Assert.IsType(model.Weights.Source); + Assert.True(LocalAiPathPolicy.TryResolve( + localDataDirectory, + component, + out LocalAiSetupPaths paths, + out string error), error); + Assert.True(LocalAiPathPolicy.TryGetModelPaths( + paths, + source.RepositoryId, + source.RevisionSha, + model.Weights.RelativePath, + out string modelPath, + out string partialPath, + out error), error); + return (modelPath, partialPath); + } + + private static LocalAiComponentIdentity TestComponent() => + new("llama-server", "v1", "win-x64"); + + private static byte[] CreateZip(params (string Name, byte[] Content)[] entries) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach ((string name, byte[] content) in entries) + { + ZipArchiveEntry entry = archive.CreateEntry(name); + using Stream destination = entry.Open(); + destination.Write(content); + } + } + return stream.ToArray(); + } + + private static string Sha256(byte[] bytes) => + Convert.ToHexStringLower(SHA256.HashData(bytes)); + + private static void CreateJunction(string link, string target) + { + using var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c mklink /J \"{link}\" \"{target}\"", + UseShellExecute = false, + CreateNoWindow = true, + }) ?? throw new InvalidOperationException("Failed to start mklink."); + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + } + + private sealed class DelegateHandler( + Func handler) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => Task.FromResult(handler(request)); + } + + private sealed class ThrowAfterPrefixStream(byte[] bytes, int prefixLength) : Stream + { + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => bytes.Length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= prefixLength) + throw new IOException("Simulated interrupted response body."); + int length = Math.Min(Math.Min(count, prefixLength - _position), bytes.Length - _position); + Array.Copy(bytes, _position, buffer, offset, length); + _position += length; + return length; + } + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_position >= prefixLength) + return ValueTask.FromException(new IOException("Simulated interrupted response body.")); + int length = Math.Min(Math.Min(buffer.Length, prefixLength - _position), bytes.Length - _position); + bytes.AsMemory(_position, length).CopyTo(buffer); + _position += length; + return ValueTask.FromResult(length); + } + } + + private sealed class ValidRuntimeInspector : ILlamaRuntimeInspector + { + public Task InspectAsync( + string installDirectory, + CancellationToken cancellationToken) => + Task.FromResult(new LlamaRuntimeInspection(true, "valid", null)); + } + + private sealed class AcceptingModelVerifier : ILocalAiModelFileVerifier + { + public Task VerifyAsync( + string path, + PinnedArtifact artifact, + CancellationToken cancellationToken) => Task.FromResult(true); + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "OpenClawLocalAiRecoveryTests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiPortHandoffTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiPortHandoffTests.cs new file mode 100644 index 000000000..1356b1462 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiPortHandoffTests.cs @@ -0,0 +1,172 @@ +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; +using OpenClaw.TestSupport; +using System.Runtime.InteropServices; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class LocalAiPortHandoffTests +{ + [Fact] + public async Task Preflight_AutomaticPortRemainsZeroForChildOwnedBind() + { + SetupContext context = CreateContext(new LocalAiConfig { Enabled = true, Port = 0 }); + var step = new PreflightLocalAiHardwareStep(new FakeHardwareProbe(CreateSparkHardware())); + + StepResult result = await step.ExecuteAsync(context, CancellationToken.None); + + Assert.Equal(StepOutcome.Success, result.Outcome); + Assert.Equal(0, context.LocalAiPort); + } + + [Fact] + public async Task Preflight_RejectsReservedPort80() + { + SetupContext context = CreateContext(new LocalAiConfig { Enabled = true, Port = 80 }); + var step = new PreflightLocalAiHardwareStep(new FakeHardwareProbe(CreateSparkHardware())); + + StepResult result = await step.ExecuteAsync(context, CancellationToken.None); + + Assert.Equal(StepOutcome.FailedTerminal, result.Outcome); + Assert.Contains("80", result.Message, StringComparison.Ordinal); + Assert.Null(context.LocalAiPort); + } + + [Fact] + public async Task PersistStep_RecordsRequestButNotEndpointBeforeHealth() + { + using var temp = new TempDirectory("local-ai-handoff-"); + SetupContext context = CreateContext( + new LocalAiConfig { Enabled = true, Port = 0 }, + temp.Path); + context.LocalAiEligibility = LocalInferenceEligibility.Evaluate(CreateSparkHardware()); + context.LocalAiPort = 0; + context.LocalAiRuntimeInstall = RuntimeInstall(temp.Path); + context.LocalAiModelInstall = ModelInstall(temp.Path, context.LocalAiEligibility.Plan!.Model); + + StepResult result = await new PersistLocalAiManifestStep().ExecuteAsync( + context, + CancellationToken.None); + + Assert.Equal(StepOutcome.Success, result.Outcome); + LocalAiResolvedInstall? saved = await new LocalAiManifestStore(new LocalAiPaths(temp.Path)).LoadAsync(); + Assert.NotNull(saved); + Assert.Equal(0, saved.Manifest.RequestedPort); + Assert.Null(saved.Endpoint); + } + + [Fact] + public async Task PersistStep_CarriesDeterministicallySelectedGpuIntoRouterEnvironment() + { + using var temp = new TempDirectory("local-ai-handoff-"); + SetupContext context = CreateContext( + new LocalAiConfig + { + Enabled = true, + Port = 0, + SelectedModelId = LocalModelCatalog.Qwen9BModelId, + }, + temp.Path); + context.LocalAiEligibility = LocalInferenceEligibility.Evaluate( + CreateMultiGpuHardware(), + context.Config.LocalAi.SelectedModelId); + context.LocalAiPort = 0; + context.LocalAiRuntimeInstall = RuntimeInstall(temp.Path); + context.LocalAiModelInstall = ModelInstall(temp.Path, context.LocalAiEligibility.Plan!.Model); + + StepResult result = await new PersistLocalAiManifestStep().ExecuteAsync( + context, + CancellationToken.None); + var paths = new LocalAiPaths(temp.Path); + LocalAiResolvedInstall saved = (await new LocalAiManifestStore(paths).LoadAsync())!; + LlamaServerRouterLaunchPlan launch = LlamaServerRouterConfiguration.Build(paths, saved); + + Assert.Equal(StepOutcome.Success, result.Outcome); + Assert.Equal("GPU-a", context.LocalAiEligibility.SelectedGpu?.StableId); + Assert.Equal("GPU-a", saved.Manifest.SelectedGpuId); + Assert.Equal("GPU-a", launch.Environment["CUDA_VISIBLE_DEVICES"]); + } + + private static SetupContext CreateContext(LocalAiConfig localAi, string? localDataDirectory = null) + { + var config = new SetupConfig { LocalAi = localAi }; + var logger = new SetupLogger(filePath: null, LogLevel.Trace); + return new SetupContext( + config, + logger, + new TransactionJournal(filePath: null), + new CommandRunner(logger), + CancellationToken.None, + localDataDir: localDataDirectory); + } + + private static HostHardwareInfo CreateSparkHardware() => new( + Architecture.Arm64, + 128L * 1024 * 1024 * 1024, + 100L * 1024 * 1024 * 1024, + [ + new GpuInfo( + GpuVendor.Nvidia, + "NVIDIA RTX Spark N1X (6144-core Blackwell RTX GPU)", + GpuVisibleMemoryBytes: 25_702_694_912, + FreeGpuVisibleMemoryBytes: 25_000_000_000, + DriverVersion: "616.00", + CudaMajorVersion: 13, + StableId: "GPU-SPARK"), + ], + VulkanAvailable: false); + + private static HostHardwareInfo CreateMultiGpuHardware() => new( + Architecture.Arm64, + 128L * 1024 * 1024 * 1024, + 100L * 1024 * 1024 * 1024, + [ + new GpuInfo( + GpuVendor.Nvidia, + "NVIDIA larger but busier GPU", + GpuVisibleMemoryBytes: 32L * 1024 * 1024 * 1024, + FreeGpuVisibleMemoryBytes: 12L * 1024 * 1024 * 1024, + DriverVersion: "616.00", + CudaMajorVersion: 13, + StableId: "GPU-z"), + new GpuInfo( + GpuVendor.Nvidia, + "NVIDIA selected GPU", + GpuVisibleMemoryBytes: 16L * 1024 * 1024 * 1024, + FreeGpuVisibleMemoryBytes: 14L * 1024 * 1024 * 1024, + DriverVersion: "616.00", + CudaMajorVersion: 13, + StableId: "GPU-a"), + ], + VulkanAvailable: false); + + private static LlamaRuntimeInstallResult RuntimeInstall(string localDataDirectory) + { + LlamaRuntimeVariant runtime = LlamaRuntimeCatalog.Find(Architecture.Arm64)!; + return new( + Path.Combine(localDataDirectory, "LocalAI", "engines", "llama-server"), + Path.Combine(localDataDirectory, "LocalAI", "engines", "llama-server", "llama-server.exe"), + LlamaRuntimeInstallDisposition.Installed, + CreatedThisRun: true, + VerifiedArchives: runtime.Artifacts.Select(artifact => new LocalAiVerifiedArchive( + Path.GetFileName(artifact.RelativePath), + artifact.SizeBytes, + artifact.Sha256.Value)).ToArray(), + Rollback: new LocalAiArtifactRollbackMetadata( + Path.Combine(localDataDirectory, "LocalAI", "engines", "llama-server"))); + } + + private static HuggingFaceModelInstallResult ModelInstall( + string localDataDirectory, + LocalModelInfo model) => new( + Path.Combine(localDataDirectory, "LocalAI", "models", model.Weights.RelativePath), + HuggingFaceModelInstallDisposition.Downloaded, + CreatedThisRun: true); + + private sealed class FakeHardwareProbe(HostHardwareInfo hardware) : IHostHardwareProbe + { + public HostHardwareInfo Probe() => hardware; + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs index 0abebd9f6..dad6bccf4 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs @@ -79,23 +79,24 @@ public void BuildDefaultSteps_IncludesCurrentSetupFlow() { var steps = SetupStepFactory.BuildDefaultSteps(); - Assert.Equal(36, steps.Count); + Assert.Equal(37, steps.Count); Assert.IsType(steps[0]); Assert.IsType(steps[1]); Assert.IsType(steps[2]); Assert.IsType(steps[3]); Assert.IsType(steps[4]); - Assert.IsType(steps[5]); - Assert.IsType(steps[6]); - Assert.IsType(steps[7]); - Assert.IsType(steps[8]); - Assert.IsType(steps[9]); - Assert.IsType(steps[10]); - Assert.IsType(steps[11]); - Assert.IsType(steps[12]); - Assert.IsType(steps[13]); - Assert.IsType(steps[14]); - Assert.IsType(steps[15]); + Assert.IsType(steps[5]); + Assert.IsType(steps[6]); + Assert.IsType(steps[7]); + Assert.IsType(steps[8]); + Assert.IsType(steps[9]); + Assert.IsType(steps[10]); + Assert.IsType(steps[11]); + Assert.IsType(steps[12]); + Assert.IsType(steps[13]); + Assert.IsType(steps[14]); + Assert.IsType(steps[15]); + Assert.IsType(steps[16]); Assert.Contains(steps, s => s is ValidateWslLockdownStep); var lockdownIndex = steps.FindIndex(s => s is ValidateWslLockdownStep); var cliInstallIndex = steps.FindIndex(s => s is InstallCliStep); From 8c9fe0b46242d475f4347c68932f13d8b8b045dc Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:53:35 -0700 Subject: [PATCH 13/23] feat(setup): verify local inference readiness Probe health, execute real inference, and verify GPU activity on the owned endpoint. Fail setup cleanly when the installed Local AI stack is not operational. Signed-off-by: Joel Fernandes --- .../LocalAi/LlamaServerInferenceClient.cs | 212 ++++++++++++ .../LocalAiGpuVerification.cs | 313 ++++++++++++++++++ src/OpenClaw.SetupEngine/default-config.json | 4 +- .../LocalAiGpuRestartEndpointTests.cs | 199 +++++++++++ 4 files changed, 727 insertions(+), 1 deletion(-) create mode 100644 src/OpenClaw.Connection/LocalAi/LlamaServerInferenceClient.cs create mode 100644 src/OpenClaw.SetupEngine/LocalAiGpuVerification.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/LocalAiGpuRestartEndpointTests.cs diff --git a/src/OpenClaw.Connection/LocalAi/LlamaServerInferenceClient.cs b/src/OpenClaw.Connection/LocalAi/LlamaServerInferenceClient.cs new file mode 100644 index 000000000..dd16aeb86 --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LlamaServerInferenceClient.cs @@ -0,0 +1,212 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace OpenClaw.Connection.LocalAi; + +public sealed record LlamaServerInferenceVerification( + string ModelId, + int PromptTokens, + int CompletionTokens, + double PromptMilliseconds, + double CompletionMilliseconds); + +public interface ILlamaServerInferenceClient : IDisposable +{ + Task VerifyAsync( + Uri endpoint, + string modelAlias, + CancellationToken cancellationToken = default); +} + +/// +/// Sends one bounded OpenAI-compatible request to the managed router. This is +/// the setup-time first request, so it intentionally triggers lazy model load. +/// Prompt and response content are never returned or logged. +/// +public sealed class LlamaServerInferenceClient : ILlamaServerInferenceClient +{ + private const int MaximumResponseBytes = 1024 * 1024; + private readonly HttpClient _client; + + public LlamaServerInferenceClient() : this(new SocketsHttpHandler + { + UseProxy = false, + AllowAutoRedirect = false, + ConnectTimeout = TimeSpan.FromSeconds(3), + }) + { + } + + internal LlamaServerInferenceClient(HttpMessageHandler handler) + { + _client = new HttpClient(handler ?? throw new ArgumentNullException(nameof(handler)), disposeHandler: true) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + } + + public async Task VerifyAsync( + Uri endpoint, + string modelAlias, + CancellationToken cancellationToken = default) + { + ValidateEndpoint(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(modelAlias); + + Uri requestUri = new(endpoint.AbsoluteUri.TrimEnd('/') + "/chat/completions"); + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = JsonContent.Create(new + { + model = modelAlias, + messages = new[] + { + new + { + role = "user", + content = "Reply with a short confirmation that local inference is ready.", + }, + }, + max_tokens = 32, + temperature = 0, + stream = false, + }), + }; + + using HttpResponseMessage response = await _client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new InvalidDataException( + $"llama-server inference returned HTTP {(int)response.StatusCode} ({response.StatusCode})."); + } + + byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken).ConfigureAwait(false); + using JsonDocument document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 24 }); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("model", out JsonElement model) || + model.ValueKind != JsonValueKind.String || + !string.Equals(model.GetString(), modelAlias, StringComparison.Ordinal)) + { + throw new InvalidDataException("llama-server inference did not report the selected model alias."); + } + + ValidateAssistantOutput(root); + (int promptTokens, int completionTokens) = ReadUsage(root); + (double promptMilliseconds, double completionMilliseconds) = ReadTimings(root); + return new( + modelAlias, + promptTokens, + completionTokens, + promptMilliseconds, + completionMilliseconds); + } + + private static void ValidateAssistantOutput(JsonElement root) + { + if (!root.TryGetProperty("choices", out JsonElement choices) || + choices.ValueKind != JsonValueKind.Array || + choices.GetArrayLength() == 0) + { + throw new InvalidDataException("llama-server inference returned no choices."); + } + + JsonElement choice = choices[0]; + if (choice.ValueKind != JsonValueKind.Object || + !choice.TryGetProperty("message", out JsonElement message) || + message.ValueKind != JsonValueKind.Object || + (!HasNonemptyString(message, "content") && + !HasNonemptyString(message, "reasoning_content"))) + { + throw new InvalidDataException("llama-server inference returned no assistant output."); + } + } + + private static bool HasNonemptyString(JsonElement value, string propertyName) => + value.TryGetProperty(propertyName, out JsonElement property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString()); + + private static (int PromptTokens, int CompletionTokens) ReadUsage(JsonElement root) + { + if (!root.TryGetProperty("usage", out JsonElement usage) || + usage.ValueKind != JsonValueKind.Object || + !usage.TryGetProperty("prompt_tokens", out JsonElement promptTokens) || + !promptTokens.TryGetInt32(out int prompt) || prompt <= 0 || + !usage.TryGetProperty("completion_tokens", out JsonElement completionTokens) || + !completionTokens.TryGetInt32(out int completion) || completion <= 0) + { + throw new InvalidDataException("llama-server inference returned invalid token usage."); + } + + return (prompt, completion); + } + + private static (double PromptMilliseconds, double CompletionMilliseconds) ReadTimings(JsonElement root) + { + if (!root.TryGetProperty("timings", out JsonElement timings) || + timings.ValueKind != JsonValueKind.Object || + !TryReadNonnegativeDouble(timings, "prompt_ms", out double promptMilliseconds) || + !TryReadNonnegativeDouble(timings, "predicted_ms", out double completionMilliseconds)) + { + throw new InvalidDataException("llama-server inference returned invalid timing evidence."); + } + + return (promptMilliseconds, completionMilliseconds); + } + + private static bool TryReadNonnegativeDouble(JsonElement value, string propertyName, out double result) + { + result = 0; + return value.TryGetProperty(propertyName, out JsonElement property) && + property.TryGetDouble(out result) && + double.IsFinite(result) && + result >= 0; + } + + private static void ValidateEndpoint(Uri endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + if (!endpoint.IsAbsoluteUri || + endpoint.Scheme != Uri.UriSchemeHttp || + !string.Equals(endpoint.Host, "127.0.0.1", StringComparison.Ordinal) || + endpoint.Port is <= 0 or > 65_535 || + endpoint.Port == 80 || + !string.Equals(endpoint.AbsolutePath, "/v1", StringComparison.Ordinal) || + !string.IsNullOrEmpty(endpoint.UserInfo) || + !string.IsNullOrEmpty(endpoint.Query) || + !string.IsNullOrEmpty(endpoint.Fragment)) + { + throw new ArgumentException( + "The llama-server endpoint must use an explicit IPv4 loopback /v1 address.", + nameof(endpoint)); + } + } + + private static async Task ReadBoundedAsync( + HttpContent content, + CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > MaximumResponseBytes) + throw new InvalidDataException("The llama-server inference response exceeds the size limit."); + + await using Stream input = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[16 * 1024]; + while (true) + { + int read = await input.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + if (read == 0) + return output.ToArray(); + if (output.Length + read > MaximumResponseBytes) + throw new InvalidDataException("The llama-server inference response exceeds the size limit."); + output.Write(buffer, 0, read); + } + } + + public void Dispose() => _client.Dispose(); +} diff --git a/src/OpenClaw.SetupEngine/LocalAiGpuVerification.cs b/src/OpenClaw.SetupEngine/LocalAiGpuVerification.cs new file mode 100644 index 000000000..8b7defdc9 --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiGpuVerification.cs @@ -0,0 +1,313 @@ +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference; + +namespace OpenClaw.SetupEngine; + +internal sealed record LocalAiGpuLoadEvidence( + int ProcessId, + string SelectedGpuId, + string CudaModulePath, + int OffloadedLayers, + int TotalLayers, + long TotalGpuVisibleBytes, + long FreeGpuVisibleBytesBeforeLoad, + long FreeGpuVisibleBytesAfterLoad) +{ + public long UsedGpuVisibleBytesAfterLoad => TotalGpuVisibleBytes - FreeGpuVisibleBytesAfterLoad; + public long LoadDeltaBytes => FreeGpuVisibleBytesBeforeLoad - FreeGpuVisibleBytesAfterLoad; +} + +internal interface ILocalAiGpuEvidenceProbe +{ + Task CaptureAsync( + int processId, + string selectedGpuId, + HostHardwareInfo baseline, + LocalAiPaths paths, + CancellationToken cancellationToken); +} + +internal sealed partial class WindowsLocalAiGpuEvidenceProbe : ILocalAiGpuEvidenceProbe +{ + private const int MaximumLogBytes = 2 * 1024 * 1024; + private readonly IHostHardwareProbe _hardwareProbe; + + public WindowsLocalAiGpuEvidenceProbe() + : this(new NvmlHostHardwareProbe()) + { + } + + internal WindowsLocalAiGpuEvidenceProbe(IHostHardwareProbe hardwareProbe) => + _hardwareProbe = hardwareProbe ?? throw new ArgumentNullException(nameof(hardwareProbe)); + + public async Task CaptureAsync( + int processId, + string selectedGpuId, + HostHardwareInfo baseline, + LocalAiPaths paths, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(selectedGpuId); + ArgumentNullException.ThrowIfNull(baseline); + ArgumentNullException.ThrowIfNull(paths); + + string cudaModule = FindCudaModule(processId); + (int offloaded, int total) = await ReadFullOffloadEvidenceAsync(paths, cancellationToken); + HostHardwareInfo current = _hardwareProbe.Probe(); + GpuInfo before = FindGpu(baseline, selectedGpuId); + GpuInfo after = FindGpu(current, selectedGpuId); + if (before.GpuVisibleMemoryBytes is not > 0 || before.FreeGpuVisibleMemoryBytes is not >= 0 || + after.GpuVisibleMemoryBytes != before.GpuVisibleMemoryBytes || after.FreeGpuVisibleMemoryBytes is not >= 0) + { + throw new InvalidDataException("The selected GPU memory evidence was incomplete or changed during model loading."); + } + + return new LocalAiGpuLoadEvidence( + processId, + selectedGpuId, + cudaModule, + offloaded, + total, + after.GpuVisibleMemoryBytes.Value, + before.FreeGpuVisibleMemoryBytes.Value, + after.FreeGpuVisibleMemoryBytes.Value); + } + + internal static (int Offloaded, int Total) ParseFullOffloadEvidence(string log) + { + ArgumentNullException.ThrowIfNull(log); + MatchCollection matches = FullOffloadPattern().Matches(log); + foreach (Match match in matches.Cast().Reverse()) + { + if (int.TryParse(match.Groups[1].Value, out int offloaded) && + int.TryParse(match.Groups[2].Value, out int total) && + offloaded > 0 && offloaded == total) + { + return (offloaded, total); + } + } + throw new InvalidDataException("llama-server did not report full GPU layer offload."); + } + + private static string FindCudaModule(int processId) + { + try + { + using Process process = Process.GetProcessById(processId); + foreach (ProcessModule module in process.Modules) + { + if (string.Equals(module.ModuleName, "ggml-cuda.dll", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(module.FileName) && + Path.IsPathFullyQualified(module.FileName)) + { + return module.FileName; + } + } + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + throw new InvalidDataException("The managed llama-server CUDA module could not be inspected.", ex); + } + throw new InvalidDataException("The managed llama-server process did not load ggml-cuda.dll."); + } + + private static async Task<(int Offloaded, int Total)> ReadFullOffloadEvidenceAsync( + LocalAiPaths paths, + CancellationToken cancellationToken) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); + InvalidDataException? lastFailure = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + string log = await ReadLogTailAsync(paths.StandardOutputLogPath, cancellationToken) + "\n" + + await ReadLogTailAsync(paths.StandardErrorLogPath, cancellationToken); + try + { + return ParseFullOffloadEvidence(log); + } + catch (InvalidDataException ex) + { + lastFailure = ex; + } + await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); + } + while (DateTimeOffset.UtcNow < deadline); + throw lastFailure ?? new InvalidDataException("llama-server GPU offload evidence was unavailable."); + } + + private static async Task ReadLogTailAsync(string path, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + return string.Empty; + await using var stream = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, + 16 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + long count = Math.Min(stream.Length, MaximumLogBytes); + stream.Seek(-count, SeekOrigin.End); + var bytes = new byte[checked((int)count)]; + int read = 0; + while (read < bytes.Length) + { + int next = await stream.ReadAsync(bytes.AsMemory(read), cancellationToken); + if (next == 0) + break; + read += next; + } + return Encoding.UTF8.GetString(bytes, 0, read); + } + + private static GpuInfo FindGpu(HostHardwareInfo hardware, string selectedGpuId) => + hardware.Gpus.SingleOrDefault(gpu => + string.Equals(gpu.StableId, selectedGpuId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidDataException("The selected GPU was not present in the verification probe."); + + [GeneratedRegex(@"offloaded\s+(\d+)\s*/\s*(\d+)\s+layers\s+to\s+GPU", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex FullOffloadPattern(); +} + +public sealed class CaptureLocalAiGpuBaselineStep : SetupStep +{ + private readonly IHostHardwareProbe _probe; + public CaptureLocalAiGpuBaselineStep() : this(new NvmlHostHardwareProbe()) { } + internal CaptureLocalAiGpuBaselineStep(IHostHardwareProbe probe) => + _probe = probe ?? throw new ArgumentNullException(nameof(probe)); + + public override string Id => "capture-local-ai-gpu-baseline"; + public override string DisplayName => "Capturing GPU baseline"; + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + try + { + ctx.LocalAiGpuBaseline = _probe.Probe(); + return Task.FromResult(StepResult.Ok("Captured the selected GPU memory baseline")); + } + catch (Exception ex) + { + return Task.FromResult(StepResult.Fail("The selected GPU baseline could not be captured.", ex)); + } + } +} + +public sealed class VerifyLocalAiGpuLoadStep : SetupStep +{ + private readonly ILocalAiGpuEvidenceProbe _probe; + private readonly Func> _installLoader; + + public VerifyLocalAiGpuLoadStep() + : this(new WindowsLocalAiGpuEvidenceProbe()) + { + } + + internal VerifyLocalAiGpuLoadStep( + ILocalAiGpuEvidenceProbe probe, + Func>? installLoader = null) + { + _probe = probe ?? throw new ArgumentNullException(nameof(probe)); + _installLoader = installLoader ?? LoadResolvedInstallAsync; + } + + public override string Id => "verify-local-ai-gpu-load"; + public override string DisplayName => "Verifying Local AI GPU placement"; + public override bool CanRetry => false; + public override RetryPolicy Retry => RetryPolicy.None; + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiRuntime is not { } runtime || + ctx.LocalAiResolvedInstall is not { } install || + ctx.LocalAiGpuBaseline is not { } baseline || + ctx.LocalAiEligibility?.Plan is not { } plan || + ctx.LocalAiEligibility.SelectedGpu?.StableId is not { Length: > 0 } gpuId || + ctx.LocalAiInferenceVerification is null || + runtime.Snapshot is not { State: LocalAiRuntimeState.Healthy, Ownership: LocalAiOwnership.CompanionManaged, + ModelEvidence.State: LocalAiModelAvailabilityState.Loaded, ProcessId: not null }) + { + return StepResult.Terminal("GPU verification requires a loaded managed model and selected GPU baseline."); + } + + LocalAiGpuLoadEvidence? evidence = null; + Exception? failure = null; + try + { + evidence = await _probe.CaptureAsync( + runtime.Snapshot.ProcessId.Value, + gpuId, + baseline, + new LocalAiPaths(ctx.LocalDataDir), + ct); + string engineDirectory = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(Path.GetDirectoryName(install.ExecutablePath)!)); + string cudaModule = Path.GetFullPath(evidence.CudaModulePath); + if (!cudaModule.StartsWith( + engineDirectory + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + "llama-server loaded CUDA from outside the managed runtime directory."); + } + long minimumDelta = Math.Max(512L * 1024 * 1024, plan.Model.Weights.SizeBytes / 2); + if (evidence.OffloadedLayers != evidence.TotalLayers || evidence.LoadDeltaBytes < minimumDelta) + { + throw new InvalidDataException( + "The selected model did not produce the required full-offload GPU memory evidence."); + } + ctx.LocalAiGpuLoadEvidence = evidence; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + await VerifyLocalAiInferenceStep.ResetRouterAsync(runtime); + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) + { + failure = ex; + } + + LocalAiRuntimeSnapshot reset = await VerifyLocalAiInferenceStep.ResetRouterAsync(runtime); + if (failure is not null) + return StepResult.Fail($"Local AI GPU verification failed: {failure.Message}", failure); + if (reset.State != LocalAiRuntimeState.Healthy || + reset.Ownership != LocalAiOwnership.CompanionManaged || + reset.ModelEvidence.State != LocalAiModelAvailabilityState.Verified) + { + return StepResult.Fail("llama-server could not return to on-demand loading after GPU verification."); + } + + LocalAiResolvedInstall? restartedInstall; + try + { + restartedInstall = await _installLoader(ctx, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) + { + return StepResult.Fail( + "llama-server restarted without a readable durable endpoint receipt.", + ex); + } + if (restartedInstall?.Endpoint is null || restartedInstall.Endpoint != reset.Endpoint) + { + return StepResult.Fail( + "llama-server restarted without committing its current endpoint receipt."); + } + ctx.LocalAiResolvedInstall = restartedInstall; + + return StepResult.Ok( + $"Verified {evidence!.OffloadedLayers}/{evidence.TotalLayers} GPU layers and {evidence.LoadDeltaBytes} bytes of load growth; on-demand loading remains enabled."); + } + + private static Task LoadResolvedInstallAsync( + SetupContext ctx, + CancellationToken cancellationToken) => + new LocalAiManifestStore(new LocalAiPaths(ctx.LocalDataDir)).LoadAsync(cancellationToken); +} diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index 10f1ce340..bc6d3a5dc 100644 --- a/src/OpenClaw.SetupEngine/default-config.json +++ b/src/OpenClaw.SetupEngine/default-config.json @@ -110,7 +110,9 @@ // The setup UI sets this only after explaining that applying mirrored mode stops running WSL distros once. "WslMirroredNetworkingConsent": false, "HealthTimeoutSeconds": 15, - "AcquisitionTimeoutSeconds": 7200 + "AcquisitionTimeoutSeconds": 7200, + // Includes first-request model load and a short verification completion. + "InferenceTimeoutSeconds": 600 }, // ─── Node Capabilities ─── diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiGpuRestartEndpointTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiGpuRestartEndpointTests.cs new file mode 100644 index 000000000..1dc44a6d2 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiGpuRestartEndpointTests.cs @@ -0,0 +1,199 @@ +using System.Collections.Immutable; +using System.Runtime.InteropServices; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; +using OpenClaw.TestSupport; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class LocalAiGpuRestartEndpointTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GpuVerification_RefreshesDynamicEndpointBeforeWslVerification( + bool durableReceiptUsesRestartedEndpoint) + { + using var temp = new TempDirectory("local-ai-gpu-restart-"); + HostHardwareInfo hardware = CreateSparkHardware(); + LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate(hardware); + LocalModelInfo model = eligibility.Plan!.Model; + Uri originalEndpoint = new("http://127.0.0.1:31001/v1"); + Uri restartedEndpoint = new("http://127.0.0.1:31002/v1"); + LocalAiResolvedInstall originalInstall = CreateInstall(temp.Path, model, originalEndpoint); + LocalAiResolvedInstall restartedInstall = CreateInstall(temp.Path, model, restartedEndpoint); + var runtime = new FakeRuntime( + CreateSnapshot(model, originalEndpoint, LocalAiModelAvailabilityState.Loaded), + CreateSnapshot(model, restartedEndpoint, LocalAiModelAvailabilityState.Verified)); + var context = CreateContext(temp.Path); + context.LocalAiEligibility = eligibility; + context.LocalAiHardware = hardware; + context.LocalAiGpuBaseline = hardware; + context.LocalAiResolvedInstall = originalInstall; + context.LocalAiRuntime = runtime; + context.LocalAiInferenceVerification = new(model.Id, 8, 32, 1, 1); + + string cudaPath = Path.Combine( + Path.GetDirectoryName(originalInstall.ExecutablePath)!, + "ggml-cuda.dll"); + var step = new VerifyLocalAiGpuLoadStep( + new FakeGpuProbe(new LocalAiGpuLoadEvidence( + ProcessId: 4242, + SelectedGpuId: "GPU-SPARK", + CudaModulePath: cudaPath, + OffloadedLayers: 42, + TotalLayers: 42, + TotalGpuVisibleBytes: 48L * 1024 * 1024 * 1024, + FreeGpuVisibleBytesBeforeLoad: 40L * 1024 * 1024 * 1024, + FreeGpuVisibleBytesAfterLoad: 16L * 1024 * 1024 * 1024)), + (_, _) => Task.FromResult( + durableReceiptUsesRestartedEndpoint ? restartedInstall : originalInstall)); + + StepResult result = await step.ExecuteAsync(context, CancellationToken.None); + + Assert.Equal(1, runtime.RestartCalls); + if (durableReceiptUsesRestartedEndpoint) + { + Assert.Equal(StepOutcome.Success, result.Outcome); + Assert.Equal(restartedEndpoint, context.LocalAiResolvedInstall!.Endpoint); + } + else + { + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("current endpoint receipt", result.Message, StringComparison.Ordinal); + Assert.Equal(originalEndpoint, context.LocalAiResolvedInstall!.Endpoint); + } + } + + private static SetupContext CreateContext(string localDataDirectory) + { + var config = new SetupConfig { LocalAi = new LocalAiConfig { Enabled = true } }; + var logger = new SetupLogger(filePath: null, LogLevel.Trace); + return new SetupContext( + config, + logger, + new TransactionJournal(filePath: null), + new CommandRunner(logger), + CancellationToken.None, + localDataDir: localDataDirectory); + } + + private static HostHardwareInfo CreateSparkHardware() => new( + Architecture.Arm64, + 128L * 1024 * 1024 * 1024, + 100L * 1024 * 1024 * 1024, + [ + new GpuInfo( + GpuVendor.Nvidia, + "NVIDIA RTX Spark N1X (6144-core Blackwell RTX GPU)", + GpuVisibleMemoryBytes: 48L * 1024 * 1024 * 1024, + FreeGpuVisibleMemoryBytes: 40L * 1024 * 1024 * 1024, + DriverVersion: "616.00", + CudaMajorVersion: 13, + StableId: "GPU-SPARK"), + ], + VulkanAvailable: false); + + private static LocalAiResolvedInstall CreateInstall( + string localDataDirectory, + LocalModelInfo model, + Uri endpoint) + { + string engineDirectory = Path.Combine( + localDataDirectory, + "LocalAI", + "engines", + "llama-server", + "b10488", + "win-arm64"); + string executable = Path.Combine(engineDirectory, "llama-server.exe"); + string modelPath = Path.Combine(localDataDirectory, "LocalAI", "models", model.Weights.RelativePath); + var receipt = new LocalAiAssetReceipt + { + FileName = "artifact.bin", + SourceUrl = "https://example.invalid/artifact.bin", + SizeBytes = 1, + Sha256 = new string('a', 64), + }; + var manifest = new LocalAiInstallManifest + { + EngineVersion = "b10488", + Architecture = "arm64", + HardwareProfileId = "nvidia-spark-arm64", + RuntimeId = "llama-server-b10488-win-arm64-cuda13", + ModelCatalogId = model.Id, + SelectedGpuId = "GPU-SPARK", + ExecutablePath = Path.GetRelativePath(Path.Combine(localDataDirectory, "LocalAI"), executable), + RuntimeAssets = ImmutableArray.Create(receipt), + ModelPath = Path.GetRelativePath(Path.Combine(localDataDirectory, "LocalAI"), modelPath), + ModelId = model.Id, + ModelAlias = model.Id, + ModelAsset = receipt with { FileName = Path.GetFileName(modelPath) }, + RequestedPort = 0, + Endpoint = endpoint.AbsoluteUri, + ContextLength = model.Recipe.ContextTokens, + }; + return new LocalAiResolvedInstall(manifest, executable, modelPath, endpoint); + } + + private static LocalAiRuntimeSnapshot CreateSnapshot( + LocalModelInfo model, + Uri endpoint, + LocalAiModelAvailabilityState state) => + new( + LocalAiRuntimeState.Healthy, + LocalAiOwnership.CompanionManaged, + endpoint, + "b10488", + model.Id, + new LocalAiModelEvidence( + state, + DateTimeOffset.UtcNow, + model.Weights.Sha256.Value, + model.Weights.SizeBytes, + state == LocalAiModelAvailabilityState.Loaded ? model.Id : null), + ProcessId: 4242, + ProcessStartedAtUtc: DateTimeOffset.UtcNow, + Detail: null, + UpdatedAtUtc: DateTimeOffset.UtcNow); + + private sealed class FakeGpuProbe(LocalAiGpuLoadEvidence evidence) : ILocalAiGpuEvidenceProbe + { + public Task CaptureAsync( + int processId, + string selectedGpuId, + HostHardwareInfo baseline, + LocalAiPaths paths, + CancellationToken cancellationToken) => + Task.FromResult(evidence); + } + + private sealed class FakeRuntime( + LocalAiRuntimeSnapshot initial, + LocalAiRuntimeSnapshot restarted) : ILocalAiRuntime + { + public LocalAiRuntimeSnapshot Snapshot { get; private set; } = initial; + public int RestartCalls { get; private set; } + public event EventHandler? StateChanged; + + public Task EnsureStartedAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Snapshot); + + public Task StopAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Snapshot); + + public Task RestartAsync(CancellationToken cancellationToken = default) + { + RestartCalls++; + Snapshot = restarted; + StateChanged?.Invoke(this, new LocalAiRuntimeSnapshotChangedEventArgs(Snapshot)); + return Task.FromResult(Snapshot); + } + + public Task RefreshAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Snapshot); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From 32b37969a591528ee728e24775ddd460dfad2f79 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:53:35 -0700 Subject: [PATCH 14/23] feat(setup): configure IPv4 llama-server gateway provider Use explicit 127.0.0.1 for Windows-to-WSL gateway connections and persisted setup state. Remove only exact managed Local AI provider state during uninstall, preserving drift. Signed-off-by: Joel Fernandes --- .../LocalAiGatewayProviderDefinition.cs | 100 ++++- .../LocalAiGatewayConfiguration.cs | 414 ++++++++++++++++++ src/OpenClaw.SetupEngine/PairOperatorStep.cs | 2 +- .../RunGatewayWizardStep.cs | 2 +- src/OpenClaw.SetupEngine/SetupContext.cs | 2 +- src/OpenClaw.SetupEngine/SetupSteps.cs | 15 +- src/OpenClaw.SetupEngine/default-config.json | 2 +- src/OpenClaw.Tray.WinUI/AppIdentity.cs | 8 +- .../Pages/SettingsPage.xaml.cs | 2 +- .../Services/StartupSetupState.cs | 20 +- .../Setup/SetupAndConnectTests.cs | 2 +- .../LocalAiGatewayUninstallTests.cs | 256 +++++++++++ .../SetupConfigTests.cs | 2 +- .../SetupContextTests.cs | 2 +- .../StartupSetupStateTests.cs | 18 +- 15 files changed, 815 insertions(+), 32 deletions(-) create mode 100644 src/OpenClaw.SetupEngine/LocalAiGatewayConfiguration.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/LocalAiGatewayUninstallTests.cs diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs b/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs index ce18b2423..3522c72ee 100644 --- a/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs +++ b/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs @@ -6,12 +6,84 @@ namespace OpenClaw.Connection.LocalAi; /// Canonical gateway configuration for the companion-owned llama.cpp provider. public static class LocalAiGatewayProviderDefinition { + private const string ApiType = "openai-completions"; + public const string CliRedactedApiKey = "__OPENCLAW_REDACTED__"; public const string ProviderPath = "models.providers.llamacpp"; public const string PrimaryModelPath = "agents.defaults.model.primary"; public const int ProviderTimeoutSeconds = 300; public const int MaximumOutputTokens = 8_192; public static string BuildProviderJson(LocalAiResolvedInstall install) + { + return BuildProviderJson(install, "llama-local"); + } + + /// + /// Compares a provider returned by openclaw config get --json with + /// the managed definition. The CLI intentionally redacts secret values, + /// so the API key may be either its written value or the documented + /// redaction marker; every routing and model field must still match. + /// + public static bool MatchesProviderJson(string providerJson, LocalAiResolvedInstall install) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerJson); + ArgumentNullException.ThrowIfNull(install); + + try + { + using JsonDocument actual = JsonDocument.Parse(providerJson); + using JsonDocument expected = JsonDocument.Parse(BuildProviderJson(install)); + if (JsonEquals(actual.RootElement, expected.RootElement)) + return true; + + using JsonDocument redacted = JsonDocument.Parse( + BuildProviderJson(install, CliRedactedApiKey)); + return JsonEquals(actual.RootElement, redacted.RootElement); + } + catch (JsonException) + { + return false; + } + } + + private static bool JsonEquals(JsonElement left, JsonElement right) + { + if (left.ValueKind != right.ValueKind) + return false; + + if (left.ValueKind == JsonValueKind.Object) + { + JsonProperty[] leftProperties = [.. left.EnumerateObject()]; + JsonProperty[] rightProperties = [.. right.EnumerateObject()]; + if (leftProperties.Length != rightProperties.Length) + return false; + foreach (JsonProperty property in leftProperties) + { + if (!right.TryGetProperty(property.Name, out JsonElement rightValue) || + !JsonEquals(property.Value, rightValue)) + { + return false; + } + } + return true; + } + + if (left.ValueKind == JsonValueKind.Array) + { + JsonElement.ArrayEnumerator leftItems = left.EnumerateArray(); + JsonElement.ArrayEnumerator rightItems = right.EnumerateArray(); + while (leftItems.MoveNext()) + { + if (!rightItems.MoveNext() || !JsonEquals(leftItems.Current, rightItems.Current)) + return false; + } + return !rightItems.MoveNext(); + } + + return JsonElement.DeepEquals(left, right); + } + + private static string BuildProviderJson(LocalAiResolvedInstall install, string apiKey) { ArgumentNullException.ThrowIfNull(install); Uri endpoint = install.Endpoint @@ -24,8 +96,8 @@ public static string BuildProviderJson(LocalAiResolvedInstall install) var value = new { baseUrl = endpoint.AbsoluteUri.TrimEnd('/'), - api = "openai-completions", - apiKey = "llama-local", + api = ApiType, + apiKey, timeoutSeconds = ProviderTimeoutSeconds, models = new[] { @@ -40,6 +112,7 @@ public static string BuildProviderJson(LocalAiResolvedInstall install) contextTokens = install.Manifest.ContextLength, maxTokens = MaximumOutputTokens, compat = new { supportsTools = true, supportsUsageInStreaming = true }, + api = ApiType, }, }, }; @@ -52,12 +125,35 @@ public static string BuildPrimaryModel(LocalAiResolvedInstall install) return $"llamacpp/{install.Manifest.ModelAlias}"; } + public static void ValidateFallbackModel(string? model) + => LocalAiGatewayModelPolicy.ValidateFallbackModel(model); + + public static bool TryReadPrimaryModelJson(string json, out string? model) + { + model = null; + try + { + using JsonDocument document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.String) + return false; + model = document.RootElement.GetString(); + ValidateFallbackModel(model); + return true; + } + catch (Exception ex) when (ex is JsonException or InvalidDataException) + { + model = null; + return false; + } + } + public static string BuildProviderBatchJson(LocalAiResolvedInstall install) { using JsonDocument provider = JsonDocument.Parse(BuildProviderJson(install)); return JsonSerializer.Serialize(new[] { new { path = ProviderPath, value = (object)provider.RootElement.Clone() }, + new { path = PrimaryModelPath, value = (object)BuildPrimaryModel(install) }, }); } } diff --git a/src/OpenClaw.SetupEngine/LocalAiGatewayConfiguration.cs b/src/OpenClaw.SetupEngine/LocalAiGatewayConfiguration.cs new file mode 100644 index 000000000..f46f8f05c --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiGatewayConfiguration.cs @@ -0,0 +1,414 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Connection.LocalAi; + +namespace OpenClaw.SetupEngine; + +internal sealed record LocalAiGatewayPriorState( + bool ProviderExisted, + string? ProviderJson, + bool PrimaryModelExisted, + string? PrimaryModelJson); + +internal static class LocalAiGatewayConfigBuilder +{ + internal const string ProviderPath = LocalAiGatewayProviderDefinition.ProviderPath; + internal const string PrimaryModelPath = LocalAiGatewayProviderDefinition.PrimaryModelPath; + + public static string BuildBatchJson(SetupContext context) + { + ArgumentNullException.ThrowIfNull(context); + var install = context.LocalAiResolvedInstall + ?? throw new InvalidOperationException("The Local AI install receipt is required."); + _ = context.LocalAiEligibility?.Plan + ?? throw new InvalidOperationException("The qualified Local AI plan is required."); + using JsonDocument provider = JsonDocument.Parse( + LocalAiGatewayProviderDefinition.BuildProviderJson(install)); + object[] operations = + [ + new { path = ProviderPath, value = (object)provider.RootElement.Clone() }, + new { path = PrimaryModelPath, value = (object)LocalAiGatewayProviderDefinition.BuildPrimaryModel(install) }, + ]; + return JsonSerializer.Serialize(operations); + } + + public static string BuildRestoreBatchJson(LocalAiGatewayPriorState prior) + { + ArgumentNullException.ThrowIfNull(prior); + var operations = new List(1); + // Setup accepts a pre-existing provider only when it already matches the + // managed definition. Do not replay its CLI-redacted API key on rollback. + // The exact current provider is retained below when it existed beforehand. + if (prior.PrimaryModelExisted) + { + using JsonDocument primary = JsonDocument.Parse(prior.PrimaryModelJson!); + operations.Add(new { path = PrimaryModelPath, value = (object)primary.RootElement.Clone() }); + } + return JsonSerializer.Serialize(operations); + } + + public static string ExpectedPrimaryModel(SetupContext context) => + LocalAiGatewayProviderDefinition.BuildPrimaryModel( + context.LocalAiResolvedInstall + ?? throw new InvalidOperationException("The Local AI install receipt is required.")); +} + +public sealed class ConfigureLocalAiGatewayStep : SetupStep +{ + private const string ProviderMarker = "OPENCLAW_LOCAL_AI_PROVIDER_B64="; + private const string PrimaryMarker = "OPENCLAW_LOCAL_AI_PRIMARY_B64="; + private const string MissingValue = "MISSING"; + private const string BatchVariable = "OPENCLAW_LOCAL_AI_BATCH_B64"; + private const int MaximumSnapshotBytes = 1024 * 1024; + + public override string Id => "configure-local-ai-gateway"; + public override string DisplayName => "Connect gateway to Local AI"; + public override bool CanSkip(SetupContext ctx) => !ctx.Config.LocalAi.Enabled; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.LocalAiResolvedInstall is null || ctx.LocalAiEligibility?.Plan is null) + return StepResult.Terminal("Local AI gateway configuration requires a qualified install receipt."); + + CommandResult snapshotResult = await CaptureStateAsync(ctx, ct); + if (snapshotResult.ExitCode != 0 || snapshotResult.TimedOut) + return StepResult.Fail("Could not safely snapshot the existing Local AI gateway configuration."); + + LocalAiGatewayPriorState prior; + try + { + prior = ParseSnapshot(snapshotResult.Stdout); + ctx.LocalAiGatewayPriorState = prior; + } + catch (Exception ex) when (ex is FormatException or JsonException or InvalidDataException) + { + return StepResult.Fail("The existing Local AI gateway configuration could not be validated.", ex); + } + + LocalAiResolvedInstall install = ctx.LocalAiResolvedInstall; + string expectedPrimary = JsonSerializer.Serialize( + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + string? fallbackModel; + if (prior.ProviderExisted) + { + if (install.Endpoint is null || + !LocalAiGatewayProviderDefinition.MatchesProviderJson(prior.ProviderJson!, install) || + !prior.PrimaryModelExisted || + !JsonEquals(prior.PrimaryModelJson!, expectedPrimary)) + { + return StepResult.Fail( + "The existing llamacpp gateway route is not the exact companion-managed configuration; preserving it."); + } + fallbackModel = install.Manifest.GatewayFallbackModel; + } + else if (prior.PrimaryModelExisted) + { + if (!LocalAiGatewayProviderDefinition.TryReadPrimaryModelJson( + prior.PrimaryModelJson!, + out fallbackModel)) + { + return StepResult.Fail( + "The existing gateway primary model cannot be safely restored after Local AI stops; preserving it."); + } + } + else + { + fallbackModel = null; + } + + if (!string.Equals( + install.Manifest.GatewayFallbackModel, + fallbackModel, + StringComparison.Ordinal)) + { + try + { + var store = new LocalAiManifestStore(new LocalAiPaths(ctx.LocalDataDir)); + LocalAiInstallManifest updatedManifest = install.Manifest with + { + GatewayFallbackModel = fallbackModel, + }; + await store.SaveAsync(updatedManifest, ct).ConfigureAwait(false); + ctx.LocalAiResolvedInstall = store.ResolveAndValidate(updatedManifest); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + return StepResult.Fail( + "The prior gateway model could not be recorded before Local AI was enabled.", ex); + } + } + + string batchJson = LocalAiGatewayConfigBuilder.BuildBatchJson(ctx); + CommandResult result = await ApplyBatchAsync(ctx, batchJson, "LOCAL_AI_GATEWAY_CONFIGURED", ct); + if (result.ExitCode != 0 || result.TimedOut || + !result.Stdout.Contains("LOCAL_AI_GATEWAY_CONFIGURED", StringComparison.Ordinal)) + { + return StepResult.Fail(result.TimedOut + ? "Local AI gateway configuration timed out." + : $"Local AI gateway configuration failed (exit {result.ExitCode})."); + } + + return StepResult.Ok("Gateway configured to use the managed llama-server provider"); + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + if (ctx.IsUninstalling) + { + await RemoveManagedStateForUninstallAsync(ctx, ct).ConfigureAwait(false); + return; + } + + if (ctx.LocalAiGatewayPriorState is not { } prior) + return; + + CommandResult currentResult = await CaptureStateAsync(ctx, ct); + if (currentResult.ExitCode != 0 || currentResult.TimedOut) + { + ctx.Logger.Warn("Could not inspect the Local AI gateway configuration during rollback; preserving it."); + return; + } + + LocalAiGatewayPriorState current; + try + { + current = ParseSnapshot(currentResult.Stdout); + } + catch (Exception ex) when (ex is FormatException or JsonException or InvalidDataException) + { + ctx.Logger.Warn($"Could not validate Local AI gateway rollback state; preserving it ({ex.GetType().Name})."); + return; + } + + string expectedPrimary = JsonSerializer.Serialize(LocalAiGatewayConfigBuilder.ExpectedPrimaryModel(ctx)); + if (!current.ProviderExisted || !current.PrimaryModelExisted || + !LocalAiGatewayProviderDefinition.MatchesProviderJson( + current.ProviderJson!, + ctx.LocalAiResolvedInstall!) || + !JsonEquals(current.PrimaryModelJson!, expectedPrimary)) + { + ctx.Logger.Warn("Local AI gateway settings changed after setup; preserving the newer values."); + return; + } + + string restoreBatch = LocalAiGatewayConfigBuilder.BuildRestoreBatchJson(prior); + if (restoreBatch != "[]") + { + CommandResult restore = await ApplyBatchAsync(ctx, restoreBatch, "LOCAL_AI_GATEWAY_RESTORED", ct); + if (restore.ExitCode != 0 || restore.TimedOut) + ctx.Logger.Warn("Restoring the previous Local AI gateway settings failed."); + } + + var unset = new List(2); + if (!prior.PrimaryModelExisted) + unset.Add($"openclaw config unset {LocalAiGatewayConfigBuilder.PrimaryModelPath}"); + if (!prior.ProviderExisted) + unset.Add($"openclaw config unset {LocalAiGatewayConfigBuilder.ProviderPath}"); + if (unset.Count > 0) + { + string script = $"set -e\n{ctx.WslPathPrefix}\n{string.Join("\n", unset)}\necho LOCAL_AI_GATEWAY_UNSET"; + CommandResult result = await ctx.Commands.RunInWslAsync( + ctx.DistroName!, script, TimeSpan.FromMinutes(2), ct: ct, + user: ctx.Config.Wsl.User, inputViaStdin: true); + if (result.ExitCode != 0 || result.TimedOut) + ctx.Logger.Warn("Removing setup-created Local AI gateway settings failed."); + } + } + + private static async Task RemoveManagedStateForUninstallAsync( + SetupContext ctx, + CancellationToken ct) + { + LocalAiResolvedInstall? install = ctx.LocalAiResolvedInstall; + if (install is null) + { + install = await new LocalAiManifestStore(new LocalAiPaths(ctx.LocalDataDir)) + .LoadAsync(ct) + .ConfigureAwait(false); + } + if (install is null) + return; + + CommandResult currentResult = await CaptureStateAsync(ctx, ct).ConfigureAwait(false); + if (currentResult.ExitCode != 0 || currentResult.TimedOut) + { + throw new IOException( + "Could not safely inspect the managed Local AI gateway configuration during uninstall."); + } + + LocalAiGatewayPriorState current = ParseSnapshot(currentResult.Stdout); + if (!current.ProviderExisted && !current.PrimaryModelExisted) + return; + if (install.Endpoint is null) + { + throw new InvalidDataException( + "The Local AI manifest has no verified endpoint, so existing gateway settings cannot be proven app-owned."); + } + + string managedPrimary = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install); + string expectedPrimary = JsonSerializer.Serialize(managedPrimary); + string? fallbackModel = install.Manifest.GatewayFallbackModel; + string? currentPrimary = null; + bool currentPrimaryIsManaged = current.PrimaryModelExisted && + JsonEquals(current.PrimaryModelJson!, expectedPrimary); + bool currentPrimaryIsFallback = current.PrimaryModelExisted && + fallbackModel is not null && + JsonEquals(current.PrimaryModelJson!, JsonSerializer.Serialize(fallbackModel)); + if (current.PrimaryModelExisted && + !currentPrimaryIsManaged && + !currentPrimaryIsFallback && + LocalAiGatewayProviderDefinition.TryReadPrimaryModelJson(current.PrimaryModelJson!, out string? parsed)) + { + currentPrimary = parsed; + } + + if ((current.ProviderExisted && + !LocalAiGatewayProviderDefinition.MatchesProviderJson(current.ProviderJson!, install)) || + (current.PrimaryModelExisted && + !currentPrimaryIsManaged && + !currentPrimaryIsFallback && + currentPrimary is null)) + { + throw new InvalidDataException( + "Local AI gateway settings changed after setup; preserving them instead of removing unproven values."); + } + + if (currentPrimaryIsManaged && fallbackModel is not null) + { + string restorePrimary = JsonSerializer.Serialize(new[] + { + new { path = LocalAiGatewayConfigBuilder.PrimaryModelPath, value = fallbackModel }, + }); + CommandResult restored = await ApplyBatchAsync( + ctx, restorePrimary, "LOCAL_AI_PRIMARY_RESTORED", ct).ConfigureAwait(false); + if (restored.ExitCode != 0 || restored.TimedOut) + throw new IOException("Restoring the prior gateway primary model failed during uninstall."); + } + + var unset = new List(capacity: 2); + if (currentPrimaryIsManaged && fallbackModel is null) + unset.Add($"openclaw config unset {LocalAiGatewayConfigBuilder.PrimaryModelPath}"); + if (current.ProviderExisted) + unset.Add($"openclaw config unset {LocalAiGatewayConfigBuilder.ProviderPath}"); + + if (unset.Count > 0) + { + string script = $"set -e\n{ctx.WslPathPrefix}\n{string.Join("\n", unset)}\necho LOCAL_AI_GATEWAY_UNSET"; + CommandResult result = await ctx.Commands.RunInWslAsync( + ctx.DistroName!, script, TimeSpan.FromMinutes(2), ct: ct, + user: ctx.Config.Wsl.User, inputViaStdin: true); + if (result.ExitCode != 0 || result.TimedOut || + !result.Stdout.Contains("LOCAL_AI_GATEWAY_UNSET", StringComparison.Ordinal)) + { + throw new IOException("Removing the managed Local AI gateway settings failed."); + } + } + + CommandResult verifiedResult = await CaptureStateAsync(ctx, ct).ConfigureAwait(false); + if (verifiedResult.ExitCode != 0 || verifiedResult.TimedOut) + throw new IOException("Could not verify Local AI gateway removal during uninstall."); + LocalAiGatewayPriorState verified = ParseSnapshot(verifiedResult.Stdout); + bool primaryIsSafe = fallbackModel is not null + ? verified.PrimaryModelExisted && + JsonEquals(verified.PrimaryModelJson!, JsonSerializer.Serialize(fallbackModel)) + : currentPrimary is not null + ? verified.PrimaryModelExisted && + JsonEquals(verified.PrimaryModelJson!, JsonSerializer.Serialize(currentPrimary)) + : !verified.PrimaryModelExisted; + if (verified.ProviderExisted || !primaryIsSafe) + throw new IOException("Managed Local AI gateway settings remained after uninstall cleanup."); + } + + private static Task CaptureStateAsync(SetupContext ctx, CancellationToken ct) + { + string script = $$""" + set -eu + {{ctx.WslPathPrefix}} + capture_value() { + key="$1" + marker="$2" + temp_file="$(mktemp)" + error_file="$(mktemp)" + if openclaw config get "$key" --json >"$temp_file" 2>"$error_file"; then + printf '%s%s\n' "$marker" "$(base64 -w0 <"$temp_file")" + elif grep -Fq "Config path not found: $key" "$error_file"; then + printf '%s{{MissingValue}}\n' "$marker" + else + cat "$error_file" >&2 + rm -f "$temp_file" "$error_file" + return 1 + fi + rm -f "$temp_file" "$error_file" + } + capture_value '{{LocalAiGatewayConfigBuilder.ProviderPath}}' '{{ProviderMarker}}' + capture_value '{{LocalAiGatewayConfigBuilder.PrimaryModelPath}}' '{{PrimaryMarker}}' + """; + return ctx.Commands.RunInWslAsync( + ctx.DistroName!, script, TimeSpan.FromMinutes(1), ct: ct, + user: ctx.Config.Wsl.User, inputViaStdin: true); + } + + private static Task ApplyBatchAsync( + SetupContext ctx, + string batchJson, + string successMarker, + CancellationToken ct) + { + var environment = new Dictionary + { + [BatchVariable] = Convert.ToBase64String(Encoding.UTF8.GetBytes(batchJson)), + }; + string script = $$""" + set -e + {{ctx.WslPathPrefix}} + batch_file="$(mktemp)" + trap 'rm -f "$batch_file"' EXIT + printf '%s' "$OPENCLAW_LOCAL_AI_BATCH_B64" | base64 -d > "$batch_file" + openclaw config set --batch-file "$batch_file" --dry-run + openclaw config set --batch-file "$batch_file" + echo {{successMarker}} + """; + return ctx.Commands.RunInWslAsync( + ctx.DistroName!, script, TimeSpan.FromMinutes(2), environment, ct, + user: ctx.Config.Wsl.User, inputViaStdin: true); + } + + private static LocalAiGatewayPriorState ParseSnapshot(string stdout) + { + (bool providerExists, string? provider) = ParseMarker(stdout, ProviderMarker); + (bool primaryExists, string? primary) = ParseMarker(stdout, PrimaryMarker); + return new(providerExists, provider, primaryExists, primary); + } + + private static (bool Exists, string? Json) ParseMarker(string stdout, string marker) + { + string? value = stdout.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .SingleOrDefault(line => line.StartsWith(marker, StringComparison.Ordinal))?[marker.Length..]; + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidDataException($"Missing configuration marker '{marker}'."); + if (string.Equals(value, MissingValue, StringComparison.Ordinal)) + return (false, null); + if (value.Length > MaximumSnapshotBytes * 2) + throw new InvalidDataException("The configuration snapshot is too large."); + + byte[] bytes = Convert.FromBase64String(value); + if (bytes.Length > MaximumSnapshotBytes) + throw new InvalidDataException("The configuration snapshot is too large."); + string json = Encoding.UTF8.GetString(bytes); + using JsonDocument _ = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 32 }); + return (true, json); + } + + private static string ExtractOperationValue(string batchJson, int index) + { + using JsonDocument document = JsonDocument.Parse(batchJson); + return document.RootElement[index].GetProperty("value").GetRawText(); + } + + private static bool JsonEquals(string left, string right) + { + using JsonDocument leftDocument = JsonDocument.Parse(left); + using JsonDocument rightDocument = JsonDocument.Parse(right); + return JsonElement.DeepEquals(leftDocument.RootElement, rightDocument.RootElement); + } +} diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 9a30951df..80ea4f36c 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -586,7 +586,7 @@ private static async Task TryRevokeOperatorTokenAsync(SetupContext ctx, Cancella if (string.IsNullOrWhiteSpace(token)) return; - var gatewayUrl = ctx.GatewayUrl ?? "ws://localhost:18789"; + var gatewayUrl = ctx.GatewayUrl ?? "ws://127.0.0.1:18789"; var httpBase = gatewayUrl .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase) .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase) diff --git a/src/OpenClaw.SetupEngine/RunGatewayWizardStep.cs b/src/OpenClaw.SetupEngine/RunGatewayWizardStep.cs index 1ebd5a609..8ab7a5bba 100644 --- a/src/OpenClaw.SetupEngine/RunGatewayWizardStep.cs +++ b/src/OpenClaw.SetupEngine/RunGatewayWizardStep.cs @@ -16,7 +16,7 @@ public sealed class RunGatewayWizardStep : SetupStep public override string DisplayName => "Run gateway wizard"; public override bool CanRetry => false; - public override bool CanSkip(SetupContext ctx) => ctx.Config.SkipWizard; + public override bool CanSkip(SetupContext ctx) => ctx.Config.SkipWizard || ctx.Config.LocalAi.Enabled; public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index 3c11adc8e..81aa1c29f 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -43,7 +43,7 @@ public sealed class SetupConfig public TailscaleConfig Tailscale { get; set; } = new(); public LocalAiConfig LocalAi { get; set; } = new(); - public string EffectiveGatewayUrl => GatewayUrl ?? $"ws://localhost:{GatewayPort}"; + public string EffectiveGatewayUrl => GatewayUrl ?? $"ws://127.0.0.1:{GatewayPort}"; public static SetupConfig LoadFromFile(string path) { diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index c26c6114e..5d8fa752b 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -247,11 +247,7 @@ public static async Task VerifyAsync(SetupContext ctx, string pairin try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var gatewayUri = new Uri(ctx.GatewayUrl!); - var scheme = gatewayUri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) - ? Uri.UriSchemeHttps - : Uri.UriSchemeHttp; - var healthUri = new UriBuilder(gatewayUri) { Scheme = scheme, Port = gatewayUri.Port }.Uri; + var healthUri = BuildHealthUri(ctx.GatewayUrl!); var resp = await http.GetAsync(healthUri, ct); ctx.Logger.Debug($"Gateway health check: HTTP {(int)resp.StatusCode}"); return StepResult.Ok(); @@ -265,4 +261,13 @@ public static async Task VerifyAsync(SetupContext ctx, string pairin return StepResult.Fail($"Gateway not reachable before {pairingRole} pairing: {ex.Message}"); } } + + internal static Uri BuildHealthUri(string gatewayUrl) + { + var gatewayUri = new Uri(gatewayUrl); + var scheme = gatewayUri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) + ? Uri.UriSchemeHttps + : Uri.UriSchemeHttp; + return new UriBuilder(gatewayUri) { Scheme = scheme, Port = gatewayUri.Port }.Uri; + } } diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index bc6d3a5dc..2d4cacfe5 100644 --- a/src/OpenClaw.SetupEngine/default-config.json +++ b/src/OpenClaw.SetupEngine/default-config.json @@ -36,7 +36,7 @@ "LogLevel": "trace", // Override log file path (null = %APPDATA%\OpenClawTray\Logs\Setup\) "LogPath": null, - // Override gateway WebSocket URL (null = ws://localhost:{GatewayPort}) + // Override gateway WebSocket URL (null = ws://127.0.0.1:{GatewayPort}) "GatewayUrl": null, // Optional tailnet-only HTTPS/WSS endpoint for the generated WSL gateway. "Tailscale": { diff --git a/src/OpenClaw.Tray.WinUI/AppIdentity.cs b/src/OpenClaw.Tray.WinUI/AppIdentity.cs index b60eeefbf..f009f5374 100644 --- a/src/OpenClaw.Tray.WinUI/AppIdentity.cs +++ b/src/OpenClaw.Tray.WinUI/AppIdentity.cs @@ -40,8 +40,8 @@ internal static class AppIdentity /// Loopback gateway port used by embedded setup. public const int SetupGatewayPort = 18790; - /// Default gateway URL for this app variant. - public const string SetupGatewayUrl = "ws://localhost:18790"; + /// Explicit IPv4 loopback gateway URL used by embedded setup and post-setup startup. + public const string SetupGatewayUrl = "ws://127.0.0.1:18790"; /// Whether this is a development build. public static bool IsDev => true; @@ -79,8 +79,8 @@ internal static class AppIdentity /// Loopback gateway port used by embedded setup. public const int SetupGatewayPort = 18789; - /// Default gateway URL for this app variant. - public const string SetupGatewayUrl = "ws://localhost:18789"; + /// Explicit IPv4 loopback gateway URL used by embedded setup and post-setup startup. + public const string SetupGatewayUrl = "ws://127.0.0.1:18789"; /// Whether this is a development build. public static bool IsDev => false; diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs index 71936584c..1e42caee2 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs @@ -234,7 +234,7 @@ private void LoadGatewaySection(SettingsManager settings) var activeGatewayAccess = GatewayHostAccessClassifier.Classify(CurrentApp.Registry?.GetActive()); _localGatewayInstalled = File.Exists(setupStatePath) - || (settings.GatewayUrl?.StartsWith("ws://localhost", StringComparison.OrdinalIgnoreCase) == true); + || LocalGatewayUrlClassifier.IsLocalGatewayUrl(settings.GatewayUrl); OpenClawOnboardCard.Visibility = activeGatewayAccess.CanControlWslGateway ? Visibility.Visible : Visibility.Collapsed; diff --git a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs index a9a6e25d4..e13e7bd9b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs +++ b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs @@ -91,12 +91,20 @@ internal static bool HasAnyConfiguredGatewayTarget(SettingsManager settings) return HasNonDefaultGatewayUrl(settings); } - private static bool HasNonDefaultGatewayUrl(SettingsManager settings) => - !string.IsNullOrWhiteSpace(settings.GatewayUrl) - && !string.Equals( - settings.GatewayUrl, - AppIdentity.SetupGatewayUrl, - StringComparison.OrdinalIgnoreCase); + private static bool HasNonDefaultGatewayUrl(SettingsManager settings) + { + if (string.IsNullOrWhiteSpace(settings.GatewayUrl)) + return false; + + return !string.Equals( + settings.GatewayUrl, + AppIdentity.SetupGatewayUrl, + StringComparison.OrdinalIgnoreCase) + && !string.Equals( + settings.GatewayUrl, + $"ws://localhost:{AppIdentity.SetupGatewayPort}", + StringComparison.OrdinalIgnoreCase); + } public static bool CanStartNodeGateway(SettingsManager settings, string dataPath) { diff --git a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs index 55d5607c2..51c64f027 100644 --- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs @@ -151,7 +151,7 @@ public async Task FullSetup_WslAndGatewayConfiguration_FilesValidated() Assert.Equal(expectedCommands, effectiveCommands.Order(StringComparer.OrdinalIgnoreCase).ToArray()); var gateway = _fixture.ReadActiveGatewayRecord(); - Assert.Equal($"ws://localhost:{_fixture.GatewayPort}", gateway.GatewayUrl); + Assert.Equal($"ws://127.0.0.1:{_fixture.GatewayPort}", gateway.GatewayUrl); var settingsPath = Path.Combine(_fixture.DataDir, "settings.json"); var gatewaysPath = Path.Combine(_fixture.DataDir, "gateways.json"); diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiGatewayUninstallTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiGatewayUninstallTests.cs new file mode 100644 index 000000000..b1fdb81f7 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiGatewayUninstallTests.cs @@ -0,0 +1,256 @@ +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference.Catalog; +using OpenClaw.TestSupport; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class LocalAiGatewayUninstallTests +{ + [Fact] + public async Task FreshProcessUninstall_RemovesExactManagedProviderAndPrimary() + { + using var temp = new TempDirectory("local-ai-gateway-uninstall-"); + LocalAiResolvedInstall install = await SaveManifestAsync(temp.Path); + string provider = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + string primary = JsonSerializer.Serialize( + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var commands = new GatewayStateCommandRunner(provider, primary); + SetupContext context = CreateContext(temp.Path, commands); + context.IsUninstalling = true; + + await new ConfigureLocalAiGatewayStep().RollbackAsync(context, CancellationToken.None); + + Assert.Null(commands.ProviderJson); + Assert.Null(commands.PrimaryJson); + Assert.Contains(commands.WslCalls, command => + command.Contains("LOCAL_AI_GATEWAY_UNSET", StringComparison.Ordinal)); + } + + [Fact] + public async Task FreshProcessUninstall_AcceptsCliRedactedManagedApiKey() + { + using var temp = new TempDirectory("local-ai-gateway-uninstall-"); + LocalAiResolvedInstall install = await SaveManifestAsync(temp.Path); + string provider = LocalAiGatewayProviderDefinition.BuildProviderJson(install).Replace( + "\"api\":\"openai-completions\",\"apiKey\":\"llama-local\"", + $"\"apiKey\":\"{LocalAiGatewayProviderDefinition.CliRedactedApiKey}\",\"api\":\"openai-completions\"", + StringComparison.Ordinal); + string primary = JsonSerializer.Serialize( + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var commands = new GatewayStateCommandRunner(provider, primary); + SetupContext context = CreateContext(temp.Path, commands); + context.IsUninstalling = true; + + await new ConfigureLocalAiGatewayStep().RollbackAsync(context, CancellationToken.None); + + Assert.Null(commands.ProviderJson); + Assert.Null(commands.PrimaryJson); + } + + [Fact] + public async Task FreshProcessUninstall_RestoresRecordedFallbackPrimary() + { + using var temp = new TempDirectory("local-ai-gateway-uninstall-"); + LocalAiResolvedInstall install = await SaveManifestAsync(temp.Path, "openai/gpt-5"); + string provider = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + string primary = JsonSerializer.Serialize( + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var commands = new GatewayStateCommandRunner(provider, primary); + SetupContext context = CreateContext(temp.Path, commands); + context.IsUninstalling = true; + + await new ConfigureLocalAiGatewayStep().RollbackAsync(context, CancellationToken.None); + + Assert.Null(commands.ProviderJson); + Assert.Equal(JsonSerializer.Serialize("openai/gpt-5"), commands.PrimaryJson); + Assert.Contains(commands.WslCalls, command => + command.Contains("LOCAL_AI_PRIMARY_RESTORED", StringComparison.Ordinal)); + } + + [Fact] + public async Task FreshProcessUninstall_PreservesDriftAndFailsClosed() + { + using var temp = new TempDirectory("local-ai-gateway-uninstall-"); + LocalAiResolvedInstall install = await SaveManifestAsync(temp.Path); + string expectedProvider = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + string driftedProvider = expectedProvider.Replace( + "http://127.0.0.1:28765/v1", + "http://127.0.0.1:39876/v1", + StringComparison.Ordinal); + string primary = JsonSerializer.Serialize( + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var commands = new GatewayStateCommandRunner(driftedProvider, primary); + SetupContext context = CreateContext(temp.Path, commands); + context.IsUninstalling = true; + + InvalidDataException error = await Assert.ThrowsAsync(() => + new ConfigureLocalAiGatewayStep().RollbackAsync(context, CancellationToken.None)); + + Assert.Contains("preserving", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(driftedProvider, commands.ProviderJson); + Assert.Equal(primary, commands.PrimaryJson); + Assert.DoesNotContain(commands.WslCalls, command => + command.Contains("LOCAL_AI_GATEWAY_UNSET", StringComparison.Ordinal)); + } + + [Fact] + public async Task FreshProcessUninstall_PreservesStateWhenSnapshotFails() + { + using var temp = new TempDirectory("local-ai-gateway-uninstall-"); + LocalAiResolvedInstall install = await SaveManifestAsync(temp.Path); + string provider = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + string primary = JsonSerializer.Serialize( + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var commands = new GatewayStateCommandRunner(provider, primary) { FailCapture = true }; + SetupContext context = CreateContext(temp.Path, commands); + context.IsUninstalling = true; + + await Assert.ThrowsAsync(() => + new ConfigureLocalAiGatewayStep().RollbackAsync(context, CancellationToken.None)); + + Assert.Equal(provider, commands.ProviderJson); + Assert.Equal(primary, commands.PrimaryJson); + Assert.DoesNotContain(commands.WslCalls, command => + command.Contains("LOCAL_AI_GATEWAY_UNSET", StringComparison.Ordinal)); + } + + private static SetupContext CreateContext(string localDataDirectory, ICommandRunner commands) + { + var config = new SetupConfig(); + var logger = new SetupLogger(filePath: null); + return new SetupContext( + config, + logger, + new TransactionJournal(filePath: null), + commands, + CancellationToken.None, + localDataDir: localDataDirectory); + } + + private static async Task SaveManifestAsync( + string localDataDirectory, + string? fallbackModel = null) + { + var paths = new LocalAiPaths(localDataDirectory); + const string revision = "5bc3e238d916f48a861bac2f8a1990a0e9b7e98d"; + var manifest = new LocalAiInstallManifest + { + EngineVersion = "b10488", + Architecture = "arm64", + HardwareProfileId = "rtx-spark-n1x", + RuntimeId = "b10488-cuda13-arm64", + ModelCatalogId = LocalModelCatalog.Qwen35BModelId, + SelectedGpuId = "GPU-SPARK", + ExecutablePath = Path.Combine("engines", "llama-b10488", "llama-server.exe"), + RuntimeAssets = + [ + new LocalAiAssetReceipt + { + FileName = "llama-runtime.zip", + SourceUrl = "https://github.com/ggml-org/llama.cpp/releases/download/b10488/llama-runtime.zip", + SizeBytes = 1, + Sha256 = new string('a', 64), + }, + ], + ModelPath = Path.Combine("models", "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"), + ModelId = $"unsloth/Qwen3.6-35B-A3B-MTP-GGUF@{revision}", + ModelAlias = LocalModelCatalog.Qwen35BModelId, + ModelAsset = new LocalAiAssetReceipt + { + FileName = "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", + SourceUrl = $"https://huggingface.co/unsloth/Qwen3.6-35B-A3B-MTP-GGUF/resolve/{revision}/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf?download=true", + SizeBytes = 1, + Sha256 = new string('b', 64), + }, + RequestedPort = 0, + Endpoint = "http://127.0.0.1:28765/v1", + GatewayFallbackModel = fallbackModel, + ContextLength = LocalModelCatalog.NativeContextTokens, + }; + var store = new LocalAiManifestStore(paths); + await store.SaveAsync(manifest); + return (await store.LoadAsync())!; + } + + private sealed class GatewayStateCommandRunner( + string? providerJson, + string? primaryJson) : ICommandRunner + { + private const string ProviderMarker = "OPENCLAW_LOCAL_AI_PROVIDER_B64="; + private const string PrimaryMarker = "OPENCLAW_LOCAL_AI_PRIMARY_B64="; + + public string? ProviderJson { get; private set; } = providerJson; + public string? PrimaryJson { get; private set; } = primaryJson; + public bool FailCapture { get; init; } + public List WslCalls { get; } = []; + + public Task RunAsync( + string executable, + string[] arguments, + TimeSpan timeout, + IReadOnlyDictionary? environment = null, + string? workingDirectory = null, + string? stdinInput = null, + CancellationToken ct = default, + Stream? stdinStream = null) => throw new NotSupportedException(); + + public Task RunInWslAsync( + string distroName, + string command, + TimeSpan timeout, + IReadOnlyDictionary? environment = null, + CancellationToken ct = default, + string? user = null, + bool inputViaStdin = false) + { + ct.ThrowIfCancellationRequested(); + WslCalls.Add(command); + if (command.Contains("LOCAL_AI_PRIMARY_RESTORED", StringComparison.Ordinal)) + { + string encoded = Assert.Single(environment!).Value; + string batch = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); + using JsonDocument document = JsonDocument.Parse(batch); + PrimaryJson = document.RootElement[0].GetProperty("value").GetRawText(); + return Task.FromResult(new CommandResult( + 0, + "LOCAL_AI_PRIMARY_RESTORED", + "", + TimeSpan.Zero, + TimedOut: false)); + } + if (command.Contains("LOCAL_AI_GATEWAY_UNSET", StringComparison.Ordinal)) + { + if (command.Contains(LocalAiGatewayProviderDefinition.PrimaryModelPath, StringComparison.Ordinal)) + PrimaryJson = null; + if (command.Contains(LocalAiGatewayProviderDefinition.ProviderPath, StringComparison.Ordinal)) + ProviderJson = null; + return Task.FromResult(new CommandResult( + 0, + "LOCAL_AI_GATEWAY_UNSET", + "", + TimeSpan.Zero, + TimedOut: false)); + } + if (FailCapture) + { + return Task.FromResult(new CommandResult( + 1, + "", + "openclaw config get failed", + TimeSpan.Zero, + TimedOut: false)); + } + + string stdout = + ProviderMarker + EncodeOrMissing(ProviderJson) + Environment.NewLine + + PrimaryMarker + EncodeOrMissing(PrimaryJson) + Environment.NewLine; + return Task.FromResult(new CommandResult(0, stdout, "", TimeSpan.Zero, TimedOut: false)); + } + + private static string EncodeOrMissing(string? value) => value is null + ? "MISSING" + : Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index 7dcfd9305..89b07d062 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -106,7 +106,7 @@ public void ApplyUiDefaults_AllowsRollbackOptOut() public void EffectiveGatewayUrl_UsesPort() { var config = new SetupConfig { GatewayPort = 9999 }; - Assert.Equal("ws://localhost:9999", config.EffectiveGatewayUrl); + Assert.Equal("ws://127.0.0.1:9999", config.EffectiveGatewayUrl); } [Fact] diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupContextTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupContextTests.cs index 5f331a52b..bf7301433 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupContextTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupContextTests.cs @@ -132,7 +132,7 @@ public void GatewayUrl_DefaultsFromConfig() { var config = new SetupConfig { GatewayPort = 5555 }; var ctx = CreateContext(config); - Assert.Equal("ws://localhost:5555", ctx.GatewayUrl); + Assert.Equal("ws://127.0.0.1:5555", ctx.GatewayUrl); } private static SetupContext CreateContext(SetupConfig? config = null) diff --git a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs index e4b8cbdf2..2933c3c46 100644 --- a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs @@ -578,14 +578,18 @@ public void RequiresSetup_ReturnsFalse_WhenMcpEnabledEvenWithNodeModeAndNoNodeTo Assert.False(StartupSetupState.RequiresSetup(settings, temp.Path)); } - [Fact] - public void DefaultGatewayUrl_IsLocalhost18789() + [Theory] + [InlineData("ws://127.0.0.1:18789")] + [InlineData("ws://localhost:18789")] + public void DefaultAndLegacyGatewayUrls_DoNotBypassSetup(string gatewayUrl) { - // StartupSetupState uses "ws://localhost:18789" as the default gateway URL. - // A non-default URL indicates the user has configured an external gateway. - // This test guards against accidentally changing the constant. - var settings = new SettingsManager(Path.GetTempPath()) { GatewayUrl = "ws://localhost:18789" }; - Assert.True(StartupSetupState.RequiresSetup(settings, Path.GetTempPath())); + // Setup uses explicit IPv4 to avoid localhost resolving to IPv6 across + // mirrored WSL networking. Preserve the older localhost value only as + // a settings-migration alias, not as the runtime connection target. + using var temp = TempSettings.Create(); + var settings = new SettingsManager(temp.Path) { GatewayUrl = gatewayUrl }; + + Assert.True(StartupSetupState.RequiresSetup(settings, temp.Path)); } private static void StoreDeviceToken(string dataPath) From 3fa1d6f3c29cb0ddeb712ec1066f3883ead5d6a3 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:53:35 -0700 Subject: [PATCH 15/23] feat(local-ai): own llama router in companion lifecycle Bind Local AI to the singleton setup-managed distro resolved from the loaded gateway registry. Quiesce exact provider state before transitions, then publish only healthy owned endpoints and compensate failures. Signed-off-by: Joel Fernandes --- docs/ARCHITECTURE.md | 2 + .../LocalAi/LlamaServerRuntimeService.cs | 49 ++- .../App.AppShutdownCoordinator.cs | 19 + src/OpenClaw.Tray.WinUI/App.xaml.cs | 63 ++- .../Presentation/AppServiceContext.cs | 6 +- .../Presentation/AppServiceRegistration.cs | 2 + .../Services/LocalAiGatewayDistroResolver.cs | 97 +++++ .../LocalAiGatewayProviderCoordinator.cs | 339 +++++++++++++++ .../LocalAiGatewayProviderCoordinatorTests.cs | 411 ++++++++++++++++++ .../OpenClaw.Tray.Tests.csproj | 2 + 10 files changed, 967 insertions(+), 23 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayDistroResolver.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs create mode 100644 tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4e0445a94..0bf6da409 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,6 +66,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Settings page load/persist view logic | `SettingsPageViewModel` | authoritative | | Native tool identity, display arguments, payload extraction, and flattened-history projection | `NativeToolProjector` | authoritative | | Managed-local listener provenance and strong-credential authorization | `ManagedLocalGatewayPortProvenanceService` | authoritative | +| Local AI gateway-record ownership and WSL distro binding | `LocalAiGatewayDistroResolver` | authoritative | | Exact Gateway wizard terminal-restart compatibility and bounded retry policy | `GatewayWizardRestartRecoveryPolicy` | authoritative | | Managed-local automatic repair eligibility and orchestration | `ManagedLocalGatewayAutoRepairMonitor` + `ManagedLocalGatewayRepairCoordinator` | authoritative | | Permissions page state, settings commands, and exec-approvals presentation | `PermissionsPageViewModel` | authoritative | @@ -160,6 +161,7 @@ leading and trailing pipe. Columns, in order: | setup-keepalive-process-manager | authoritative | src/OpenClaw.SetupEngine/SetupSteps.cs (StartKeepaliveStep) | setup-time WSL keepalive process discovery, start, marker read/write, command-line identity, and rollback cleanup | KeepaliveProcessManager (raw OS calls delegated to internal IKeepaliveProcessRuntime seam; StartKeepaliveStep is the only caller that reads SetupContext) | StartKeepaliveStep keeps Id/DisplayName and thin ExecuteAsync/RollbackAsync orchestration only | setup-time keepalive never hard-fails the pipeline on start failure (null PID or thrown exception both soft-fail identically); its marker path/JSON are the intentional handoff consumed by the tray keepalive service; rollback kills only wsl/wsl.exe processes whose command line matches this distro via WslCommandLineMatcher, leaves wrong-distro/unmatched command lines untouched, and deletes only its own marker/empty directory | KeepaliveProcessManagerTests.RollbackAsync_KillsOnlyMatchingDistroProcesses_LeavesOthersUntouched | behavioral | when StartKeepaliveStep contains no process/marker logic of its own | | wsl-distro-install-path | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | inline Path.Combine wsl distro install-path derivation | DistroInstallPathPolicy | - | new installs use the strict supported name grammar; teardown accepts only unambiguous single-segment names whose canonical path is an immediate child of LocalDataDir\wsl with no aliases, case or Unicode collisions, or reparse points at the root or child | SetupStepsTests.DistroInstallPathPolicy_ResolvesImmediateChild | behavioral | - | | managed-local-provenance | authoritative | scattered connection, setup, browser, and reconnect call sites | implicit loopback trust and duplicated strong-credential listener checks | ManagedLocalGatewayPortProvenanceService | callers request inspection, authorization, or conflict repair only | unknown, incomplete, conflicting, or changed Windows listener ownership never receives strong credentials or destructive remediation; relayless ownership requires a complete empty Windows snapshot, expected-distro systemd MainPID proof, and immediate complete empty revalidation | ManagedLocalGatewayPortProvenanceServiceTests.InteractiveCredentialGate_ExpectedCacheThenOwnerChanges_FailsClosed | behavioral | - | +| local-ai-gateway-distro-binding | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | hardcoded Local AI WSL distro selection | LocalAiGatewayDistroResolver | App loads the gateway registry and composes the resolver, provider coordinator, and runtime | the singleton Local AI installation binds to exactly one explicit setup-managed local no-SSH gateway record; its record ID and SetupManagedDistroName are pinned and revalidated before every WSL command, while missing, ambiguous, unavailable, or drifted ownership fails closed | LocalAiGatewayProviderCoordinatorTests.Quiesce_OwnerDriftsAfterInspection_BlocksFirstMutation | behavioral | - | | gateway-wizard-restart-recovery | authoritative | WizardPage + SetupWizardRunner reconnect call sites | duplicated exact-version terminal-restart classification and bounded provenance retry orchestration | GatewayWizardRestartRecoveryPolicy | WizardPage and SetupWizardRunner apply hosted and headless lifecycle and consume provenance inspection results | only managed-local restart-like disconnects may retry NoListener or the typed snapshot-changed race; other unknown or conflicting ownership fails immediately, retryable startup close 1013 stays inside the existing reconnect bound, and exact Gateway 2026.7.1 final model-check close 1012 completes only after a fresh hello-ok, and a terminal hosted-wizard payload completes on the exact TUI SIGTERM termination only when the request just sent answered the authoritative final done acknowledgement step | GatewayWizardRestartRecoveryPolicyTests.Exact2026_7_1TerminalModelCheckServiceRestart_IsExpected | behavioral | when the 2026.7.1 terminal-restart compatibility path is removed | | managed-local-repair | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs and direct reconnect callbacks | repair eligibility, restart budgets, port remediation, and reconnect verification | ManagedLocalGatewayAutoRepairMonitor + ManagedLocalGatewayRepairCoordinator | App composition and dependency callbacks only | explicit disconnect and gateway switches abort repair before restart or reconnect | ManagedLocalGatewayRepairCoordinatorTests.UserDisconnectedIntent_AbortsBeforeProbeOrRestart | behavioral | - | | app-managed-local-repair-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | managed-local repair loops, probing, restart budgeting, and verification implementation | ManagedLocalGatewayAutoRepairMonitor + ManagedLocalGatewayRepairCoordinator | service construction, callback adapters, and lifetime wiring only | App remains the composition root and does not regain repair implementation | AppRefactorContractTests.ManagedLocalGatewayRepair_StaysDelegatedToDedicatedOwners | source-shape | when App no longer constructs the managed-local repair services directly | diff --git a/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs b/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs index 66470ce47..8e8b3343c 100644 --- a/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs +++ b/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs @@ -165,6 +165,17 @@ private async Task EnsureStartedCoreAsync(CancellationTo if (_managedProcess is not null) await DisposeManagedProcessAsync(CancellationToken.None).ConfigureAwait(false); + LocalAiEndpointLifecycleResult quiesced = await _options.EndpointLifecycle + .QuiesceAsync(install, cancellationToken) + .ConfigureAwait(false); + if (!quiesced.Success) + { + return Publish( + LocalAiRuntimeState.Failed, + LocalAiOwnership.None, + quiesced.Detail ?? "The Local AI gateway provider could not be safely disabled."); + } + LlamaServerRouterLaunchPlan launchPlan; try { @@ -186,22 +197,11 @@ private async Task EnsureStartedCoreAsync(CancellationTo if (!beforeStart.Ipv4Complete) return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "TCP listener ownership could not be determined."); if (install.Manifest.RequestedPort != LocalAiPortPolicy.Automatic && - FindLoopbackListeners(beforeStart, install.Manifest.RequestedPort).Count > 0) + FindEndpointListeners(beforeStart, install.Manifest.RequestedPort).Count > 0) { return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "The configured llama-server port is already in use."); } - LocalAiEndpointLifecycleResult quiesced = await _options.EndpointLifecycle - .QuiesceAsync(install, cancellationToken) - .ConfigureAwait(false); - if (!quiesced.Success) - { - return Publish( - LocalAiRuntimeState.Failed, - LocalAiOwnership.None, - quiesced.Detail ?? "The Local AI gateway provider could not be safely disabled."); - } - long generation = ++_generation; Publish(LocalAiRuntimeState.Starting, LocalAiOwnership.CompanionManaged, "Starting the local AI router."); var spec = new LocalAiProcessStartSpec( @@ -315,7 +315,7 @@ private async Task RefreshCoreAsync(CancellationToken ca WindowsTcpListenerSnapshotResult snapshot = _platform.CaptureListeners(); if (!snapshot.Ipv4Complete) return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "TCP listener ownership could not be determined."); - if (FindLoopbackListeners(snapshot, persistedEndpoint.Port).Count > 0) + if (FindEndpointListeners(snapshot, persistedEndpoint.Port).Count > 0) { return Publish( LocalAiRuntimeState.Conflict, @@ -578,6 +578,16 @@ private EndpointOwnershipObservation DiscoverOwnedEndpoint( WindowsTcpListenerInfo[] loopbackListeners = snapshot.Listeners .Where(IsIpv4LoopbackListener) .ToArray(); + if (snapshot.Listeners.Any(listener => + listener.ProcessId == process.ProcessId && + listener.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && + !IsIpv4LoopbackListener(listener))) + { + return new( + true, + null, + "Managed llama-server opened an IPv4 listener outside the loopback interface."); + } WindowsTcpListenerInfo[] processListeners = loopbackListeners .Where(listener => listener.ProcessId == process.ProcessId) .ToArray(); @@ -595,7 +605,7 @@ private EndpointOwnershipObservation DiscoverOwnedEndpoint( int requestedPort = install.Manifest.RequestedPort; if (requestedPort != LocalAiPortPolicy.Automatic) { - IReadOnlyList requestedListeners = FindLoopbackListeners(snapshot, requestedPort); + IReadOnlyList requestedListeners = FindEndpointListeners(snapshot, requestedPort); if (requestedListeners.Any(listener => !IsManagedListener(listener, process))) return new(true, null, "Another process owns the configured llama-server endpoint."); if (ownedListeners.Any(listener => listener.Port != requestedPort)) @@ -612,19 +622,22 @@ private EndpointOwnershipObservation DiscoverOwnedEndpoint( return new(true, null, "Managed llama-server opened more than one candidate loopback endpoint."); int selectedPort = ownedPorts[0]; - if (FindLoopbackListeners(snapshot, selectedPort).Any(listener => !IsManagedListener(listener, process))) + if (FindEndpointListeners(snapshot, selectedPort).Any(listener => !IsManagedListener(listener, process))) return new(true, null, "Another process shares the managed llama-server endpoint."); return new(true, BuildEndpoint(selectedPort), null); } - private static IReadOnlyList FindLoopbackListeners( + private static IReadOnlyList FindEndpointListeners( WindowsTcpListenerSnapshotResult snapshot, int port) => snapshot.Listeners - .Where(listener => listener.Port == port && IsIpv4LoopbackListener(listener)) + .Where(listener => listener.Port == port && IsIpv4EndpointListener(listener)) .ToArray(); + private static bool IsIpv4EndpointListener(WindowsTcpListenerInfo listener) => + IsIpv4LoopbackListener(listener) || listener.Address.Equals(IPAddress.Any); + private static bool IsIpv4LoopbackListener(WindowsTcpListenerInfo listener) => - listener.Address.Equals(IPAddress.Loopback) || listener.Address.Equals(IPAddress.Any); + listener.Address.Equals(IPAddress.Loopback); private static Uri BuildEndpoint(int port) => new UriBuilder(Uri.UriSchemeHttp, "127.0.0.1", port, "/v1").Uri; diff --git a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs index 9f35e1fc6..c5ad8bb28 100644 --- a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs @@ -97,6 +97,25 @@ private AppShutdownPlan BuildShutdownPlan() })); } + // The gateway and chat are consumers of local inference, so stop them first. + // App owns this pre-built runtime instance; the DI provider must not dispose it. + var localAiRuntime = _localAiRuntime; + if (localAiRuntime is not null) + { + steps.Add(new AppShutdownStep("local AI runtime", async () => + { + try + { + await localAiRuntime.DisposeAsync(); + } + finally + { + if (ReferenceEquals(_localAiRuntime, localAiRuntime)) + _localAiRuntime = null; + } + })); + } + steps.Add(new AppShutdownStep("OpenTelemetry endpoint", () => { _openTelemetryConnection?.Dispose(); diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index a57b2c8c1..01fc05e50 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -15,6 +15,7 @@ using OpenClawTray.Services; using OpenClawTray.Windows; using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; using Microsoft.Extensions.DependencyInjection; using OpenClawTray.Presentation; using OpenClawTray.Presentation.Adapters; @@ -56,6 +57,7 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands, IPer private OpenClawTray.Services.ManagedLocalGatewayAutoRepairMonitor? _managedLocalAutoRepairMonitor; private ManagedLocalGatewayPortProvenanceService? _managedLocalPortProvenance; private OpenClawTray.Chat.OpenClawChatCoordinator? _chatCoordinator; + private ILocalAiRuntime? _localAiRuntime; /// /// Root DI composition root, built once during startup and disposed during @@ -457,7 +459,13 @@ private void InitializeServiceProvider() } var dispatcher = new WinUIDispatcher(_dispatcherQueue); - var context = new AppServiceContext(dispatcher, this, _settings, ExecApprovalsStore, this); + var context = new AppServiceContext( + dispatcher, + this, + _settings, + ExecApprovalsStore, + this, + _localAiRuntime); var services = new ServiceCollection(); services.AddOpenClawTrayCore(context); @@ -717,17 +725,35 @@ _dispatcherQueue is null InitializeTrayIcon(); ShowSurfaceImprovementsTipIfNeeded(); + // The singleton Local AI installation belongs to exactly one explicit + // setup-managed local WSL gateway. Load the registry before composing its + // lifecycle so no hardcoded distro can receive provider commands. + var appLogger = new AppLogger(); + _gatewayRegistry = new GatewayRegistry(SettingsManager.SettingsDirectoryPath, logger: appLogger); + _gatewayRegistry.Load(); + var localAiLogger = new AppLogger(); + var localAiPaths = new LocalAiPaths(AppIdentity.ResolveSetupLocalDataDirectory()); + var localAiEndpointLifecycle = new LocalAiGatewayProviderCoordinator( + new WslExeCommandRunner(localAiLogger), + new LocalAiGatewayDistroResolver(_gatewayRegistry), + localAiLogger); + _localAiRuntime = new LlamaServerRuntimeService( + new LlamaServerRuntimeOptions + { + Paths = localAiPaths, + EndpointLifecycle = localAiEndpointLifecycle, + }, + localAiLogger); + // Build the DI composition root AFTER the tray is up, so additive plumbing // can never delay or preempt tray initialization. It only needs the // dispatcher + settings (created above) and failures are non-fatal. InitializeServiceProvider(); + StartLocalAiRouterInBackground(); // Initialize connection manager before setup flow. - _gatewayRegistry = new GatewayRegistry(SettingsManager.SettingsDirectoryPath, logger: new AppLogger()); - _gatewayRegistry.Load(); var credentialResolver = new CredentialResolver(DeviceIdentityFileReader.Instance); var clientFactory = new GatewayClientFactory(); - var appLogger = new AppLogger(); var diagnostics = new ConnectionDiagnostics(); var nodeConnector = new NodeConnector(appLogger, diagnostics); // Bridge: whenever NodeConnector creates a fresh WindowsNodeClient (initial @@ -936,6 +962,35 @@ _dispatcherQueue is null Logger.Info("Application started (WinUI 3)"); } + /// + /// Starts only the lightweight llama-server router. The verified model preset is + /// explicitly load-on-startup=false, so the first inference request owns model load. + /// This observed background task must never delay tray or gateway startup. + /// + private void StartLocalAiRouterInBackground() + { + ILocalAiRuntime? runtime = _localAiRuntime; + if (runtime is null) + return; + + _ = Task.Run(async () => + { + try + { + LocalAiRuntimeSnapshot snapshot = await runtime.EnsureStartedAsync(); + Logger.Info($"Local AI router startup state: {snapshot.State}"); + } + catch (ObjectDisposedException) + { + // Shutdown won the race with background router startup. + } + catch (Exception ex) + { + Logger.Warn($"Local AI router startup failed: {ex.Message}"); + } + }); + } + private void InitializeTrayIcon() { // Initialize keep-alive window first to anchor WinUI runtime diff --git a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs index f2d5cfea5..b1f75c269 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared.ExecApprovals; +using OpenClaw.Connection.LocalAi; using OpenClawTray.Services; namespace OpenClawTray.Presentation; @@ -16,13 +17,15 @@ public AppServiceContext( IAppCommands appCommands, SettingsManager settings, IExecApprovalsPresentationStore execApprovalsStore, - IPermissionsPageRuntimeHost permissionsRuntimeHost) + IPermissionsPageRuntimeHost permissionsRuntimeHost, + ILocalAiRuntime? localAiRuntime = null) { Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); AppCommands = appCommands ?? throw new ArgumentNullException(nameof(appCommands)); Settings = settings ?? throw new ArgumentNullException(nameof(settings)); ExecApprovalsStore = execApprovalsStore ?? throw new ArgumentNullException(nameof(execApprovalsStore)); PermissionsRuntimeHost = permissionsRuntimeHost ?? throw new ArgumentNullException(nameof(permissionsRuntimeHost)); + LocalAiRuntime = localAiRuntime; } public IUiDispatcher Dispatcher { get; } @@ -30,4 +33,5 @@ public AppServiceContext( public SettingsManager Settings { get; } public IExecApprovalsPresentationStore ExecApprovalsStore { get; } public IPermissionsPageRuntimeHost PermissionsRuntimeHost { get; } + public ILocalAiRuntime? LocalAiRuntime { get; } } diff --git a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs index 0df20d95d..dc24cccbd 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs @@ -36,6 +36,8 @@ public static IServiceCollection AddOpenClawTrayCore(this IServiceCollection ser services.AddSingleton(context.Settings); services.AddSingleton(context.ExecApprovalsStore); services.AddSingleton(context.PermissionsRuntimeHost); + if (context.LocalAiRuntime is not null) + services.AddSingleton(context.LocalAiRuntime); // Settings facade over the App-owned SettingsManager. Container-owned so it can dispose // its Saved-event subscription during shutdown. services.AddSingleton(); diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayDistroResolver.cs b/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayDistroResolver.cs new file mode 100644 index 000000000..9f7ae897e --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayDistroResolver.cs @@ -0,0 +1,97 @@ +using OpenClaw.Connection; + +namespace OpenClawTray.Services; + +internal sealed record LocalAiGatewayDistroResolution( + bool Success, + string? DistroName, + string? Detail) +{ + public static LocalAiGatewayDistroResolution Resolved(string distroName) => + new(true, distroName, null); + + public static LocalAiGatewayDistroResolution Failed(string detail) => + new(false, null, detail); +} + +internal interface ILocalAiGatewayDistroResolver +{ + LocalAiGatewayDistroResolution Resolve(); +} + +/// +/// Pins the singleton Local AI installation to the one explicitly setup-managed +/// local WSL gateway in the loaded registry. Every resolution revalidates the +/// pinned record so a registry replacement or ownership drift cannot redirect +/// lifecycle commands to another distro. +/// +internal sealed class LocalAiGatewayDistroResolver : ILocalAiGatewayDistroResolver +{ + private readonly GatewayRegistry? _registry; + private readonly string? _gatewayId; + private readonly string? _distroName; + private readonly string? _initialFailure; + + public LocalAiGatewayDistroResolver(GatewayRegistry? registry) + { + _registry = registry; + if (registry is null) + { + _initialFailure = + "The gateway registry is unavailable; refusing to change the Local AI gateway route."; + return; + } + + IReadOnlyList owners = FindOwners(registry.GetAll()); + if (owners.Count == 0) + { + _initialFailure = + "No explicit setup-managed WSL gateway owns the Local AI installation; refusing to change its gateway route."; + return; + } + + if (owners.Count != 1) + { + _initialFailure = + "Multiple explicit setup-managed WSL gateways could own the Local AI installation; refusing to choose one."; + return; + } + + GatewayRecord owner = owners[0]; + _gatewayId = owner.Id; + _distroName = owner.SetupManagedDistroName!.Trim(); + } + + public LocalAiGatewayDistroResolution Resolve() + { + if (_initialFailure is not null) + return LocalAiGatewayDistroResolution.Failed(_initialFailure); + if (_registry is null || _gatewayId is null || _distroName is null) + { + return LocalAiGatewayDistroResolution.Failed( + "The gateway registry is unavailable; refusing to change the Local AI gateway route."); + } + + IReadOnlyList owners = FindOwners(_registry.GetAll()); + if (owners.Count != 1 || + !string.Equals(owners[0].Id, _gatewayId, StringComparison.Ordinal) || + !string.Equals( + owners[0].SetupManagedDistroName?.Trim(), + _distroName, + StringComparison.Ordinal)) + { + return LocalAiGatewayDistroResolution.Failed( + "The setup-managed WSL gateway owner changed after Local AI startup; refusing to route lifecycle commands to an unknown distro."); + } + + return LocalAiGatewayDistroResolution.Resolved(_distroName); + } + + private static IReadOnlyList FindOwners(IEnumerable records) => + records + .Where(record => + WslKeepAlivePolicy.IsSetupManagedLocalRecord(record) && + !string.IsNullOrWhiteSpace(record.Id) && + !string.IsNullOrWhiteSpace(record.SetupManagedDistroName)) + .ToArray(); +} diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs new file mode 100644 index 000000000..30b29bde3 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs @@ -0,0 +1,339 @@ +using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared; +using System.Text; +using System.Text.Json; + +namespace OpenClawTray.Services; + +/// +/// Keeps the app-owned WSL gateway from routing to a listener while the native +/// llama-server endpoint is absent, changing, or not owned by this companion. +/// +internal sealed class LocalAiGatewayProviderCoordinator : ILocalAiEndpointLifecycle +{ + private const string FixedPath = "/home/openclaw/.openclaw/bin:/opt/openclaw/bin:/usr/local/bin:/usr/bin:/bin"; + private const int MaximumConfigBytes = 1024 * 1024; + + private readonly IWslCommandRunner _commands; + private readonly ILocalAiGatewayDistroResolver _distroResolver; + private readonly IOpenClawLogger _logger; + + public LocalAiGatewayProviderCoordinator( + IWslCommandRunner commands, + ILocalAiGatewayDistroResolver distroResolver, + IOpenClawLogger logger) + { + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); + _distroResolver = distroResolver ?? throw new ArgumentNullException(nameof(distroResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task QuiesceAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(install); + GatewayCapture current = await CaptureGatewayAsync(cancellationToken).ConfigureAwait(false); + if (!current.Success) + return Failed(current.Detail ?? "The managed Local AI gateway route could not be inspected."); + + string managedPrimary; + try + { + _ = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + managedPrimary = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install); + LocalAiGatewayProviderDefinition.ValidateFallbackModel( + install.Manifest.GatewayFallbackModel); + } + catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException) + { + return Failed(ex.Message); + } + if (current.ProviderExists && + !LocalAiGatewayProviderDefinition.MatchesProviderJson(current.ProviderJson!, install)) + { + return Failed("The llamacpp provider was changed outside the companion; preserving it and refusing to cycle the managed endpoint."); + } + + bool primaryIsManaged = current.PrimaryExists && + string.Equals(current.PrimaryModel, managedPrimary, StringComparison.Ordinal); + if (current.PrimaryExists && + !primaryIsManaged && + current.PrimaryModel!.StartsWith("llamacpp/", StringComparison.OrdinalIgnoreCase)) + { + return Failed("The llamacpp primary model was changed outside the companion; preserving it and refusing to cycle the managed endpoint."); + } + + string? expectedPrimary = current.PrimaryModel; + if (primaryIsManaged) + { + expectedPrimary = install.Manifest.GatewayFallbackModel; + LocalAiEndpointLifecycleResult primaryResult = expectedPrimary is null + ? await UnsetAsync(LocalAiGatewayProviderDefinition.PrimaryModelPath, cancellationToken) + .ConfigureAwait(false) + : await SetPrimaryAsync(expectedPrimary, cancellationToken).ConfigureAwait(false); + if (!primaryResult.Success) + return primaryResult; + } + + if (current.ProviderExists) + { + LocalAiEndpointLifecycleResult providerResult = await UnsetAsync( + LocalAiGatewayProviderDefinition.ProviderPath, + cancellationToken) + .ConfigureAwait(false); + if (!providerResult.Success) + return providerResult; + } + + GatewayCapture verified = await CaptureGatewayAsync(cancellationToken).ConfigureAwait(false); + if (!verified.Success || verified.ProviderExists || + verified.PrimaryExists != (expectedPrimary is not null) || + (expectedPrimary is not null && + !string.Equals(verified.PrimaryModel, expectedPrimary, StringComparison.Ordinal))) + { + return Failed("The managed Local AI gateway route remained active after it was disabled."); + } + return LocalAiEndpointLifecycleResult.Ok(); + } + + public async Task PublishAsync( + LocalAiResolvedInstall install, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(install); + string batch; + try + { + batch = LocalAiGatewayProviderDefinition.BuildProviderBatchJson(install); + } + catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException) + { + return Failed(ex.Message); + } + + GatewayCapture current = await CaptureGatewayAsync(cancellationToken).ConfigureAwait(false); + if (!current.Success) + return Failed(current.Detail ?? "The managed Local AI gateway route could not be inspected."); + string managedPrimary = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install); + if (current.ProviderExists) + { + return LocalAiGatewayProviderDefinition.MatchesProviderJson(current.ProviderJson!, install) && + current.PrimaryExists && + string.Equals(current.PrimaryModel, managedPrimary, StringComparison.Ordinal) + ? LocalAiEndpointLifecycleResult.Ok() + : Failed("The Local AI gateway route changed outside the companion; preserving it instead of publishing the managed endpoint."); + } + + string? fallbackModel = install.Manifest.GatewayFallbackModel; + if (current.PrimaryExists != (fallbackModel is not null) || + (fallbackModel is not null && + !string.Equals(current.PrimaryModel, fallbackModel, StringComparison.Ordinal))) + { + return Failed("The gateway primary model changed while Local AI was stopped; preserving it instead of overwriting it."); + } + + RoutedCommandResult applied = await ApplyBatchAsync(batch, cancellationToken).ConfigureAwait(false); + if (!applied.Routed) + return Failed(applied.Detail!); + if (!applied.Result!.Success) + { + LocalAiEndpointLifecycleResult cleanup = await QuiesceAsync(install, cancellationToken) + .ConfigureAwait(false); + return PublicationFailed( + "The verified Local AI route could not be published to the app-owned gateway.", + cleanup); + } + + GatewayCapture verified = await CaptureGatewayAsync(cancellationToken).ConfigureAwait(false); + if (!verified.Success || !verified.ProviderExists || + !LocalAiGatewayProviderDefinition.MatchesProviderJson(verified.ProviderJson!, install) || + !verified.PrimaryExists || + !string.Equals(verified.PrimaryModel, managedPrimary, StringComparison.Ordinal)) + { + LocalAiEndpointLifecycleResult cleanup = await QuiesceAsync(install, cancellationToken) + .ConfigureAwait(false); + return PublicationFailed( + "The app-owned gateway did not retain the verified Local AI route.", + cleanup); + } + return LocalAiEndpointLifecycleResult.Ok(); + } + + private LocalAiEndpointLifecycleResult PublicationFailed( + string detail, + LocalAiEndpointLifecycleResult cleanup) => cleanup.Success + ? Failed($"{detail} The just-written route was removed.") + : Failed($"{detail} Cleanup also failed: {cleanup.Detail}"); + + private async Task CaptureGatewayAsync(CancellationToken cancellationToken) + { + SettingCapture provider = await CaptureSettingAsync( + LocalAiGatewayProviderDefinition.ProviderPath, + cancellationToken).ConfigureAwait(false); + if (!provider.Success) + return new(false, false, null, false, null, provider.Detail); + + SettingCapture primary = await CaptureSettingAsync( + LocalAiGatewayProviderDefinition.PrimaryModelPath, + cancellationToken).ConfigureAwait(false); + if (!primary.Success) + return new(false, false, null, false, null, primary.Detail); + + string? providerJson = null; + if (provider.Exists) + { + try + { + using JsonDocument document = ParseBounded(provider.Json!); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return new(false, false, null, false, null, "The managed llamacpp provider has an invalid shape."); + providerJson = document.RootElement.GetRawText(); + } + catch (JsonException) + { + return new(false, false, null, false, null, "The managed llamacpp provider is not valid JSON."); + } + } + + string? primaryModel = null; + if (primary.Exists) + { + try + { + using JsonDocument document = ParseBounded(primary.Json!); + if (document.RootElement.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(primaryModel = document.RootElement.GetString()) || + primaryModel.Length > 512 || + primaryModel.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + return new(false, false, null, false, null, "The gateway primary model has an invalid shape."); + } + } + catch (JsonException) + { + return new(false, false, null, false, null, "The gateway primary model is not valid JSON."); + } + } + + return new(true, provider.Exists, providerJson, primary.Exists, primaryModel, null); + } + + private async Task CaptureSettingAsync( + string path, + CancellationToken cancellationToken) + { + RoutedCommandResult routed = await RunOpenClawAsync( + ["config", "get", path, "--json"], cancellationToken).ConfigureAwait(false); + if (!routed.Routed) + return new(false, false, null, routed.Detail); + + WslCommandResult direct = routed.Result!; + if (direct.Success) + return new(true, true, direct.StandardOutput, null); + string missing = $"Config path not found: {path}"; + return direct.StandardError.Contains(missing, StringComparison.Ordinal) + ? new(true, false, null, null) + : new(false, false, null, $"The app-owned gateway setting '{path}' could not be read."); + } + + private async Task SetPrimaryAsync( + string model, + CancellationToken cancellationToken) + { + LocalAiGatewayProviderDefinition.ValidateFallbackModel(model); + string batch = JsonSerializer.Serialize(new[] + { + new { path = LocalAiGatewayProviderDefinition.PrimaryModelPath, value = model }, + }); + RoutedCommandResult routed = await ApplyBatchAsync(batch, cancellationToken).ConfigureAwait(false); + if (!routed.Routed) + return Failed(routed.Detail!); + return routed.Result!.Success + ? LocalAiEndpointLifecycleResult.Ok() + : Failed("The prior gateway primary model could not be restored before the Local AI endpoint changed."); + } + + private async Task UnsetAsync( + string path, + CancellationToken cancellationToken) + { + RoutedCommandResult routed = await RunOpenClawAsync( + ["config", "unset", path], cancellationToken).ConfigureAwait(false); + if (!routed.Routed) + return Failed(routed.Detail!); + return routed.Result!.Success + ? LocalAiEndpointLifecycleResult.Ok() + : Failed($"The managed gateway setting '{path}' could not be disabled before the Local AI endpoint changed."); + } + + private Task ApplyBatchAsync( + string batch, + CancellationToken cancellationToken) + { + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(batch)); + string script = + $"set -e\nprintf '%s' '{encoded}' | base64 -d | openclaw config set --batch-file /dev/stdin --dry-run\n" + + $"printf '%s' '{encoded}' | base64 -d | openclaw config set --batch-file /dev/stdin"; + return RunInManagedDistroAsync( + ["/usr/bin/env", $"PATH={FixedPath}", "/bin/sh", "-c", script], + cancellationToken); + } + + private static JsonDocument ParseBounded(string value) + { + if (Encoding.UTF8.GetByteCount(value) > MaximumConfigBytes) + throw new JsonException("The gateway configuration is too large."); + return JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 32 }); + } + + private Task RunOpenClawAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + var command = new List(arguments.Count + 3) + { + "/usr/bin/env", + $"PATH={FixedPath}", + "openclaw", + }; + command.AddRange(arguments); + return RunInManagedDistroAsync(command, cancellationToken); + } + + private async Task RunInManagedDistroAsync( + IReadOnlyList command, + CancellationToken cancellationToken) + { + LocalAiGatewayDistroResolution resolution = _distroResolver.Resolve(); + if (!resolution.Success) + return new(null, resolution.Detail); + + WslCommandResult result = await _commands.RunInDistroAsync( + resolution.DistroName!, + command, + cancellationToken) + .ConfigureAwait(false); + return new(result, null); + } + + private LocalAiEndpointLifecycleResult Failed(string detail) + { + _logger.Warn(detail); + return LocalAiEndpointLifecycleResult.Failed(detail); + } + + private sealed record SettingCapture(bool Success, bool Exists, string? Json, string? Detail); + private sealed record RoutedCommandResult(WslCommandResult? Result, string? Detail) + { + public bool Routed => Result is not null; + } + private sealed record GatewayCapture( + bool Success, + bool ProviderExists, + string? ProviderJson, + bool PrimaryExists, + string? PrimaryModel, + string? Detail); +} diff --git a/tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs b/tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs new file mode 100644 index 000000000..50b82f299 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs @@ -0,0 +1,411 @@ +using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared; +using OpenClaw.Shared.Inference.Catalog; +using OpenClawTray.Services; +using System.Collections.Immutable; +using System.Text.Json; + +namespace OpenClaw.Tray.Tests; + +public sealed class LocalAiGatewayProviderCoordinatorTests +{ + [Fact] + public async Task Quiesce_RemovesExactManagedRouteWhenNoFallbackExists() + { + LocalAiResolvedInstall install = Install(28_765); + string expected = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + var commands = new FakeWslCommandRunner( + expected, + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install); + + Assert.True(result.Success); + Assert.Null(commands.ProviderJson); + Assert.Null(commands.PrimaryModel); + Assert.Contains(commands.Calls, call => call.Contains("unset")); + } + + [Fact] + public async Task Quiesce_AcceptsCliRedactedManagedApiKey() + { + LocalAiResolvedInstall install = Install(28_765); + string observed = RedactApiKey(LocalAiGatewayProviderDefinition.BuildProviderJson(install)); + var commands = new FakeWslCommandRunner( + observed, + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install); + + Assert.True(result.Success); + Assert.Null(commands.ProviderJson); + Assert.Null(commands.PrimaryModel); + } + + [Fact] + public async Task Quiesce_PreservesProviderDriftAndFailsClosed() + { + LocalAiResolvedInstall install = Install(28_765); + string drifted = LocalAiGatewayProviderDefinition.BuildProviderJson(install) + .Replace("28765", "39876", StringComparison.Ordinal); + var commands = new FakeWslCommandRunner( + drifted, + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)); + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install); + + Assert.False(result.Success); + Assert.Equal(drifted, commands.ProviderJson); + Assert.DoesNotContain(commands.Calls, call => call.Contains("unset")); + } + + [Fact] + public async Task Publish_UsesVerifiedEndpointAndNonDefaultManagedDistro() + { + LocalAiResolvedInstall install = Install(28_766); + string expected = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + var commands = new FakeWslCommandRunner(providerJson: null) + { + ProviderAfterApply = RedactApiKey(expected), + PrimaryAfterApply = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install), + }; + var coordinator = CreateCoordinator(commands, "CustomGateway"); + + LocalAiEndpointLifecycleResult result = await coordinator.PublishAsync(install); + + Assert.True(result.Success); + Assert.True(LocalAiGatewayProviderDefinition.MatchesProviderJson(commands.ProviderJson!, install)); + Assert.Equal(LocalAiGatewayProviderDefinition.BuildPrimaryModel(install), commands.PrimaryModel); + IReadOnlyList apply = Assert.Single(commands.Calls, call => call.Contains("/bin/sh")); + string script = apply[^1]; + Assert.Contains("--dry-run", script, StringComparison.Ordinal); + Assert.DoesNotContain('$', script); + Assert.All(commands.Distros, distro => Assert.Equal("CustomGateway", distro)); + } + + [Fact] + public async Task Publish_VerificationFailureRemovesExactJustWrittenProvider() + { + LocalAiResolvedInstall install = Install(28_768); + string expected = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + var commands = new FakeWslCommandRunner(providerJson: null) + { + ProviderAfterApply = expected, + PrimaryAfterApply = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install), + FailedReadCalls = [2], + }; + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.PublishAsync(install); + + Assert.False(result.Success); + Assert.Contains("was removed", result.Detail, StringComparison.Ordinal); + Assert.Null(commands.ProviderJson); + Assert.Null(commands.PrimaryModel); + Assert.Contains(commands.Calls, call => call.Contains("unset")); + } + + [Fact] + public async Task Publish_VerificationFailureSurfacesCleanupFailure() + { + LocalAiResolvedInstall install = Install(28_769); + string expected = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + var commands = new FakeWslCommandRunner(providerJson: null) + { + ProviderAfterApply = expected, + PrimaryAfterApply = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install), + FailedReadCalls = [2, 3], + }; + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.PublishAsync(install); + + Assert.False(result.Success); + Assert.Contains("Cleanup also failed", result.Detail, StringComparison.Ordinal); + Assert.Equal(expected, commands.ProviderJson); + Assert.Equal(LocalAiGatewayProviderDefinition.BuildPrimaryModel(install), commands.PrimaryModel); + Assert.DoesNotContain(commands.Calls, call => call.Contains("unset")); + } + + [Fact] + public async Task Quiesce_DoesNotMistakeWslFailureForMissingProvider() + { + LocalAiResolvedInstall install = Install(28_767); + var commands = new FakeWslCommandRunner(providerJson: null) { FailReads = true }; + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install); + + Assert.False(result.Success); + } + + [Fact] + public async Task Quiesce_RestoresFallbackBeforeRemovingProvider() + { + LocalAiResolvedInstall install = Install(28_770, "openai/gpt-5"); + var commands = new FakeWslCommandRunner( + LocalAiGatewayProviderDefinition.BuildProviderJson(install), + LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)) + { + PrimaryAfterApply = "openai/gpt-5", + }; + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install); + + Assert.True(result.Success); + Assert.Null(commands.ProviderJson); + Assert.Equal("openai/gpt-5", commands.PrimaryModel); + int restoreIndex = commands.Calls.FindIndex(call => call.Contains("/bin/sh")); + int unsetIndex = commands.Calls.FindIndex(call => + call.Contains("unset") && call.Contains(LocalAiGatewayProviderDefinition.ProviderPath)); + Assert.True(restoreIndex >= 0 && unsetIndex > restoreIndex); + } + + [Fact] + public async Task Publish_PreservesUnexpectedPrimaryAndFailsClosed() + { + LocalAiResolvedInstall install = Install(28_771, "openai/gpt-5"); + var commands = new FakeWslCommandRunner(providerJson: null, primaryModel: "anthropic/claude"); + var coordinator = CreateCoordinator(commands); + + LocalAiEndpointLifecycleResult result = await coordinator.PublishAsync(install); + + Assert.False(result.Success); + Assert.Null(commands.ProviderJson); + Assert.Equal("anthropic/claude", commands.PrimaryModel); + Assert.DoesNotContain(commands.Calls, call => call.Contains("/bin/sh")); + } + + [Fact] + public async Task Publish_MissingManagedGatewayOwner_FailsWithoutWslCommand() + { + LocalAiResolvedInstall install = Install(28_772); + var commands = new FakeWslCommandRunner(providerJson: null); + var coordinator = CreateCoordinatorForRegistry(commands, CreateRegistry()); + + LocalAiEndpointLifecycleResult result = await coordinator.PublishAsync(install); + + Assert.False(result.Success); + Assert.Contains("No explicit setup-managed WSL gateway", result.Detail, StringComparison.Ordinal); + Assert.Empty(commands.Calls); + Assert.Empty(commands.Distros); + } + + [Fact] + public async Task Publish_AmbiguousManagedGatewayOwners_FailsWithoutWslCommand() + { + LocalAiResolvedInstall install = Install(28_773); + var commands = new FakeWslCommandRunner(providerJson: null); + GatewayRegistry registry = CreateRegistry( + ManagedRecord("managed-a", "GatewayA"), + ManagedRecord("managed-b", "GatewayB")); + var coordinator = CreateCoordinatorForRegistry(commands, registry); + + LocalAiEndpointLifecycleResult result = await coordinator.PublishAsync(install); + + Assert.False(result.Success); + Assert.Contains("Multiple explicit setup-managed WSL gateways", result.Detail, StringComparison.Ordinal); + Assert.Empty(commands.Calls); + Assert.Empty(commands.Distros); + } + + [Fact] + public async Task Quiesce_RegistryUnavailable_FailsWithoutWslCommand() + { + LocalAiResolvedInstall install = Install(28_774); + var commands = new FakeWslCommandRunner(providerJson: null); + var coordinator = CreateCoordinatorForRegistry(commands, registry: null); + + LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install); + + Assert.False(result.Success); + Assert.Contains("registry is unavailable", result.Detail, StringComparison.Ordinal); + Assert.Empty(commands.Calls); + Assert.Empty(commands.Distros); + } + + [Fact] + public async Task Quiesce_OwnerDriftsAfterInspection_BlocksFirstMutation() + { + LocalAiResolvedInstall install = Install(28_775); + GatewayRegistry registry = CreateRegistry(ManagedRecord("managed", "CustomGateway")); + string provider = LocalAiGatewayProviderDefinition.BuildProviderJson(install); + string primary = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install); + var commands = new FakeWslCommandRunner(provider, primary) + { + CommandObserved = callCount => + { + if (callCount == 2) + { + registry.Update( + "managed", + record => record with { SetupManagedDistroName = "UnexpectedGateway" }); + } + }, + }; + var coordinator = CreateCoordinatorForRegistry(commands, registry); + + LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install); + + Assert.False(result.Success); + Assert.Contains("owner changed", result.Detail, StringComparison.Ordinal); + Assert.Equal(provider, commands.ProviderJson); + Assert.Equal(primary, commands.PrimaryModel); + Assert.Equal(2, commands.Calls.Count); + Assert.DoesNotContain(commands.Calls, call => call.Contains("unset")); + } + + private static LocalAiGatewayProviderCoordinator CreateCoordinator( + FakeWslCommandRunner commands, + string distroName = "OpenClawGateway") => + CreateCoordinatorForRegistry( + commands, + CreateRegistry(ManagedRecord("managed", distroName))); + + private static LocalAiGatewayProviderCoordinator CreateCoordinatorForRegistry( + FakeWslCommandRunner commands, + GatewayRegistry? registry) => + new( + commands, + new LocalAiGatewayDistroResolver(registry), + NullLogger.Instance); + + private static GatewayRegistry CreateRegistry(params GatewayRecord[] records) + { + var registry = new GatewayRegistry(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + foreach (GatewayRecord record in records) + registry.AddOrUpdate(record); + return registry; + } + + private static GatewayRecord ManagedRecord(string id, string distroName) => new() + { + Id = id, + Url = "ws://localhost:18789", + IsLocal = true, + SetupManagedDistroName = distroName, + }; + + private static LocalAiResolvedInstall Install(int port, string? fallbackModel = null) + { + var endpoint = new Uri($"http://127.0.0.1:{port}/v1"); + var manifest = new LocalAiInstallManifest + { + EngineVersion = "b10488", + Architecture = "arm64", + HardwareProfileId = "rtx-spark-n1x", + RuntimeId = "b10488-cuda13-arm64", + ModelCatalogId = LocalModelCatalog.Qwen35BModelId, + SelectedGpuId = "GPU-SPARK", + ExecutablePath = "engines/llama-server.exe", + RuntimeAssets = ImmutableArray.Empty, + ModelPath = "models/model.gguf", + ModelId = "owner/model@0123456789abcdef0123456789abcdef01234567", + ModelAlias = LocalModelCatalog.Qwen35BModelId, + ModelAsset = new LocalAiAssetReceipt + { + FileName = "model.gguf", + SourceUrl = "https://huggingface.co/owner/model/resolve/0123456789abcdef0123456789abcdef01234567/model.gguf", + SizeBytes = 1, + Sha256 = new string('a', 64), + }, + RequestedPort = 0, + Endpoint = endpoint.AbsoluteUri, + GatewayFallbackModel = fallbackModel, + ContextLength = LocalModelCatalog.NativeContextTokens, + }; + return new(manifest, "llama-server.exe", "model.gguf", endpoint); + } + + private static string RedactApiKey(string value) => value.Replace( + "\"api\":\"openai-completions\",\"apiKey\":\"llama-local\"", + $"\"apiKey\":\"{LocalAiGatewayProviderDefinition.CliRedactedApiKey}\",\"api\":\"openai-completions\"", + StringComparison.Ordinal); + + private sealed class FakeWslCommandRunner(string? providerJson, string? primaryModel = null) : IWslCommandRunner + { + public string? ProviderJson { get; private set; } = providerJson; + public string? PrimaryModel { get; private set; } = primaryModel; + public string? ProviderAfterApply { get; init; } + public string? PrimaryAfterApply { get; init; } + public bool FailReads { get; init; } + public HashSet FailedReadCalls { get; init; } = []; + public Action? CommandObserved { get; init; } + public List> Calls { get; } = []; + public List Distros { get; } = []; + private int _readCalls; + + public Task RunInDistroAsync( + string name, + IReadOnlyList command, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) + { + cancellationToken.ThrowIfCancellationRequested(); + Distros.Add(name); + Calls.Add(command.ToArray()); + CommandObserved?.Invoke(Calls.Count); + bool providerRead = command.Contains("get") && + command.Contains(LocalAiGatewayProviderDefinition.ProviderPath); + if (providerRead && (FailReads || FailedReadCalls.Contains(++_readCalls))) + return Result(1, string.Empty, "wsl.exe failed"); + if (command.Contains("/bin/sh")) + { + if (ProviderAfterApply is not null) + ProviderJson = ProviderAfterApply; + PrimaryModel = PrimaryAfterApply; + return Result(PrimaryModel is null ? 1 : 0, string.Empty); + } + if (command.Contains("unset")) + { + if (command.Contains(LocalAiGatewayProviderDefinition.ProviderPath)) + ProviderJson = null; + if (command.Contains(LocalAiGatewayProviderDefinition.PrimaryModelPath)) + PrimaryModel = null; + return Result(0, string.Empty); + } + if (command.Contains(LocalAiGatewayProviderDefinition.ProviderPath)) + return ProviderJson is null + ? Result( + 1, + string.Empty, + $"Config path not found: {LocalAiGatewayProviderDefinition.ProviderPath}") + : Result(0, ProviderJson); + if (command.Contains(LocalAiGatewayProviderDefinition.PrimaryModelPath)) + return PrimaryModel is null + ? Result( + 1, + string.Empty, + $"Config path not found: {LocalAiGatewayProviderDefinition.PrimaryModelPath}") + : Result(0, JsonSerializer.Serialize(PrimaryModel)); + return Result(1, string.Empty); + } + + public Task RunAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) => Result(1, string.Empty); + + public Task> ListDistrosAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task TerminateDistroAsync( + string name, + CancellationToken cancellationToken = default) => Result(1, string.Empty); + + public Task UnregisterDistroAsync( + string name, + CancellationToken cancellationToken = default) => Result(1, string.Empty); + + private static Task Result( + int exitCode, + string stdout, + string stderr = "") => + Task.FromResult(new WslCommandResult(exitCode, stdout, stderr)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 27f309181..0ee8e94a6 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -93,6 +93,8 @@ + + From 69e2a27e766164f79f9d0985440c37e17500c343 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:57:05 -0700 Subject: [PATCH 16/23] feat(setup-ui): add Local AI onboarding Surface Local AI eligibility, explicit consent, review, and setup progress. Show native inference before WSL setup and describe dynamic multi-gigabyte disk use accurately. Signed-off-by: Joel Fernandes --- .../Pages/CapabilitiesPage.xaml | 76 ++++- .../Pages/CapabilitiesPage.xaml.cs | 259 +++++++++++++++++- .../Pages/CompletePage.xaml | 22 ++ .../Pages/CompletePage.xaml.cs | 13 +- .../Pages/ProgressPage.xaml | 2 +- .../Pages/ProgressPage.xaml.cs | 117 +++++++- .../Pages/WelcomePage.xaml | 21 +- .../Pages/WelcomePage.xaml.cs | 27 ++ .../SetupWindow.xaml.cs | 23 +- .../SetupDetailProgress.cs | 22 ++ .../SetupReviewSummary.cs | 40 ++- .../SetupConfigTests.cs | 7 +- 12 files changed, 606 insertions(+), 23 deletions(-) create mode 100644 src/OpenClaw.SetupEngine/SetupDetailProgress.cs diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml index 275bca9dc..2dce0f977 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml @@ -73,6 +73,11 @@ + + @@ -104,11 +109,78 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs index fb288106e..cdc57c3c8 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs @@ -4,6 +4,8 @@ using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; using OpenClaw.Shared; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; using OpenClaw.SetupEngine.UI; using System.Diagnostics; @@ -18,7 +20,17 @@ public sealed partial class CapabilitiesPage : Page private SetupWindow? _setupWindow; private Task? _permissionsTask; private bool _suppressProfile; + private bool _suppressLocalAiToggle; + private bool _suppressLocalAiSelection; + private bool _suppressLocalAiConsent; private bool _skipPermissions; + private bool _skipWizardWithoutLocalAi; + private bool _localAiSelectionEligible; + private bool _localAiNetworkingConsentRequired; + private bool _localAiNetworkingInspectionFailed; + private HostHardwareInfo? _localAiHardware; + private string? _localAiRecommendedModelId; + private long? _localAiSelectedGpuCapacityBytes; private bool _treatBundledAllOnAsPlaceholder; private int _step = 1; @@ -61,6 +73,7 @@ protected override void OnNavigatedTo(NavigationEventArgs e) // setup declaration and gateway allowlist aligned with that runtime contract. _config.Capabilities.Device = true; _skipPermissions = _config.SkipPermissions; + _skipWizardWithoutLocalAi = _config.SkipWizard; _treatBundledAllOnAsPlaceholder = _config.UsesBundledDefaultConfig; BuildToggles(); _suppressProfile = true; @@ -81,12 +94,23 @@ protected override void OnNavigatedTo(NavigationEventArgs e) _setupWindow = SetupWindow.Active; if (_setupWindow is not null) _setupWindow.Activated += SetupWindow_Activated; - ApplySetupReviewSummary(_config); TailscaleToggle.IsOn = _config.Tailscale.Enabled; TailscaleTrustAuthToggle.IsOn = _config.Tailscale.TrustTailscaleAuth; TailscaleAuthModeSelector.SelectedIndex = _config.Tailscale.AuthMode == TailscaleAuthMode.AuthKey ? 1 : 0; UpdateTailscaleOptions(); - GoToStep(1); + var previewPage = SetupPreview.RequestedPage; + var localAiReviewPreview = previewPage is "capabilities-review" or "capabilities-review-consent"; + if (localAiReviewPreview) + _config.LocalAi.Enabled = true; + AsyncEventHandlerGuard.Run( + () => InitializeLocalAiReviewAsync( + forceNetworkingConsent: previewPage == "capabilities-review-consent"), + NullLogger.Instance, + nameof(InitializeLocalAiReviewAsync)); + ApplySetupReviewSummary(_config); + GoToStep(localAiReviewPreview ? 3 : 1); + if (localAiReviewPreview) + DispatcherQueue.TryEnqueue(() => Scroller.ChangeView(null, 0, null, disableAnimation: true)); } protected override void OnNavigatedFrom(NavigationEventArgs e) @@ -138,6 +162,7 @@ private void GoToStep(int step) PrimaryButton.Content = step == 3 ? "Install & set up" : "Next"; // Back is always available — from step 1 it returns to the Welcome screen. BackButton.Visibility = Visibility.Visible; + UpdatePrimaryButtonState(); ScrollActiveIntoView(); } @@ -214,6 +239,12 @@ private void WriteCapabilities() config.Tailscale.AuthKey = config.Tailscale.AuthMode == TailscaleAuthMode.AuthKey ? TailscaleAuthKeyBox.Password : null; + config.LocalAi.Enabled = LocalAiToggle.IsOn == true; + config.SkipWizard = config.LocalAi.Enabled || _skipWizardWithoutLocalAi; + config.LocalAi.WslMirroredNetworkingConsent = + config.LocalAi.Enabled && + _localAiNetworkingConsentRequired && + LocalAiNetworkingConsentCheckBox.IsChecked == true; } private void ApplySetupReviewSummary(SetupConfig config) @@ -231,6 +262,230 @@ private void ApplySetupReviewSummary(SetupConfig config) ExactCommandsText.Text = summary.ExactCommands; } + private async Task InitializeLocalAiReviewAsync(bool forceNetworkingConsent) + { + try + { + SetupWindow? setupWindow = _setupWindow; + _localAiHardware = setupWindow is not null + ? await setupWindow.GetLocalAiHardwareAsync() + : await Task.Run(() => new NvmlHostHardwareProbe().Probe()); + if (_setupWindow is null && setupWindow is not null) + return; + + LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate( + _localAiHardware, + _config!.LocalAi.SelectedModelId); + _localAiSelectedGpuCapacityBytes = eligibility.DetectedTotalMemoryBytes; + _localAiRecommendedModelId = LocalModelCatalog.Models + .OrderByDescending(model => model.Weights.SizeBytes) + .FirstOrDefault(model => + _localAiSelectedGpuCapacityBytes is { } capacityBytes && + LocalInferenceEligibility.GetRequiredMemoryBytes(model) <= capacityBytes)?.Id; + if (!eligibility.CanInstall || eligibility.Plan is null || eligibility.SelectedGpu is null) + { + _localAiSelectionEligible = false; + LocalAiInstallReviewCard.Visibility = Visibility.Collapsed; + _config.LocalAi.Enabled = false; + _config.SkipWizard = _skipWizardWithoutLocalAi; + return; + } + + LocalAiInstallReviewCard.Visibility = Visibility.Visible; + _localAiSelectionEligible = eligibility.Status == LocalInferenceEligibilityStatus.Eligible; + _config.LocalAi.SelectedModelId ??= eligibility.Plan.Model.Id; + PopulateLocalAiModels(); + _suppressLocalAiToggle = true; + LocalAiToggle.IsOn = _config.LocalAi.Enabled; + _suppressLocalAiToggle = false; + UpdateLocalAiOptions(forceNetworkingConsent); + ApplySetupReviewSummary(_config); + } + catch + { + _localAiSelectionEligible = false; + LocalAiInstallReviewCard.Visibility = Visibility.Collapsed; + _config!.LocalAi.Enabled = false; + _config.SkipWizard = _skipWizardWithoutLocalAi; + } + } + + private void PopulateLocalAiModels() + { + _suppressLocalAiSelection = true; + LocalAiModelSelector.Items.Clear(); + int selectedIndex = 0; + LocalModelInfo[] fittingModels = LocalModelCatalog.Models + .Where(model => + _localAiSelectedGpuCapacityBytes is { } capacityBytes && + LocalInferenceEligibility.GetRequiredMemoryBytes(model) <= capacityBytes) + .ToArray(); + for (int index = 0; index < fittingModels.Length; index++) + { + LocalModelInfo model = fittingModels[index]; + bool isRecommended = string.Equals( + _localAiRecommendedModelId, + model.Id, + StringComparison.OrdinalIgnoreCase); + LocalAiModelSelector.Items.Add(new ComboBoxItem + { + Content = $"{model.DisplayName} ({FormatSize(model.Weights.SizeBytes)})" + + (isRecommended ? " - Recommended" : string.Empty), + Tag = model.Id, + }); + string? selectedModelId = _config!.LocalAi.SelectedModelId ?? _localAiRecommendedModelId; + if (string.Equals(selectedModelId, model.Id, StringComparison.OrdinalIgnoreCase)) + selectedIndex = index; + } + LocalAiModelSelector.SelectedIndex = selectedIndex; + _suppressLocalAiSelection = false; + } + + private void LocalAiToggle_Toggled(object sender, RoutedEventArgs e) + { + if (_suppressLocalAiToggle || _config is null) + return; + UpdateLocalAiOptions(); + ApplySetupReviewSummary(_config); + } + + private void LocalAiModelSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressLocalAiSelection || _config is null || + LocalAiModelSelector.SelectedItem is not ComboBoxItem { Tag: string modelId }) + { + return; + } + _config.LocalAi.SelectedModelId = modelId; + UpdateLocalAiModelDetails(); + ApplySetupReviewSummary(_config); + } + + private void LocalAiNetworkingConsent_Changed(object sender, RoutedEventArgs e) + { + if (_suppressLocalAiConsent || _config is null) + return; + _config.LocalAi.WslMirroredNetworkingConsent = + LocalAiToggle.IsOn == true && + _localAiNetworkingConsentRequired && + LocalAiNetworkingConsentCheckBox.IsChecked == true; + UpdatePrimaryButtonState(); + } + + private void UpdateLocalAiOptions(bool forceNetworkingConsent = false) + { + var config = _config!; + bool enabled = LocalAiToggle.IsOn == true; + config.LocalAi.Enabled = enabled; + config.SkipWizard = enabled || _skipWizardWithoutLocalAi; + LocalAiDetailsPanel.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed; + LocalAiNetworkingInspectionError.Visibility = Visibility.Collapsed; + _localAiNetworkingConsentRequired = false; + _localAiNetworkingInspectionFailed = false; + + if (!enabled) + { + LocalAiNetworkingConsentPanel.Visibility = Visibility.Collapsed; + SetLocalAiNetworkingConsent(false); + config.LocalAi.WslMirroredNetworkingConsent = false; + UpdatePrimaryButtonState(); + return; + } + + UpdateLocalAiModelDetails(); + try + { + var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var configPath = Path.Combine(profile, ".wslconfig"); + var localDataDir = SetupWindow.Active?.LocalDataDir ?? SetupContext.ResolveLocalDataDir(); + var manager = new WslGlobalConfigManager( + configPath, + Path.Combine(localDataDir, "LocalAI", "network-backup")); + WslGlobalConfigStatus status = forceNetworkingConsent + ? new(false, false) + : manager.Inspect(); + _localAiNetworkingConsentRequired = !status.IsMirrored; + LocalAiNetworkingConsentPanel.Visibility = _localAiNetworkingConsentRequired + ? Visibility.Visible + : Visibility.Collapsed; + SetLocalAiNetworkingConsent(false); + config.LocalAi.WslMirroredNetworkingConsent = false; + } + catch (Exception ex) + { + _localAiNetworkingInspectionFailed = true; + LocalAiNetworkingConsentPanel.Visibility = Visibility.Collapsed; + SetLocalAiNetworkingConsent(false); + config.LocalAi.WslMirroredNetworkingConsent = false; + LocalAiNetworkingInspectionError.Message = + $"Setup cannot continue with Local AI until the global WSL configuration can be read. {ex.Message}"; + LocalAiNetworkingInspectionError.Visibility = Visibility.Visible; + } + UpdatePrimaryButtonState(); + } + + private void UpdateLocalAiModelDetails() + { + if (_localAiHardware is null || + LocalAiModelSelector.SelectedItem is not ComboBoxItem { Tag: string modelId }) + { + return; + } + + LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate(_localAiHardware, modelId); + if (eligibility.Plan is not { } plan || eligibility.SelectedGpu is not { } gpu) + { + _localAiSelectionEligible = false; + LocalAiHardwareStatusText.Text = "This model is not qualified for the detected hardware."; + UpdatePrimaryButtonState(); + return; + } + + _localAiSelectionEligible = eligibility.Status == LocalInferenceEligibilityStatus.Eligible; + LocalAiHardwareStatusText.Text = eligibility.Status switch + { + LocalInferenceEligibilityStatus.Eligible => + $"Detected {gpu.Name} with {FormatOptionalSize(eligibility.DetectedTotalMemoryBytes)}. " + + $"The selected model requires {FormatSize(eligibility.RequiredTotalMemoryBytes)}.", + LocalInferenceEligibilityStatus.EligibleButBusy => + $"Detected {gpu.Name}, but only {FormatOptionalSize(eligibility.AvailableFreeMemoryBytes)} of " + + $"{FormatSize(eligibility.RequiredFreeMemoryBytes)} required GPU memory is currently free. " + + "Close GPU applications and retry setup.", + _ => "This model is not qualified for the detected hardware.", + }; + LocalAiEngineDetailText.Text = + "llama-server for Windows; " + + $"{FormatSize(plan.Runtime.Artifacts.Sum(artifact => artifact.SizeBytes))} verified download"; + LocalAiModelDetailText.Text = + $"{plan.Model.DisplayName}, {FormatSize(plan.Model.Weights.SizeBytes)} from Hugging Face"; + LocalAiSettingsDetailText.Text = + $"{plan.Model.Recipe.ContextTokens / 1024}K context, FP16 KV cache, full CUDA offload, loads on first request"; + UpdatePrimaryButtonState(); + } + + private void SetLocalAiNetworkingConsent(bool value) + { + _suppressLocalAiConsent = true; + LocalAiNetworkingConsentCheckBox.IsChecked = value; + _suppressLocalAiConsent = false; + } + + private void UpdatePrimaryButtonState() + { + PrimaryButton.IsEnabled = + _step != 3 || + LocalAiToggle.IsOn != true || + (_localAiSelectionEligible && + !_localAiNetworkingInspectionFailed && + (!_localAiNetworkingConsentRequired || LocalAiNetworkingConsentCheckBox.IsChecked == true)); + } + + private static string FormatSize(long bytes) => + $"{bytes / 1_000_000_000d:0.#} GB"; + + private static string FormatOptionalSize(long? bytes) => + bytes is { } value ? FormatSize(value) : "an unknown amount"; + private void TailscaleToggle_Toggled(object sender, RoutedEventArgs e) { UpdateTailscaleOptions(); diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml index 0c93742a9..6dd3afab3 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml @@ -63,6 +63,28 @@ + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs index f5fbf8a62..8273a3cda 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs @@ -31,13 +31,23 @@ protected override void OnNavigatedTo(NavigationEventArgs e) FailureIcon.Visibility = Visibility.Collapsed; StartupToggle.IsOn = args.DefaultAutoStart; StartupRow.Visibility = args.ShowStartupPreference ? Visibility.Visible : Visibility.Collapsed; - GatewaySummaryText.Text = (args.ReviewSummary ?? SetupReviewSummaryBuilder.Build(new SetupConfig())).CompletionGatewaySummary; + SetupReviewSummary review = args.ReviewSummary ?? SetupReviewSummaryBuilder.Build(new SetupConfig()); + GatewaySummaryText.Text = review.CompletionGatewaySummary; TitleText.Text = "All set!"; SubtitleText.Text = "OpenClaw is ready to go"; ErrorCard.Visibility = Visibility.Collapsed; HelpLink.Visibility = Visibility.Collapsed; FallbackButton.Visibility = Visibility.Collapsed; SummaryPanel.Visibility = Visibility.Visible; + LocalAiSummaryCard.Visibility = review.LocalAiEnabled ? Visibility.Visible : Visibility.Collapsed; + if (review.LocalAiEnabled) + { + LocalAiSummaryTitle.Text = review.LocalAiTitle ?? "Local AI verified"; + LocalAiSummaryDescription.Text = review.LocalAiDescription ?? + "The native llama-server router is ready. The model loads on the first request."; + SubtitleText.Text = "OpenClaw and Local AI are ready"; + LaunchButton.Content = "Open chat"; + } } else { @@ -53,6 +63,7 @@ protected override void OnNavigatedTo(NavigationEventArgs e) NodeModeBanner.Visibility = Visibility.Collapsed; StartupRow.Visibility = Visibility.Collapsed; SummaryPanel.Visibility = Visibility.Collapsed; + LocalAiSummaryCard.Visibility = Visibility.Collapsed; LaunchButton.Content = "Close"; FallbackButton.Visibility = args.CanRetryGatewayFallback ? Visibility.Visible diff --git a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml index 5469239c8..f799f2207 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml @@ -11,7 +11,7 @@ - g.GroupId).ToArray(); + int previewRunningIndex = localAiPreview + ? Array.IndexOf(ids, "local-ai-model") + : 3; for (int i = 0; i < ids.Length; i++) { - var status = i < 3 ? StepStatus.Done : i == 3 ? StepStatus.Running : StepStatus.Idle; + var status = i < previewRunningIndex + ? StepStatus.Done + : i == previewRunningIndex ? StepStatus.Running : StepStatus.Idle; if (_rows.TryGetValue(ids[i], out var row)) row.SetStatus(status); } + if (localAiPreview && _rows.TryGetValue("local-ai-model", out var modelRow)) + modelRow.SetDetail("Downloading Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", 8_701_231_104, 22_663_387_424, SetupDetailProgressUnit.Bytes); LogText.Text = "[12:04:01] [info] Windows 11 26100 · WSL 2 present\n" + "[12:04:03] [info] port 127.0.0.1:18789 available\n" + "[12:04:05] [info] wsl --install -d Ubuntu-24.04 --name OpenClawGateway --no-launch\n" + - "[12:04:38] [info] downloading distro … 142/200 MB\n" + + "[12:04:38] [info] downloading distro image (disk use varies)\n" + "[12:04:38] [changed] created %LOCALAPPDATA%\\OpenClawTray\\wsl\\OpenClawGateway\\\n" + "[12:04:38] [info] next: install CLI via HTTPS, configure loopback gateway\n"; } @@ -113,9 +132,13 @@ private void BuildStepRows() { foreach (var (groupId, displayName, _) in StepGroups) { - var row = new StepRow(displayName); + var row = new StepRow( + displayName, + showDetailProgress: groupId is "local-ai-engine" or "local-ai-model"); _rows[groupId] = row; StepsPanel.Children.Add(row.Element); + if (_config?.LocalAi.Enabled != true && groupId.StartsWith("local-ai", StringComparison.Ordinal)) + row.Element.Visibility = Visibility.Collapsed; } } @@ -157,6 +180,7 @@ private async Task StartPipelineAsync() _dataDir, _localDataDir); ctx.ExternalAuthorizationPresenter = new ProgressAuthorizationPresenter(DispatcherQueue, ShowTailscaleAuthorization); + ctx.DetailProgress = new DirectProgress(OnDetailProgress); var steps = BuildSteps(config); _pipeline = new SetupPipeline(steps); @@ -274,6 +298,17 @@ private void OnStepProgress(object? sender, StepProgressEvent e) private readonly HashSet _completedSteps = new(); + private void OnDetailProgress(SetupDetailProgressEvent progress) + { + DispatcherQueue.TryEnqueue(() => + { + var group = StepGroups.FirstOrDefault(candidate => candidate.StepIds.Contains(progress.StepId)); + if (string.IsNullOrWhiteSpace(group.GroupId) || !_rows.TryGetValue(group.GroupId, out var row)) + return; + row.SetDetail(progress.Detail, progress.Completed, progress.Total, progress.Unit); + }); + } + private void OnLogEmitted(object? sender, LogEntry entry) { DispatcherQueue.TryEnqueue(() => @@ -356,6 +391,13 @@ public Task PresentAsync(ExternalAuthorizationRequest request, CancellationToken } } +internal sealed class DirectProgress(Action report) : IProgress +{ + private readonly Action _report = report ?? throw new ArgumentNullException(nameof(report)); + + public void Report(T value) => _report(value); +} + // ─── Step Row UI Element ─── internal enum StepStatus { Idle, Running, Done, Failed } @@ -366,13 +408,15 @@ internal sealed class StepRow public StepStatus Status { get; private set; } private readonly TextBlock _label; + private readonly TextBlock _detail; + private readonly ProgressBar _detailProgress; private readonly ProgressRing _spinner; private readonly Border _idleBadge; private readonly Border _checkBadge; private readonly Border _errorBadge; private readonly Border _rowBorder; - public StepRow(string displayName) + public StepRow(string displayName, bool showDetailProgress = false) { _label = new TextBlock { @@ -380,6 +424,21 @@ public StepRow(string displayName) FontSize = 14, VerticalAlignment = VerticalAlignment.Center, }; + _detail = new TextBlock + { + FontSize = 12, + Opacity = 0.72, + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed, + }; + _detailProgress = new ProgressBar + { + Minimum = 0, + Maximum = 1, + Height = 4, + Margin = new Thickness(0, 3, 0, 0), + Visibility = Visibility.Collapsed, + }; // Bare Windows spinner (no filled disc) — theme-neutral so it reads white // on the dark active row and dark on light, like a standard ProgressRing. @@ -417,9 +476,16 @@ public StepRow(string displayName) { ColumnDefinitions = { new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, new ColumnDefinition { Width = GridLength.Auto } }, }; - Grid.SetColumn(_label, 0); + var textStack = new StackPanel { Spacing = 1 }; + textStack.Children.Add(_label); + if (showDetailProgress) + { + textStack.Children.Add(_detail); + textStack.Children.Add(_detailProgress); + } + Grid.SetColumn(textStack, 0); Grid.SetColumn(badgeContainer, 1); - grid.Children.Add(_label); + grid.Children.Add(textStack); grid.Children.Add(badgeContainer); _rowBorder = new Border @@ -464,6 +530,37 @@ public void SetStatus(StepStatus status) } } + public void SetDetail( + string detail, + long completed, + long? total, + SetupDetailProgressUnit unit) + { + string measurement = unit switch + { + SetupDetailProgressUnit.Bytes when total is > 0 => + $"{FormatBytes(completed)} of {FormatBytes(total.Value)}", + SetupDetailProgressUnit.Items when total is > 0 => $"{completed} of {total.Value}", + _ => string.Empty, + }; + _detail.Text = string.IsNullOrWhiteSpace(measurement) ? detail : $"{detail} {measurement}"; + _detail.Visibility = Visibility.Visible; + if (total is > 0) + { + _detailProgress.Value = Math.Clamp((double)completed / total.Value, 0, 1); + _detailProgress.Visibility = Visibility.Visible; + } + else + { + _detailProgress.Visibility = Visibility.Collapsed; + } + } + + private static string FormatBytes(long bytes) => + bytes >= 1_000_000_000 + ? $"{bytes / 1_000_000_000d:0.0} GB" + : $"{bytes / 1_000_000d:0} MB"; + private static Border CreateEmptyBadge() { // Use a theme-aware stroke so the pending-step ring stays visible in every theme. diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml index 739bdd326..26c78111d 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml @@ -65,6 +65,25 @@ Style="{StaticResource CaptionTextBlockStyle}" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="A private, self-hosted OpenClaw gateway in an isolated Ubuntu WSL instance. We'll show exactly what gets installed before anything runs." /> + + + + + + + @@ -87,7 +106,7 @@ + Text="Already run an OpenClaw gateway: here, on another machine, or remote? Pair without installing a gateway or configuring Local AI on this PC." /> diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs index b50030fd2..7e08da64d 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs @@ -6,6 +6,7 @@ using OpenClaw.SetupEngine; using OpenClaw.SetupEngine.UI; using OpenClaw.Shared; +using OpenClaw.Shared.Inference.Catalog; using System.Numerics; namespace OpenClaw.SetupEngine.UI.Pages; @@ -42,6 +43,32 @@ protected override void OnNavigatedTo(NavigationEventArgs e) private void OnLoaded(object sender, RoutedEventArgs e) { StartMascotBreatheAnimation(); + AsyncEventHandlerGuard.Run( + DetectLocalAiAvailabilityAsync, + NullLogger.Instance, + nameof(DetectLocalAiAvailabilityAsync)); + } + + private async Task DetectLocalAiAvailabilityAsync() + { + SetupWindow? setupWindow = SetupWindow.Active; + SetupConfig? config = _config; + if (setupWindow is null || config is null) + return; + + var hardware = await setupWindow.GetLocalAiHardwareAsync(); + if (!IsLoaded || !ReferenceEquals(SetupWindow.Active, setupWindow)) + return; + + LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate( + hardware, + config.LocalAi.SelectedModelId); + if (!eligibility.CanInstall || eligibility.SelectedGpu is not { } gpu) + return; + + LocalAiAvailabilityText.Text = + $"{gpu.Name} detected. Install a local gateway to use local AI inference on this PC."; + LocalAiAvailabilityPanel.Visibility = Visibility.Visible; } private void StartMascotBreatheAnimation() diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs index eb93e7351..32fd42fd1 100644 --- a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Animation; +using OpenClaw.Shared.Inference; using OpenClaw.SetupEngine.UI.Pages; using System.Runtime.InteropServices; @@ -25,6 +26,8 @@ public sealed partial class SetupWindow : Window private bool _showStartupPreferenceOnComplete = true; private readonly string _dataDir; private readonly string _localDataDir; + private readonly object _localAiHardwareProbeLock = new(); + private Task? _localAiHardwareProbeTask; public static SetupWindow? Active { get; private set; } @@ -193,6 +196,16 @@ public SetupWindow( public void NavigateToWelcome(bool back = false) => NavigateTo(typeof(WelcomePage), _config, back); public bool IsWelcomeInstallSelected => _isWelcomeInstallSelected; public void SetWelcomeInstallSelected(bool installSelected) => _isWelcomeInstallSelected = installSelected; + + internal Task GetLocalAiHardwareAsync() + { + lock (_localAiHardwareProbeLock) + { + return _localAiHardwareProbeTask ??= + Task.Run(() => new NvmlHostHardwareProbe().Probe()); + } + } + public void NavigateToAdvancedSetup() => NavigateTo(typeof(AdvancedSetupPage), _config); public void NavigateToCapabilities() => NavigateTo(typeof(CapabilitiesPage), _config); public void NavigateToProgress() => NavigateTo(typeof(ProgressPage), CreateProgressPageArgs(showMilestoneOnly: false)); @@ -330,7 +343,10 @@ private void NavigatePreview(string page) => RootFrame.Navigate( "welcome" => typeof(WelcomePage), "advanced" => typeof(AdvancedSetupPage), "capabilities" => typeof(CapabilitiesPage), + "capabilities-review" => typeof(CapabilitiesPage), + "capabilities-review-consent" => typeof(CapabilitiesPage), "progress" => typeof(ProgressPage), + "progress-local-ai" => typeof(ProgressPage), "milestone" => typeof(ProgressPage), "wizard" => typeof(WizardPage), "wizard-error" => typeof(WizardPage), @@ -340,9 +356,14 @@ private void NavigatePreview(string page) => RootFrame.Navigate( }, page switch { - "complete" => new CompletePageArgs(true, TimeSpan.FromMinutes(3), null), + "complete" => new CompletePageArgs( + true, + TimeSpan.FromMinutes(3), + null, + ReviewSummary: SetupReviewSummaryBuilder.Build(_config, _dataDir, _localDataDir)), "complete-error" => new CompletePageArgs(false, TimeSpan.FromMinutes(3), null, "Setup could not finish. Review the details, then retry setup when you are ready."), "progress" => CreateProgressPageArgs(showMilestoneOnly: false), + "progress-local-ai" => CreateProgressPageArgs(showMilestoneOnly: false), "milestone" => CreateProgressPageArgs(showMilestoneOnly: true), _ => _config, }); diff --git a/src/OpenClaw.SetupEngine/SetupDetailProgress.cs b/src/OpenClaw.SetupEngine/SetupDetailProgress.cs new file mode 100644 index 000000000..ef875f32e --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupDetailProgress.cs @@ -0,0 +1,22 @@ +namespace OpenClaw.SetupEngine; + +public enum SetupDetailProgressUnit +{ + None = 0, + Bytes = 1, + Items = 2, +} + +public sealed record SetupDetailProgressEvent( + string StepId, + string Detail, + long Completed, + long? Total, + SetupDetailProgressUnit Unit); + +internal sealed class SynchronousProgress(Action callback) : IProgress +{ + private readonly Action _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + + public void Report(T value) => _callback(value); +} diff --git a/src/OpenClaw.SetupEngine/SetupReviewSummary.cs b/src/OpenClaw.SetupEngine/SetupReviewSummary.cs index 809d31688..c1f53770b 100644 --- a/src/OpenClaw.SetupEngine/SetupReviewSummary.cs +++ b/src/OpenClaw.SetupEngine/SetupReviewSummary.cs @@ -1,5 +1,7 @@ namespace OpenClaw.SetupEngine; +using OpenClaw.Shared.Inference.Catalog; + public sealed record SetupReviewSummary( string DistroTitle, string DistroDescription, @@ -8,7 +10,12 @@ public sealed record SetupReviewSummary( string GatewayDescription, string GatewayEndpoint, string ExactCommands, - string CompletionGatewaySummary); + string CompletionGatewaySummary) +{ + public bool LocalAiEnabled { get; init; } + public string? LocalAiTitle { get; init; } + public string? LocalAiDescription { get; init; } +} public static class SetupReviewSummaryBuilder { @@ -53,10 +60,22 @@ public static SetupReviewSummary Build(SetupConfig config, string? dataDir = nul : $" --node-version {GatewayReleasePolicy.NodeVersion}"; var installCommand = $"curl -fsSL --proto '=https' --tlsv1.2 | bash -s -- --version {release.Version}{runtimeArgument}"; + LocalModelInfo localAiModel = + LocalModelCatalog.Find(config.LocalAi.SelectedModelId) ?? LocalModelCatalog.Default; + string[] localAiCommands = config.LocalAi.Enabled + ? + [ + "download verified llama-server + CUDA runtime for Windows", + $"download {localAiModel.Weights.RelativePath} from Hugging Face revision " + + ((HuggingFaceRevisionSource)localAiModel.Weights.Source).RevisionSha, + $"llama-server router on dynamic 127.0.0.1 port; model loads on first request", + $"openclaw provider llamacpp -> /v1; primary llamacpp/{localAiModel.Id}", + ] + : []; - return new SetupReviewSummary( + var summary = new SetupReviewSummary( DistroTitle: $"Install an isolated {baseDistro} instance", - DistroDescription: $"WSL distro \"{distroName}\" at {installPath}. Separate from any Linux distributions you already have.", + DistroDescription: $"WSL distro \"{distroName}\" at {installPath}. Separate from any Linux distributions you already have. Disk use grows dynamically and is typically several GB.", InstallerDescription: installerDescription, InstallerBadge: installerBadge, GatewayDescription: gatewayDescription, @@ -74,10 +93,23 @@ public static SetupReviewSummary Build(SetupConfig config, string? dataDir = nul : "install signed Tailscale package · root owns tailscale up/serve" : null, "openclaw gateway install --force (systemd --user service)", + }.Concat(localAiCommands).Concat(new[] + { $"writes -> {installPath}", $"writes -> {gatewayDataPath} + identity" - }.Where(line => line is not null)), + }).Where(line => line is not null)), CompletionGatewaySummary: $"{distroName} · {gatewayEndpoint}"); + return summary with + { + LocalAiEnabled = config.LocalAi.Enabled, + LocalAiTitle = config.LocalAi.Enabled + ? $"Local AI verified with {localAiModel.DisplayName}" + : null, + LocalAiDescription = config.LocalAi.Enabled + ? "llama-server · " + + $"{localAiModel.Recipe.ContextTokens / 1024}K context · FP16 KV · full CUDA offload · loads on first request" + : null, + }; } private static string Display(string? value, string fallback) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index 89b07d062..45ccdc767 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -410,13 +410,15 @@ public void SetupReviewSummary_UsesActiveSetupConfig() Bind = "lan", InstallUrl = "https://example.test/install.sh", Version = GatewayReleasePolicy.SecurityFloor - } + }, + LocalAi = { Enabled = true } }; var summary = SetupReviewSummaryBuilder.Build(config); Assert.Contains("Debian", summary.DistroTitle); Assert.Contains("CustomClaw", summary.DistroDescription); + Assert.Contains("several GB", summary.DistroDescription); Assert.Contains("19999", summary.GatewayEndpoint); Assert.Contains("LAN bind enabled", summary.GatewayDescription); Assert.Contains("example.test", summary.InstallerDescription); @@ -424,6 +426,9 @@ public void SetupReviewSummary_UsesActiveSetupConfig() Assert.Contains("CustomClaw", summary.ExactCommands); Assert.Contains("19999", summary.ExactCommands); Assert.Equal("CustomClaw · LAN:19999", summary.CompletionGatewaySummary); + Assert.StartsWith("llama-server · ", summary.LocalAiDescription, StringComparison.Ordinal); + Assert.DoesNotContain("immutable revision", summary.LocalAiDescription, StringComparison.Ordinal); + Assert.DoesNotContain("llama-server b", summary.LocalAiDescription, StringComparison.Ordinal); } finally { From 366add1492eddc6d002e964bcb1e0aef895fec64 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Wed, 19 Aug 2026 07:57:32 -0700 Subject: [PATCH 17/23] feat(tray): add Local AI status and controls Add Local AI navigation, status, controls, logs, and localized resources. Wire the page through application services and retain focused UI contracts. Signed-off-by: Joel Fernandes --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 16 +- .../Pages/LocalAiPage.xaml | 119 ++++++++ .../Pages/LocalAiPage.xaml.cs | 110 +++++++ .../Presentation/AppServiceRegistration.cs | 2 + .../Presentation/HubPageRegistry.cs | 6 + .../Presentation/LocalAiPageViewModel.cs | 281 ++++++++++++++++++ .../Services/IAppCommands.cs | 1 + .../Strings/en-us/Resources.resw | 36 +++ .../Strings/fr-fr/Resources.resw | 36 +++ .../Strings/nl-nl/Resources.resw | 36 +++ .../Strings/zh-cn/Resources.resw | 36 +++ .../Strings/zh-tw/Resources.resw | 36 +++ .../Windows/HubWindow.xaml | 3 + .../LocalizationValidationTests.cs | 3 + .../OpenClaw.Tray.Tests.csproj | 1 + .../Presentation/HubPageRegistryTests.cs | 18 +- 16 files changed, 730 insertions(+), 10 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml create mode 100644 src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml.cs create mode 100644 src/OpenClaw.Tray.WinUI/Presentation/LocalAiPageViewModel.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 01fc05e50..abac3e5cf 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -78,6 +78,7 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands, IPer { [typeof(Pages.SettingsPage)] = typeof(SettingsPageViewModel), [typeof(Pages.PermissionsPage)] = typeof(PermissionsPageViewModel), + [typeof(Pages.LocalAiPage)] = typeof(LocalAiPageViewModel), }; /// The root service provider, or null before startup / after shutdown. @@ -3308,7 +3309,7 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) { if (_settings?.EnableNodeMode == true && _nodeService?.IsConnected == true) { - _appState!.LastCheckTime = DateTime.Now; + RecordHealthCheckTime(); OnUiThread(UpdateStatusDetailWindow); if (userInitiated) { @@ -3330,7 +3331,7 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) try { - _appState!.LastCheckTime = DateTime.Now; + RecordHealthCheckTime(); await client.CheckHealthAsync(); if (userInitiated) { @@ -3351,6 +3352,15 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) } } + private void RecordHealthCheckTime() + { + OnUiThread(() => + { + if (_appState is not null) + _appState.LastCheckTime = DateTime.Now; + }); + } + #endregion #region Tray Icon @@ -3897,6 +3907,8 @@ void IAppCommands.Disconnect() void IAppCommands.ShowChat() => ShowChatWindow(); void IAppCommands.CheckForUpdates() => _ = _updateCoordinator!.CheckForUpdatesUserInitiatedAsync(); void IAppCommands.ShowOnboarding() => _ = ShowOnboardingAsync(); + void IAppCommands.OpenLocalAiLogs() => + OpenFolder(new LocalAiPaths(AppIdentity.ResolveSetupLocalDataDirectory()).LogsDirectory, "Local AI logs"); void IAppCommands.ShowGatewayWizard() => _ = ShowGatewayWizardAsync(); void IAppCommands.ShowConnectionStatus() => ShowConnectionStatusWindow(); void IAppCommands.NotifySettingsSaved() => OnSettingsSaved(this, EventArgs.Empty); diff --git a/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml new file mode 100644 index 000000000..83157c6b6 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +