Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ The first version is for a machine with one user: on a shared machine, other OS
- Without Ollama, setup carries on and search works by keyword; re-run `kcap setup --local` once Ollama runs. A failed pull or a model that cannot embed is reported the same way and never fails the install.

- A machine that already has a profile for another server is refused for now.
- Every kcap that talks to the local server must be newer than 1.3.0, which does not keep to its rules. Setup asks the `kcap` on `PATH` and the desktop app's bundled one (in `/Applications` or `~/Applications`) for their versions, and stops if either is 1.3.0 or older, or does not report one: update it, or remove the one on `PATH`. The local server refuses an older kcap too, so its hooks and watcher record nothing there.
- Builds of kcap for musl Linux are not supported.
- `--local` cannot be combined with `<tenant>`, `--server-url`, `--discover`, `--org`, `--slug`, `--tier`, `--invite-code`, `--device`, `--github` or `--muse-assistant`, and is refused while `KCAP_URL` is set.

Expand Down
13 changes: 9 additions & 4 deletions src/Capacitor.App/Services/ServerConnectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Runtime.CompilerServices;
using System.Net.WebSockets;
using System.Text.Json;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Auth;
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
Expand Down Expand Up @@ -78,17 +79,20 @@ credentials is null
? () => Task.FromResult<string?>(null)
: async () => (await credentials.ResolveAsync(default)).Bearer,
localRoot: localRoot,
refusal: profiles is null ? null : LocalDestination.Refusal(profiles.Resolution.ServerUrl, profiles.Resolution.Endpoint)) { }
refusal: profiles is null ? null : LocalDestination.Refusal(profiles.Resolution.ServerUrl, profiles.Resolution.Endpoint),
updateCheckOff: profiles?.Effective?.UpdateCheck == false) { }

readonly LocalRoot? _localRoot;
readonly string? _refusal;
readonly bool _updateCheckOff;

internal ServerConnectionService(
TimeProvider time, string? serverUrl, Func<Task<string?>> accessTokenProvider,
TimeSpan[]? reconnectDelays = null, Action<ILoggingBuilder>? hubLogging = null,
LocalRoot? localRoot = null, string? refusal = null) {
_localRoot = localRoot;
_refusal = refusal;
LocalRoot? localRoot = null, string? refusal = null, bool updateCheckOff = false) {
_localRoot = localRoot;
_refusal = refusal;
_updateCheckOff = updateCheckOff;
_time = time;
_serverUrl = string.IsNullOrEmpty(serverUrl) ? null : serverUrl.TrimEnd('/');
_token = accessTokenProvider;
Expand Down Expand Up @@ -288,6 +292,7 @@ HubConnection Build() {
var builder = new HubConnectionBuilder()
.WithUrl($"{_serverUrl}/hubs/sessions", o => {
o.AccessTokenProvider = _token;
HttpClientExtensions.AddObservationHeaders(o.Headers, _updateCheckOff);
// Every negotiation and every WebSocket upgrade, reconnects included.
// The guard follows redirects itself, so it can check each hop; the handler under it follows none.
o.HttpMessageHandlerFactory = inner => new LocalDestinationHandler(_localRoot, _serverUrl, _refusal, followRedirects: true) {
Expand Down
16 changes: 16 additions & 0 deletions src/Capacitor.Cli.Core/HttpClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ public static class HttpClientExtensions {
/// <summary>Wire header naming the installed CLI's display version (see <see cref="CapacitorVersion.CurrentDisplay"/>).</summary>
public const string CliVersionHeader = "X-Kcap-Cli-Version";

/// <summary>The <see cref="CliVersionHeader"/> value this process sends, or null when it has none to send.</summary>
public static string? CliVersion => ObservationHeaderHandler.ProcessVersion;

/// <summary>
/// Adds what <see cref="ObservationHeaderHandler"/> adds to a request to a hub connection's headers, which SignalR
/// sends on negotiation. A local server refuses a hub negotiation without the version, since that is how a kcap
/// below its client floor arrives; and a server reads a version without <see cref="UpdateCheckHeader"/> as the
/// update check being on, so an opt-out must ride along.
/// </summary>
public static void AddObservationHeaders(IDictionary<string, string> headers, bool updateCheckOff) {
if (CliVersion is not { } version) return;

headers[CliVersionHeader] = version;
if (updateCheckOff) headers[UpdateCheckHeader] = UpdateCheckOffValue;
}

/// <summary>Response header carrying the connected server's own version, captured by
/// <see cref="ServerVersionCaptureHandler"/> so the passive update notice can cap its
/// recommendation at <c>min(npm latest, server version)</c>.</summary>
Expand Down
16 changes: 16 additions & 0 deletions src/Capacitor.Cli.Core/LocalEdition/LocalClientFloor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Capacitor.Cli.Core.LocalEdition;

/// <summary>
/// The client floor of a local installation, for the CLI, the daemon and the desktop app alike: a kcap must be newer
/// than <see cref="NewestRefused"/>. An older copy does not know the local rules, so it would post past the
/// destination check and spool where nothing drains. The local server refuses by the same number, so the two must
/// move together.
/// </summary>
public static class LocalClientFloor {
/// <summary>The newest release that does not keep to the local rules. Every build after it, prereleases
/// included, is admitted.</summary>
public const string NewestRefused = "1.3.0";

/// <summary>A version that cannot be read is not admitted.</summary>
public static bool Admits(string? version) => PrereleaseSemver.Compare(version, NewestRefused) > 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public enum LocalInstallFailureCode {
NotOurServer,
CommitFailed,
RecoveryFailed,
Cancelled
Cancelled,
ClientBelowFloor
}

public static class LocalInstallFailureCodeExtensions {
Expand All @@ -44,6 +45,7 @@ public static class LocalInstallFailureCodeExtensions {
LocalInstallFailureCode.CommitFailed => "commit_failed",
LocalInstallFailureCode.RecoveryFailed => "recovery_failed",
LocalInstallFailureCode.Cancelled => "cancelled",
LocalInstallFailureCode.ClientBelowFloor => "client_below_floor",
_ => throw new ArgumentOutOfRangeException(nameof(code), code, null)
};
}
3 changes: 3 additions & 0 deletions src/Capacitor.Cli.Daemon/DaemonConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ public ProfileContext Profiles {
/// was resolved.</summary>
public string? DestinationRefusal => _profiles is null ? null : LocalDestination.Refusal(ServerUrl, _profiles.Resolution.Endpoint);

/// <summary>Whether the resolved profile opted out of the update check; false when nothing was resolved.</summary>
public bool UpdateCheckOff => _profiles?.Effective?.UpdateCheck == false;

/// <summary>Phase B (D4): a fresh per-boot epoch (GUID). Written into each spawned child's
/// <c>KCAP_DAEMON_EPOCH</c> env marker; the startup env-marker scan kills same-daemon children
/// whose epoch differs from the current one (i.e. survivors of a prior incarnation). Null → the
Expand Down
1 change: 1 addition & 0 deletions src/Capacitor.Cli.Daemon/Services/ServerConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ DelegatingHandler Guarded(HttpMessageHandler inner) =>
options => {
options.AccessTokenProvider = async () => (await credentials.ResolveAsync(default)).Bearer;
options.HttpMessageHandlerFactory = Guarded;
HttpClientExtensions.AddObservationHeaders(options.Headers, config.UpdateCheckOff);
options.WebSocketFactory = (context, ct) => {
LocalDestination.Check(context.Uri, config.LocalRoot, config.ServerUrl, refusal);

Expand Down
1 change: 1 addition & 0 deletions src/Capacitor.Cli/Commands/WatchCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ internal HubConnection BuildHubConnection(string hubUrl) {
options.AccessTokenProvider = async () => (await credentials.ResolveAsync(default)).Bearer;
options.HttpMessageHandlerFactory = inner =>
new BearerRejectionReportHandler(time, report => Log(time, report)) { InnerHandler = inner };
HttpClientExtensions.AddObservationHeaders(options.Headers, profiles.Effective?.UpdateCheck == false);
options.WebSocketFactory = (context, ct) => BearerRejectionReportHandler.ConnectWebSocketAsync(
context.Uri, context.Options.AccessTokenProvider, time, report => Log(time, report), ct);
}
Expand Down
4 changes: 4 additions & 0 deletions src/Capacitor.Cli/LocalEdition/ClientCopy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Capacitor.Cli.LocalEdition;

/// <param name="Version">Null when the copy did not report one.</param>
sealed record ClientCopy(ClientCopyKind Kind, string Path, string? Version);
6 changes: 6 additions & 0 deletions src/Capacitor.Cli/LocalEdition/ClientCopyKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Capacitor.Cli.LocalEdition;

enum ClientCopyKind {
OnPath,
DesktopApp,
}
6 changes: 6 additions & 0 deletions src/Capacitor.Cli/LocalEdition/ILocalClientCopies.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Capacitor.Cli.LocalEdition;

/// <summary>The other copies of kcap on this machine that would talk to a local installation.</summary>
interface ILocalClientCopies {
IReadOnlyList<ClientCopy> Find();
}
66 changes: 66 additions & 0 deletions src/Capacitor.Cli/LocalEdition/LocalClientCopies.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Setup;

namespace Capacitor.Cli.LocalEdition;

/// <summary>
/// The kcap that <c>PATH</c> runs and the desktop app's bundled one, each asked for its version. The running kcap is
/// left out, and a copy reached by both is counted once, as the app's: updating the app is what moves it.
/// </summary>
sealed class LocalClientCopies(IProcessRunner runner, string? searchPath, string? self, IReadOnlyList<string> appClis) : ILocalClientCopies {
static readonly TimeSpan VersionTimeout = TimeSpan.FromSeconds(10);

const int OutputLimit = 4096;

const string AppBundle = "Kurrent Capacitor.app";

IReadOnlyList<ClientCopy>? _found;

public static LocalClientCopies ForCurrentProcess(UserHome home, TimeProvider time) {
string[] apps = OperatingSystem.IsMacOS()
? [AppCli("/Applications"), AppCli(Path.Combine(home.Path, "Applications"))]
: [];

return new(new ProcessRunner(time), Environment.GetEnvironmentVariable("PATH"), Environment.ProcessPath, apps);
}

static string AppCli(string applications) => Path.Combine(applications, AppBundle, "Contents", "MacOS", "kcap");

public IReadOnlyList<ClientCopy> Find() => _found ??= Locate().Select(c => c with { Version = VersionOf(c.Path) }).ToList();

IEnumerable<ClientCopy> Locate() {
var seen = new HashSet<string>(StringComparer.Ordinal);
if (self is not null) seen.Add(CanonicalPath.Resolve(self));

foreach (var app in appClis.Where(File.Exists)) {
if (seen.Add(CanonicalPath.Resolve(app))) yield return new(ClientCopyKind.DesktopApp, app, null);
}

if (BinaryProbe.Searching(searchPath).Resolve("kcap") is { } onPath && seen.Add(CanonicalPath.Resolve(onPath)))
yield return new(ClientCopyKind.OnPath, onPath, null);
}

/// <summary>Without <c>--no-update-check</c> the copy would run its npm update check on the way out.</summary>
string? VersionOf(string path) {
ProcessResult result;

try {
result = runner.RunBoundedAsync(path, ["--version", "--no-update-check"], new(Timeout: VersionTimeout), OutputLimit, CancellationToken.None)
.GetAwaiter().GetResult();
} catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) {
return null;
}

return result is { ExitCode: 0, TimedOut: false, Oversized: false } ? Parse(result.Stdout) : null;
}

/// <summary>Exactly one line, <c>kcap &lt;version&gt;</c>.</summary>
static string? Parse(string stdout) {
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (lines is not [var line] || !line.StartsWith("kcap ", StringComparison.Ordinal)) return null;

var version = line["kcap ".Length..].Trim();

return version is "" or "unknown" ? null : version;
}
}
5 changes: 4 additions & 1 deletion src/Capacitor.Cli/LocalEdition/LocalInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ sealed class LocalInstaller(
/// <summary>How long it waits for another root's hold on the per-user unit lock.</summary>
public TimeSpan UnitLockWait { get; init; } = LocalUnitLock.Wait;

/// <summary>The other copies of kcap the preflight holds to the client floor; none are looked for when null.</summary>
public ILocalClientCopies? ClientCopies { get; init; }

/// <summary>Required when the answers carry an embedding answer.</summary>
public OllamaClient? Ollama { get; init; }

Expand Down Expand Up @@ -66,7 +69,7 @@ sealed class LocalInstaller(
return done;
}

LocalPreflight Checks => new(root, config, platform, service, SocketLimit);
LocalPreflight Checks => new(root, config, platform, service, SocketLimit, ClientCopies);

/// <summary>No lock, no network, no writes.</summary>
public LocalPlan Preflight() => Checks.Check(Version);
Expand Down
3 changes: 2 additions & 1 deletion src/Capacitor.Cli/LocalEdition/LocalInstallerFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public LocalInstaller Create() {
new LocalPurgeRecovery(service, home, recorders, config, mayStop: false, force: false, installer.Progress),
new LocalUpgradeRecovery(stack, installer.Progress),
]) {
Ollama = new OllamaClient(http.CreateClient(CapacitorClients.Ollama), OllamaClient.DefaultUrl, time),
Ollama = new OllamaClient(http.CreateClient(CapacitorClients.Ollama), OllamaClient.DefaultUrl, time),
ClientCopies = LocalClientCopies.ForCurrentProcess(home, time),
};
}
}
29 changes: 25 additions & 4 deletions src/Capacitor.Cli/LocalEdition/LocalPreflight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,31 @@ namespace Capacitor.Cli.LocalEdition;
/// What must hold before a local install starts. Nothing here locks, fetches or writes; a refusal throws
/// <see cref="LocalInstallFailure"/> naming what the user can change.
/// </summary>
sealed class LocalPreflight(LocalRoot root, ConfigRoot config, LocalPlatform platform, ILocalServerService service, int? socketLimit = null) {
sealed class LocalPreflight(
LocalRoot root, ConfigRoot config, LocalPlatform platform, ILocalServerService service, int? socketLimit = null, ILocalClientCopies? copies = null) {
static readonly string[] Sockets = ["run/.s.PGSQL.5432", "run/kurrentdb.sock"];

/// <summary>Platform, socket paths, ancestors, journal and registry, then (unless a journal calls for
/// recovery first) profiles, the empty slot and the config; the version last.</summary>
/// recovery first) profiles, the empty slot and the config, then the version; the other kcap copies last, since
/// asking them spawns each one. An old copy never blocks a recovery: <see cref="Recheck"/> refuses it after.</summary>
public LocalPlan Check(string version) {
CheckPlatform();
CheckSockets();
CheckAncestors();

return Decide(version, afterRecovery: false);
var plan = Decide(version, afterRecovery: false);
if (plan.Hint != PlanHint.Recover) CheckClients();

return plan;
}

/// <summary>Under the lease, after recovery: a journal still present is a bug in the caller.</summary>
public LocalPlan Recheck(string version) => Decide(version, afterRecovery: true);
public LocalPlan Recheck(string version) {
var plan = Decide(version, afterRecovery: true);
CheckClients();

return plan;
}

public static int SocketLimit(LocalPlatform p) => p.IsMacOS ? 103 : 107;

Expand Down Expand Up @@ -68,6 +78,17 @@ void CheckPlatform() {
throw Refuse(LocalInstallFailureCode.UnsupportedPlatform, "The local edition runs on macOS for now.");
}

void CheckClients() {
if (copies?.Find().FirstOrDefault(c => !LocalClientFloor.Admits(c.Version)) is not { } old) return;

var which = old.Kind == ClientCopyKind.DesktopApp ? $"The desktop app's kcap ({old.Path})" : $"The kcap on PATH ({old.Path})";
var found = old.Version is { } v ? $"is {v}" : "did not report its version";
var fix = old.Kind == ClientCopyKind.DesktopApp ? "Update the desktop app" : "Update or remove it";

throw Refuse(LocalInstallFailureCode.ClientBelowFloor,
$"{which} {found}, and a local installation needs a kcap newer than {LocalClientFloor.NewestRefused}. {fix} before setting up a local installation.");
}

void CheckSockets() {
var limit = socketLimit ?? SocketLimit(platform);
var paths = SocketPaths(root);
Expand Down
9 changes: 8 additions & 1 deletion test/Capacitor.App.Tests.Unit/HubTestHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Eventuous.SignalR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
Expand Down Expand Up @@ -49,8 +50,9 @@ public sealed class HubTestHost : IAsyncDisposable {
/// <param name="admissionDelay">Holds back the server's registration of each connection, to widen
/// the gap <see cref="BroadcastAsync"/> waits out into one a test can rely on.</param>
/// <param name="onRequest">Sees each request's path and whether it is a WebSocket upgrade, before the hub does.</param>
/// <param name="inspect">Sees each request before the hub does.</param>
public static async Task<HubTestHost> StartAsync(bool requireAuth = false, TimeSpan? admissionDelay = null,
Action<string, bool>? onRequest = null) {
Action<string, bool>? onRequest = null, Action<HttpRequest>? inspect = null) {
DaemonsHandler = () => [];
LaunchHandler = _ => "agent-1";
_launchCalls = 0;
Expand Down Expand Up @@ -94,6 +96,11 @@ public static async Task<HubTestHost> StartAsync(bool requireAuth = false, TimeS
onRequest(ctx.Request.Path.Value ?? "", ctx.WebSockets.IsWebSocketRequest);
await next();
});
if (inspect is not null)
app.Use(async (ctx, next) => {
inspect(ctx.Request);
await next();
});
if (requireAuth)
app.Use(async (ctx, next) => {
if (!ctx.Request.Headers.ContainsKey("Authorization")) {
Expand Down
1 change: 1 addition & 0 deletions test/Capacitor.App.Tests.Unit/LocalInstallOutcomeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ await Assert.That(outcome).IsEqualTo(new LocalInstallOutcome.Failed(
[Test]
[Arguments("lifecycle_busy", LocalInstallAffordance.Busy)]
[Arguments("other_server", LocalInstallAffordance.Permanent)]
[Arguments("client_below_floor", LocalInstallAffordance.Permanent)]
[Arguments("cancelled", LocalInstallAffordance.Retry)]
[Arguments("verification_failed", LocalInstallAffordance.Retry)]
[Arguments(null, LocalInstallAffordance.Retry)]
Expand Down
24 changes: 24 additions & 0 deletions test/Capacitor.App.Tests.Unit/ServerConnectionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ public async Task ConnectsAndServesDaemons() {
await Assert.That(daemons![0].Name).IsEqualTo("work-mac");
}

/// A local server refuses a hub negotiation that names no kcap version above its client floor, and a server reads
/// a version without the update-check opt-out as the update check being on.
[Test]
[Arguments(false)]
[Arguments(true)]
public async Task The_negotiation_carries_the_cli_version_and_the_update_check_opt_out(bool updateCheckOff) {
string? negotiated = null;
string? updateCheck = null;
await using var host = await HubTestHost.StartAsync(inspect: request => {
if (request.Path.Value?.EndsWith("/negotiate", StringComparison.Ordinal) != true) return;
negotiated = request.Headers[Capacitor.Cli.Core.HttpClientExtensions.CliVersionHeader].ToString();
updateCheck = request.Headers[Capacitor.Cli.Core.HttpClientExtensions.UpdateCheckHeader].ToString();
});
await using var lane = new ServerConnectionService(
TimeProvider.System, host.Url, () => Task.FromResult<string?>(null), updateCheckOff: updateCheckOff);
lane.Start();

await Next(lane.Status, s => s.State == ServerLaneState.Connected);

await Assert.That(Capacitor.Cli.Core.HttpClientExtensions.CliVersion).IsNotNull();
await Assert.That(negotiated).IsEqualTo(Capacitor.Cli.Core.HttpClientExtensions.CliVersion);
await Assert.That(updateCheck).IsEqualTo(updateCheckOff ? "off" : "");
}

[Test]
public async Task BroadcastsSurfaceAsObservables() {
await using var host = await HubTestHost.StartAsync();
Expand Down
Loading
Loading