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/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/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.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..8e8b3343c --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs @@ -0,0 +1,813 @@ +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); + + 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 + { + 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 && + FindEndpointListeners(beforeStart, install.Manifest.RequestedPort).Count > 0) + { + return Publish(LocalAiRuntimeState.Conflict, LocalAiOwnership.None, "The configured llama-server port is already in use."); + } + + 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 (FindEndpointListeners(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(); + 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(); + 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 = 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)) + 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 (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 FindEndpointListeners( + WindowsTcpListenerSnapshotResult snapshot, + int port) => snapshot.Listeners + .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); + + 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..3522c72ee --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs @@ -0,0 +1,159 @@ +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 +{ + 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 + ?? 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 = ApiType, + apiKey, + 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 }, + api = ApiType, + }, + }, + }; + return JsonSerializer.Serialize(value); + } + + public static string BuildPrimaryModel(LocalAiResolvedInstall install) + { + ArgumentNullException.ThrowIfNull(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.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; + } +} diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs b/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs new file mode 100644 index 000000000..edb42ee91 --- /dev/null +++ b/src/OpenClaw.Connection/LocalAi/LocalAiManifest.cs @@ -0,0 +1,458 @@ +using System.Collections.Immutable; +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"); + RouterPresetPath = Path.Combine(RootDirectory, "llama-server-models.ini"); + 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 RouterPresetPath { 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 = 3; + 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; } + /// + /// 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. + /// + 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); + +/// 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 +{ + 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."); + + LocalAiPortPolicy.Validate(manifest.RequestedPort); + LocalAiGatewayModelPolicy.ValidateFallbackModel(manifest.GatewayFallbackModel); + + Uri? endpoint = null; + if (manifest.Endpoint is not null) + { + 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); + } + + 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."); + } + } + +} 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); +} diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml index 275bca9dc..05a4e49c9 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml @@ -73,6 +73,11 @@ + + @@ -104,11 +109,93 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs index fb288106e..8a7214447 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,18 @@ 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 HostHardwareInfo? _localAiHardware; + private string? _localAiRecommendedModelId; + private long? _localAiSelectedGpuCapacityBytes; + private WslGlobalConfigStatus? _localAiNetworkingStatus; + private string _localAiUnavailableReason = string.Empty; private bool _treatBundledAllOnAsPlaceholder; private int _step = 1; @@ -61,6 +74,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 +95,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 +163,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 +240,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 +263,337 @@ private void ApplySetupReviewSummary(SetupConfig config) ExactCommandsText.Text = summary.ExactCommands; } + private async Task InitializeLocalAiReviewAsync(bool forceNetworkingConsent) + { + SetupWindow? setupWindow = _setupWindow; + Task hardwareTask = setupWindow is not null + ? setupWindow.GetLocalAiHardwareAsync() + : Task.Run(() => new NvmlHostHardwareProbe().Probe()); + Task wslTask = setupWindow is not null + ? setupWindow.GetWslViabilityAsync() + : InspectWslViabilityAsync(); + + string? hardwareReason = null; + LocalInferenceEligibilityResult? eligibility = null; + try + { + _localAiHardware = await hardwareTask; + 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) + hardwareReason = DescribeLocalAiUnavailable(eligibility); + } + catch + { + hardwareReason = + "OpenClaw could not read the NVIDIA GPU, driver, CUDA, or memory information. " + + "Check the NVIDIA driver installation and try setup again."; + } + + WslViabilityResult wslViability; + try + { + wslViability = await wslTask; + } + catch + { + wslViability = new( + WslViabilityKind.InspectionFailed, + "OpenClaw could not safely verify the WSL2 environment.", + "Run wsl --status in PowerShell, resolve the reported problem, and try setup again."); + } + + string? wslNetworkingReason = null; + try + { + _localAiNetworkingStatus = forceNetworkingConsent + ? new(false, false) + : CreateWslGlobalConfigManager().Inspect(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + Debug.WriteLine($"WSL networking inspection failed: {ex}"); + wslNetworkingReason = + "OpenClaw cannot safely read the global .wslconfig file. " + + "Check that the file is valid and readable, then try setup again."; + } + + if (_setupWindow is null && setupWindow is not null) + return; + + string? unavailableReason = LocalAiAvailabilityReasons.Build( + hardwareReason, + wslViability, + wslNetworkingReason); + if (unavailableReason is not null) + { + ShowLocalAiUnavailable(unavailableReason); + return; + } + + Debug.Assert(eligibility is not null); + LocalAiInstallReviewCard.Visibility = Visibility.Visible; + LocalAiUnavailablePanel.Visibility = Visibility.Collapsed; + LocalAiToggle.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); + } + + private static async Task InspectWslViabilityAsync() + { + using var logger = new SetupLogger(filePath: null); + return await WslViabilityInspector.InspectAsync( + new CommandRunner(logger), + logger, + CancellationToken.None); + } + + private static WslGlobalConfigManager CreateWslGlobalConfigManager() + { + var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var configPath = Path.Combine(profile, ".wslconfig"); + var localDataDir = SetupWindow.Active?.LocalDataDir ?? SetupContext.ResolveLocalDataDir(); + return new WslGlobalConfigManager( + configPath, + Path.Combine(localDataDir, "LocalAI", "network-backup")); + } + + private void ShowLocalAiUnavailable(string reason) + { + _localAiSelectionEligible = false; + _suppressLocalAiToggle = true; + LocalAiToggle.IsOn = false; + _suppressLocalAiToggle = false; + LocalAiToggle.Visibility = Visibility.Collapsed; + LocalAiDetailsPanel.Visibility = Visibility.Collapsed; + _localAiUnavailableReason = reason; + LocalAiUnavailablePanel.Visibility = Visibility.Visible; + LocalAiInstallReviewCard.Visibility = Visibility.Visible; + _config!.LocalAi.Enabled = false; + _config.SkipWizard = _skipWizardWithoutLocalAi; + ApplySetupReviewSummary(_config); + } + + private static string DescribeLocalAiUnavailable(LocalInferenceEligibilityResult eligibility) => + eligibility.SelectionFailureCode switch + { + LocalInferenceSelectionFailureCode.RuntimeUnavailable => + "This Local AI release does not include a native llama-server runtime for the detected Windows architecture.", + LocalInferenceSelectionFailureCode.NoNvidiaGpu => + "No NVIDIA GPU was reported by the NVIDIA driver. Install or repair the NVIDIA driver, then try setup again.", + LocalInferenceSelectionFailureCode.UnknownModel => + "The selected model is not available in this Local AI release.", + _ => eligibility.FailureCode switch + { + LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete => + "OpenClaw could not read a stable NVIDIA GPU identifier, memory, driver, or CUDA capability.", + LocalInferenceEligibilityFailureCode.InsufficientGpuMemory => + $"{eligibility.Plan?.Model.DisplayName ?? "The selected model"} requires " + + $"{FormatSize(eligibility.RequiredTotalMemoryBytes)} of GPU memory, including the 2 GiB runtime margin. " + + $"OpenClaw detected {FormatOptionalSize(eligibility.DetectedTotalMemoryBytes)}.", + LocalInferenceEligibilityFailureCode.DriverTooOld => + $"NVIDIA driver {eligibility.SelectedGpu?.DriverVersion ?? "unknown"} was detected. " + + $"Local AI requires version {LocalInferenceEligibility.MinimumNvidiaDriverVersion} or newer.", + LocalInferenceEligibilityFailureCode.CudaCapabilityTooLow => + "The NVIDIA driver does not provide CUDA 13 support. A separate CUDA Toolkit is not required.", + _ => "OpenClaw could not verify the Local AI requirements on this system.", + }, + }; + + private void LocalAiUnavailableDetails_Click(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + ShowLocalAiUnavailableDetailsAsync, + NullLogger.Instance, + nameof(LocalAiUnavailableDetails_Click)); + + private async Task ShowLocalAiUnavailableDetailsAsync() + { + var xamlRoot = LocalAiInstallReviewCard.XamlRoot; + if (xamlRoot is null) + return; + + var dialog = new ContentDialog + { + XamlRoot = xamlRoot, + Title = "Why Local AI is unavailable", + Content = new TextBlock + { + Text = _localAiUnavailableReason, + TextWrapping = TextWrapping.Wrap, + }, + CloseButtonText = "Close", + }; + await dialog.ShowAsync(); + } + + 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; + + if (!enabled) + { + LocalAiNetworkingConsentPanel.Visibility = Visibility.Collapsed; + SetLocalAiNetworkingConsent(false); + config.LocalAi.WslMirroredNetworkingConsent = false; + UpdatePrimaryButtonState(); + return; + } + + UpdateLocalAiModelDetails(); + WslGlobalConfigStatus status = forceNetworkingConsent + ? new(false, false) + : _localAiNetworkingStatus ?? new(false, false); + _localAiNetworkingConsentRequired = !status.IsMirrored; + LocalAiNetworkingConsentPanel.Visibility = _localAiNetworkingConsentRequired + ? Visibility.Visible + : Visibility.Collapsed; + SetLocalAiNetworkingConsent(false); + config.LocalAi.WslMirroredNetworkingConsent = false; + 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.", + _ => DescribeLocalAiUnavailable(eligibility), + }; + 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 && + (!_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 +133,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 +181,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 +299,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 +392,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 +409,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 +425,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 +477,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 +531,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..96d0ca872 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml @@ -54,6 +54,11 @@ Text="Install a local gateway (WSL)" Style="{StaticResource BodyStrongTextBlockStyle}" VerticalAlignment="Center" /> + + + + + + + + @@ -87,7 +111,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..7c74afe20 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() @@ -120,14 +147,39 @@ private async Task StartInstallAsync() NextButton.IsEnabled = false; InstallTitle.Text = CheckingButtonText; + InstallCheckProgress.IsActive = true; + InstallCheckProgress.Visibility = Visibility.Visible; var navigating = false; try { - var existing = await Task.Run(() => ExistingConfigDetector.Detect(dataDir, config.DistroName)); + ExistingConfigDetector.ExistingConfig existing; + try + { + existing = await Task.Run(() => ExistingConfigDetector.Detect(dataDir, config.DistroName)); + } + catch (InvalidOperationException ex) + { + var errorRoot = XamlRoot; + if (setupWindow is not null and { IsClosed: false } && errorRoot is not null) + { + await new ContentDialog + { + Title = "Could not inspect WSL", + Content = ex.Message, + CloseButtonText = "Close", + XamlRoot = errorRoot, + }.ShowAsync(); + } + return; + } + var xamlRoot = XamlRoot; if (setupWindow is null or { IsClosed: true } || xamlRoot is null) return; + InstallTitle.Text = InstallButtonText; + InstallCheckProgress.IsActive = false; + InstallCheckProgress.Visibility = Visibility.Collapsed; var summary = ExistingConfigDetector.BuildReplacementSummary(existing); var dialog = new ContentDialog @@ -154,6 +206,8 @@ private async Task StartInstallAsync() if (!navigating && setupWindow is { IsClosed: false }) { InstallTitle.Text = InstallButtonText; + InstallCheckProgress.IsActive = false; + InstallCheckProgress.Visibility = Visibility.Collapsed; NextButton.IsEnabled = true; } } diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs index eb93e7351..8254a003c 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,10 @@ 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; + private readonly object _wslViabilityLock = new(); + private Task? _wslViabilityTask; public static SetupWindow? Active { get; private set; } @@ -193,6 +198,33 @@ 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()); + } + } + + internal Task GetWslViabilityAsync() + { + lock (_wslViabilityLock) + { + return _wslViabilityTask ??= InspectWslViabilityAsync(); + } + } + + private static async Task InspectWslViabilityAsync() + { + using var logger = new SetupLogger(filePath: null); + return await WslViabilityInspector.InspectAsync( + new CommandRunner(logger), + logger, + CancellationToken.None); + } + 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 +362,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 +375,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/CommandRunner.cs b/src/OpenClaw.SetupEngine/CommandRunner.cs index a1b34a470..1f1eaae16 100644 --- a/src/OpenClaw.SetupEngine/CommandRunner.cs +++ b/src/OpenClaw.SetupEngine/CommandRunner.cs @@ -70,7 +70,7 @@ Task RunInWslAsync( public sealed class CommandRunner : ICommandRunner { private readonly SetupLogger _logger; - private const int DrainTimeoutMs = 5000; // bounded drain for orphan WSL processes + private static readonly TimeSpan s_outputDrainGrace = TimeSpan.FromMilliseconds(250); private const int MaxCapturedStreamChars = 1_048_576; public CommandRunner(SetupLogger logger) => _logger = logger; @@ -119,10 +119,20 @@ public async Task RunAsync( using var process = new Process { StartInfo = psi }; var stdout = new BoundedOutputBuffer(MaxCapturedStreamChars); var stderr = new BoundedOutputBuffer(MaxCapturedStreamChars); + var stdoutClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stderrClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var timedOut = false; - process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + process.OutputDataReceived += (_, e) => + { + if (e.Data is null) stdoutClosed.TrySetResult(); + else stdout.AppendLine(e.Data); + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is null) stderrClosed.TrySetResult(); + else stderr.AppendLine(e.Data); + }; try { @@ -167,7 +177,7 @@ public async Task RunAsync( } } - await process.WaitForExitAsync(timeoutCts.Token); + await WaitForProcessExitOnlyAsync(process, timeoutCts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { @@ -182,10 +192,12 @@ public async Task RunAsync( throw; } - // Flush async output handlers. WaitForExitAsync observes process exit, but the - // OutputDataReceived/ErrorDataReceived callbacks can still be draining. - if (!timedOut) - process.WaitForExit(DrainTimeoutMs); + // A surviving descendant can keep inherited pipe handles open after the child + // exits. Preserve output already in flight without charging the command's full + // timeout to an EOF that may never arrive. + await Task.WhenAny( + Task.WhenAll(stdoutClosed.Task, stderrClosed.Task), + Task.Delay(s_outputDrainGrace)); sw.Stop(); var result = new CommandResult( @@ -279,6 +291,27 @@ public Task RunInWslAsync( return RunAsync("wsl.exe", args.ToArray(), timeout, env, ct: ct); } + private static async Task WaitForProcessExitOnlyAsync(Process process, CancellationToken ct) + { + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnExited(object? sender, EventArgs e) => exited.TrySetResult(); + + process.EnableRaisingEvents = true; + process.Exited += OnExited; + try + { + if (!process.HasExited) + { + using var registration = ct.Register(() => exited.TrySetCanceled(ct)); + await exited.Task; + } + } + finally + { + process.Exited -= OnExited; + } + } + private static void TryKill(Process process) { try { process.Kill(entireProcessTree: true); } diff --git a/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs b/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs index 32d7e79fe..b6daaea90 100644 --- a/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs +++ b/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs @@ -29,28 +29,12 @@ public static ExistingConfig Detect(string dataDir, string targetDistroName) var localRecord = all.FirstOrDefault(r => r.IsLocal && r.SshTunnel == null); var preserved = all.Where(r => !r.IsLocal || r.SshTunnel != null).ToList(); - var hasDistro = false; - try - { - var psi = new System.Diagnostics.ProcessStartInfo("wsl.exe", "--list --quiet") - { - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using var proc = System.Diagnostics.Process.Start(psi); - if (proc != null) - { - var output = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(5000); - hasDistro = WslInstallSupport.ContainsDistro(output, targetDistroName); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"WSL distro detection failed: {ex.Message}"); - } + var logger = new SetupLogger(filePath: null, LogLevel.Warn); + var result = new CommandRunner(logger) + .RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(5)) + .GetAwaiter() + .GetResult(); + var hasDistro = InterpretDistroList(result, targetDistroName); var hasIdentity = false; if (localRecord != null) @@ -70,6 +54,19 @@ public static ExistingConfig Detect(string dataDir, string targetDistroName) PreservedGatewayNames: preserved.Select(r => r.FriendlyName ?? r.Url).ToList()); } + internal static bool InterpretDistroList(CommandResult result, string targetDistroName) + { + if (!result.TimedOut && result.ExitCode == 0) + return WslInstallSupport.ContainsDistro(result.Stdout, targetDistroName); + + if (!result.TimedOut && WslViabilityInspector.LooksUnavailable(result)) + return false; + + throw new InvalidOperationException( + "OpenClaw could not safely inspect existing WSL distributions. " + + "Run `wsl --list --quiet` in PowerShell, resolve the reported problem, and try again."); + } + /// /// Build a human-readable summary of what will happen during setup. /// 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. + } + } +} 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. + } + } +} 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); +} 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/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/LocalAiGpuVerification.cs b/src/OpenClaw.SetupEngine/LocalAiGpuVerification.cs new file mode 100644 index 000000000..9c29eec43 --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiGpuVerification.cs @@ -0,0 +1,366 @@ +using System.Diagnostics; +using System.Globalization; +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, + long? CudaModelBufferBytes) +{ + 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); + LocalAiGpuLogEvidence logEvidence = await ReadGpuLoadEvidenceAsync(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, + logEvidence.OffloadedLayers, + logEvidence.TotalLayers, + after.GpuVisibleMemoryBytes.Value, + before.FreeGpuVisibleMemoryBytes.Value, + after.FreeGpuVisibleMemoryBytes.Value, + logEvidence.CudaModelBufferBytes); + } + + internal static (int Offloaded, int Total) ParseFullOffloadEvidence(string log) + { + LocalAiGpuLogEvidence evidence = ParseGpuLoadEvidence(log); + return (evidence.OffloadedLayers, evidence.TotalLayers); + } + + internal static LocalAiGpuLogEvidence ParseGpuLoadEvidence(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 new LocalAiGpuLogEvidence( + offloaded, + total, + ParseCudaModelBufferBytes(log)); + } + } + 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 ReadGpuLoadEvidenceAsync( + 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 ParseGpuLoadEvidence(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(); + + [GeneratedRegex(@"CUDA\d+\s+model buffer size\s*=\s*([0-9]+(?:\.[0-9]+)?)\s+MiB", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CudaModelBufferPattern(); + + private static long? ParseCudaModelBufferBytes(string log) + { + Match? match = CudaModelBufferPattern().Matches(log).Cast().LastOrDefault(); + if (match is null || + !double.TryParse( + match.Groups[1].Value, + NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out double mebibytes) || + mebibytes <= 0) + { + return null; + } + + double bytes = mebibytes * 1024 * 1024; + return bytes <= long.MaxValue ? (long)bytes : null; + } +} + +internal sealed record LocalAiGpuLogEvidence( + int OffloadedLayers, + int TotalLayers, + long? CudaModelBufferBytes); + +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 (!HasRequiredGpuLoadEvidence(evidence, 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); + + internal static bool HasRequiredGpuLoadEvidence( + LocalAiGpuLoadEvidence evidence, + long minimumDeltaBytes) + { + ArgumentNullException.ThrowIfNull(evidence); + if (minimumDeltaBytes <= 0 || evidence.OffloadedLayers != evidence.TotalLayers) + return false; + + if (evidence.LoadDeltaBytes >= minimumDeltaBytes) + return true; + + return evidence.CudaModelBufferBytes is { } cudaModelBufferBytes && + cudaModelBufferBytes >= minimumDeltaBytes; + } +} 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 new file mode 100644 index 000000000..1bbc92b31 --- /dev/null +++ b/src/OpenClaw.SetupEngine/LocalAiSetupSteps.cs @@ -0,0 +1,936 @@ +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; + +/// +/// 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; + + public PreflightLocalAiHardwareStep() + : this(new NvmlHostHardwareProbe()) + { + } + + internal PreflightLocalAiHardwareStep(IHostHardwareProbe hardwareProbe) => + _hardwareProbe = hardwareProbe ?? throw new ArgumentNullException(nameof(hardwareProbe)); + + 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 (!LocalAiPortPolicy.TryValidate(ctx.Config.LocalAi.Port, out string? portError)) + return Task.FromResult(StepResult.Terminal(portError ?? "Local inference port selection failed.")); + + ctx.LocalAiPort = ctx.Config.LocalAi.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, + requestedPort = ctx.Config.LocalAi.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}."); + } + } +} + +/// 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 +{ + 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.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) + 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 + { + 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, + 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.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) + 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 + { + 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, + 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; + } + 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/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/PreflightWslStep.cs b/src/OpenClaw.SetupEngine/PreflightWslStep.cs index 9b45d6a8a..83d777438 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 and verify it before downloading Local AI."); + } + 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); + } - ctx.Logger.Info($"WSL version output: {NormalizeWslOutput(versionResult.Stdout).Trim()}"); - ctx.Logger.Info($"WSL direct named install is supported (version {wslVersion})"); + 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); + } - // 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); + internal static string NormalizeWslOutput(string value) => WslInstallSupport.Normalize(value); +} - return StepResult.Ok("WSL available"); +public sealed class PreflightWslStep : SetupStep +{ + public override string Id => "preflight-wsl"; + public override string DisplayName => "Inspect WSL compatibility"; + public override bool CanRetry => false; + + 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 @@ -102,11 +221,21 @@ private static async Task InstallWslPlatformAsync(SetupContext ctx, return StepResult.Terminal("WSL platform install requires a restart. Reboot Windows, then run setup again."); if (process.ExitCode != 0) - return StepResult.Fail($"WSL platform install failed with exit code {process.ExitCode}."); + { + GitHubApiQuota? quota = await WslPlatformInstallDiagnostics.QueryGitHubQuotaAsync(ct); + return StepResult.Fail(WslPlatformInstallDiagnostics.DescribeFailure(process.ExitCode, quota)); + } - 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"); } @@ -114,36 +243,59 @@ private static async Task InstallWslPlatformAsync(SetupContext ctx, { return StepResult.Fail("WSL platform install was cancelled at the elevation prompt."); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception ex) { 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 read-only hardware and WSL inspection, +/// before Local AI downloads begin. +/// +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 => true; + + 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/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 dea2a4379..81aa1c29f 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -2,7 +2,10 @@ 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; namespace OpenClaw.SetupEngine; @@ -38,8 +41,9 @@ 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}"; + public string EffectiveGatewayUrl => GatewayUrl ?? $"ws://127.0.0.1:{GatewayPort}"; public static SetupConfig LoadFromFile(string path) { @@ -130,6 +134,20 @@ 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 int InferenceTimeoutSeconds { get; set; } = 600; +} + public sealed class WslConfig { private static readonly System.Text.RegularExpressions.Regex s_linuxUserNamePattern = @@ -459,8 +477,23 @@ 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; } + 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; } + 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/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/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs index 21e9c5676..0761aff8b 100644 --- a/src/OpenClaw.SetupEngine/SetupPipeline.cs +++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs @@ -54,8 +54,19 @@ public static List BuildDefaultSteps() [ new ValidateDistroInstallPathStep(), new PreflightOsStep(), + new PreflightLocalAiHardwareStep(), new PreflightWslStep(), new PreflightWindowsTailscaleStep(), + new EnsureWslPlatformStep(), + new ReconcileLocalAiInstallationStep(), + new AcquireLocalAiRuntimeStep(), + new AcquireLocalAiModelStep(), + new PersistLocalAiManifestStep(), + new StartLocalAiRuntimeStep(), + new CaptureLocalAiGpuBaselineStep(), + new VerifyLocalAiInferenceStep(), + new VerifyLocalAiGpuLoadStep(), + new ConfigureLocalAiWslNetworkingStep(), new CleanupStaleDistroStep(), new CleanupStaleGatewayStep(), new PreflightPortStep(), @@ -63,9 +74,11 @@ public static List BuildDefaultSteps() new ConfigureWslInstanceStep(), new ValidateWslLockdownStep(), new InstallCliStep(), + new VerifyLocalAiWslStep(), new InstallTailscaleStep(), new AuthorizeTailscaleStep(), new ConfigureGatewayStep(), + new ConfigureLocalAiGatewayStep(), new InstallGatewayServiceStep(), new StartGatewayStep(), new FinalizeTailscaleServeStep(), @@ -310,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/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/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/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/WslPlatformInstallDiagnostics.cs b/src/OpenClaw.SetupEngine/WslPlatformInstallDiagnostics.cs new file mode 100644 index 000000000..e565c8113 --- /dev/null +++ b/src/OpenClaw.SetupEngine/WslPlatformInstallDiagnostics.cs @@ -0,0 +1,67 @@ +using System.Text.Json; + +namespace OpenClaw.SetupEngine; + +internal sealed record GitHubApiQuota(int Limit, int Remaining, DateTimeOffset ResetsAt) +{ + public bool IsExhausted => Remaining <= 0; + public int Used => Math.Max(0, Limit - Remaining); +} + +internal static class WslPlatformInstallDiagnostics +{ + private const string RateLimitUrl = "https://api.github.com/rate_limit"; + private const string WslStoreProductId = "9P9TQF7MRM4R"; + + public static string SelfInstallInstructions => + "Install WSL yourself, then run setup again:" + Environment.NewLine + + $" Microsoft Store: {WslInstallSupport.UpdateUrl}" + Environment.NewLine + + $" Or run: winget install --id {WslStoreProductId} --source msstore" + Environment.NewLine + + " Or, in elevated PowerShell: wsl --install --no-distribution" + Environment.NewLine + + "Reboot if Windows asks for one."; + + public static string DescribeFailure(int exitCode, GitHubApiQuota? quota) + { + string reason = quota is { IsExhausted: true } + ? $"The WSL installer may need GitHub, and this network's unauthenticated API quota " + + $"is exhausted ({quota.Used}/{quota.Limit}) until {quota.ResetsAt.ToLocalTime():HH:mm}." + : "The WSL download did not complete. A network, policy, or installer error may be blocking it."; + + return $"WSL platform install failed with exit code {exitCode}. {reason}" + + Environment.NewLine + Environment.NewLine + SelfInstallInstructions; + } + + public static async Task QueryGitHubQuotaAsync(CancellationToken ct) + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + return await QueryGitHubQuotaAsync(http, ct); + } + + internal static async Task QueryGitHubQuotaAsync(HttpClient http, CancellationToken ct) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, RateLimitUrl); + request.Headers.UserAgent.ParseAdd("OpenClawSetup"); + using var response = await http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + return null; + + await using var body = await response.Content.ReadAsStreamAsync(ct); + using var json = await JsonDocument.ParseAsync(body, cancellationToken: ct); + JsonElement core = json.RootElement.GetProperty("resources").GetProperty("core"); + return new( + core.GetProperty("limit").GetInt32(), + core.GetProperty("remaining").GetInt32(), + DateTimeOffset.FromUnixTimeSeconds(core.GetProperty("reset").GetInt64())); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return null; + } + } +} diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index 85445d551..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": { @@ -100,6 +100,21 @@ "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, + // Includes first-request model load and a short verification completion. + "InferenceTimeoutSeconds": 600 + }, + // โ”€โ”€โ”€ 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/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/LlamaRuntimeCatalog.cs b/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs new file mode 100644 index 000000000..0266f51dd --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/LlamaRuntimeCatalog.cs @@ -0,0 +1,125 @@ +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 routed by Windows CPU +/// architecture. Unsupported hardware does not receive a CPU or Vulkan fallback. +/// +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/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..dbe9c5fdf --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceSelector.cs @@ -0,0 +1,160 @@ +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 + : SaturatingAdd( + gpu.GpuVisibleMemoryBytes.Value, + gpu.SharedGpuMemoryBytes is > 0 ? gpu.SharedGpuMemoryBytes.Value : 0); + + 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) && + !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/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 '.'); +} 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 b06fb41fa..dea91bc7b 100644 --- a/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs +++ b/src/OpenClaw.Shared/Inference/HostHardwareInfo.cs @@ -24,28 +24,40 @@ 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. +/// +/// 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 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, + long? SharedGpuMemoryBytes = null, + long? FreeSharedGpuMemoryBytes = 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 +72,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 +88,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..7f6aaa089 --- /dev/null +++ b/src/OpenClaw.Shared/Inference/NvmlHostHardwareProbe.cs @@ -0,0 +1,328 @@ +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 Func> _captureDxgiMemory; + private readonly Architecture _architecture; + + public NvmlHostHardwareProbe() + : 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; + } + + public HostHardwareInfo Probe() + { + PhysicalMemorySnapshot? memory = null; + NvmlProbeResult nvml = NvmlProbeResult.Empty; + IReadOnlyDictionary dxgiMemoryByName = + new Dictionary(StringComparer.OrdinalIgnoreCase); + 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. + } + + 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)) + .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( + _architecture, + memory?.TotalBytes, + memory?.AvailableBytes, + gpus, + 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 = + [ + 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); +} 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..abac3e5cf 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 @@ -76,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. @@ -457,7 +460,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 +726,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 +963,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 @@ -3253,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) { @@ -3275,7 +3331,7 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) try { - _appState!.LastCheckTime = DateTime.Now; + RecordHealthCheckTime(); await client.CheckHealthAsync(); if (userInitiated) { @@ -3296,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 @@ -3842,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/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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +