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