diff --git a/CHANGELOG.md b/CHANGELOG.md index 719b362..07c56ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **OpenKey tells you when a new version is out.** It checks once at launch and shows a quiet line + with the link — nothing is ever downloaded or installed for you. Turn it off with + `"checkForUpdates": false` in `config.json`; see `SECURITY.md` for exactly what the check sends, + which is nothing beyond the request itself. + ## [0.3.0] — 2026-08-03 ### Added diff --git a/SECURITY.md b/SECURITY.md index 506e1a2..571b609 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,11 +14,34 @@ Include what you did, what happened, and what you expected. A proof of concept h decrypt it. Copying the file to another PC or another user account yields nothing usable. - **Your conversations** are stored in plain JSON at `%APPDATA%\OpenKey\session.json`. They are not encrypted. Anyone with access to your Windows account can read them. `/reset` deletes them. -- **Network traffic** goes to `openrouter.ai` and nowhere else. The only other connection OpenKey - ever opens is a local `http://localhost:3000/callback` listener, briefly, during browser sign-in. -- **No telemetry.** No analytics, no crash reporting, no phone-home, in any phase. This is a +- **Network traffic** goes to `openrouter.ai`, plus one request to `api.github.com` at launch to + ask whether a newer release exists. The only other connection OpenKey opens is a local + `http://localhost:3000/callback` listener, briefly, during browser sign-in. +- **No telemetry.** No analytics, no crash reporting, no usage data, in any phase. This is a standing project rule, not a current default. +### About the update check + +It is worth being precise, because "checks for updates" and "phones home" can look alike. + +The check is an unauthenticated `GET` of a public page — the same URL a browser would open — and +sends no identifier, no key, no version history and no usage data. It cannot be correlated with an +account because no account is involved. + +It does, however, reveal to GitHub that *someone at your IP launched OpenKey*. That is a real +disclosure, small but not nothing, so it is declared here rather than buried, and you can switch it +off: + +```json +{ "checkForUpdates": false } +``` + +in `%APPDATA%\OpenKey\config.json`. + +**Nothing is ever downloaded or installed automatically.** OpenKey tells you a version exists and +gives you the link. A tool that replaces its own binary is a tool you are right to distrust, and no +phase of this project will add one. + ## Threat model OpenKey is a single-user desktop application. It assumes the Windows account it runs under is diff --git a/docs/05-persistence-and-reset.md b/docs/05-persistence-and-reset.md index 159e4b8..1070cd6 100644 --- a/docs/05-persistence-and-reset.md +++ b/docs/05-persistence-and-reset.md @@ -132,10 +132,16 @@ See `04-model-rotation.md` § "Persistence of rotation state". { "preferredModels": [], "theme": "default", - "maxTokens": 2048 + "maxTokens": 2048, + "checkForUpdates": true } ``` +`checkForUpdates` governs the single request to `api.github.com` made at launch to see whether a +newer release exists. Notify only — nothing is downloaded or installed automatically, in any phase. +Set it to `false` and OpenKey talks to OpenRouter and nowhere else. See +[`../SECURITY.md`](../SECURITY.md). + If absent, defaults apply (empty list means rotation chooses freely). Owned by `IConfigStore` / `JsonConfigStore`. `preferredModels` is how a pinned model is expressed — diff --git a/docs/07-roadmap.md b/docs/07-roadmap.md index cdb9cf3..4a64744 100644 --- a/docs/07-roadmap.md +++ b/docs/07-roadmap.md @@ -48,7 +48,11 @@ Features that touch storage or DI but stay backward-compatible. - **Token counter** — estimate tokens per turn and show running total in status line. Use a real tokenizer NuGet (e.g., `Tiktoken` for OpenAI-family models, `MicrosoftDeepDev.Tokenizer` for cross-model). - **`/config`** — interactive Spectre menu to edit `config.json` (preferred model order, max_tokens, theme). -- **Update checker** — on launch, query `https://api.github.com/repos/corecompiled/OpenKey/releases/latest`. If newer, Spectre yellow notice with download URL. **Do not auto-download.** +- ~~**Update checker**~~ — done. Grey rather than yellow: a version being available is + information, not a warning. Shown at the prompt rather than mid-reply, and switchable off via + `checkForUpdates` in `config.json` — it is the only request OpenKey makes outside OpenRouter, so + it is declared in [`../SECURITY.md`](../SECURITY.md) rather than left implicit. Still never + downloads anything. - **Theme toggle** — `/theme dark|light|mono`. Stored in `config.json`. Spectre styles parameterized. - **Multi-key support**: - `/key add ` — add another OpenRouter key under a label diff --git a/docs/08-user-guide.md b/docs/08-user-guide.md index 4f7a5e7..024d3fc 100644 --- a/docs/08-user-guide.md +++ b/docs/08-user-guide.md @@ -114,7 +114,11 @@ Your key is encrypted so that only your Windows account on this PC can read it to another machine gets someone nothing. **Your conversation is not encrypted**, so anyone who can use your Windows account can read it. -OpenKey talks to OpenRouter and nowhere else. No analytics, no tracking, ever. +OpenKey talks to OpenRouter, and once at launch asks GitHub whether a newer version exists. That +check sends nothing about you and never downloads anything — it just shows a link. Turn it off by +putting `"checkForUpdates": false` in `config.json`. + +No analytics, no tracking, ever. ## When something goes wrong diff --git a/src/OpenKey.Core/Storage/IConfigStore.cs b/src/OpenKey.Core/Storage/IConfigStore.cs index fd70009..ee30127 100644 --- a/src/OpenKey.Core/Storage/IConfigStore.cs +++ b/src/OpenKey.Core/Storage/IConfigStore.cs @@ -10,16 +10,22 @@ namespace OpenKey.Core.Storage; /// /// Palette name: default, dark, light, or mono. /// Upper bound on reply length requested from the model. +/// +/// Whether to ask GitHub once at launch if a newer release exists. Notify only — nothing is ever +/// downloaded or installed automatically. This is the only request OpenKey makes to anywhere other +/// than OpenRouter, so it is declared here and can be switched off. +/// public sealed record OpenKeyConfig( IReadOnlyList PreferredModels, string Theme, - int MaxTokens) + int MaxTokens, + bool CheckForUpdates = true) { public const string DefaultTheme = "default"; public const int DefaultMaxTokens = 2048; public static OpenKeyConfig Default { get; } = - new(Array.Empty(), DefaultTheme, DefaultMaxTokens); + new(Array.Empty(), DefaultTheme, DefaultMaxTokens, CheckForUpdates: true); /// /// The pinned model, or null when rotation is free to choose. A view over diff --git a/src/OpenKey.Core/Storage/JsonConfigStore.cs b/src/OpenKey.Core/Storage/JsonConfigStore.cs index e9f1ae5..1155746 100644 --- a/src/OpenKey.Core/Storage/JsonConfigStore.cs +++ b/src/OpenKey.Core/Storage/JsonConfigStore.cs @@ -81,6 +81,6 @@ private static OpenKeyConfig Normalize(OpenKeyConfig? config) ? config.MaxTokens : OpenKeyConfig.DefaultMaxTokens; - return new OpenKeyConfig(models, theme, maxTokens); + return new OpenKeyConfig(models, theme, maxTokens, config.CheckForUpdates); } } diff --git a/src/OpenKey.Core/Updates/UpdateChecker.cs b/src/OpenKey.Core/Updates/UpdateChecker.cs new file mode 100644 index 0000000..7e65da7 --- /dev/null +++ b/src/OpenKey.Core/Updates/UpdateChecker.cs @@ -0,0 +1,109 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace OpenKey.Core.Updates; + +/// A newer release than the one running. +public sealed record UpdateInfo(string Version, string Url); + +public interface IUpdateChecker +{ + /// + /// The newest release if it is newer than , otherwise null. + /// Never throws: a failed check is not worth a word on screen, let alone an error. + /// + Task CheckAsync(string currentVersion, CancellationToken ct); +} + +/// +/// Asks GitHub whether a newer release exists. +/// +/// Notify only — OpenKey never downloads or installs anything by itself. A tool that +/// replaces its own binary is a tool people are right to distrust, and it fights the "one file you +/// can copy anywhere" model. That is a standing project rule, not a default. +/// +/// +/// This is the only request OpenKey makes to anywhere other than OpenRouter, which is why it is +/// switchable off in config.json and documented in SECURITY.md. It sends no identifiers and +/// no usage data — it is an unauthenticated GET of a public page, the same one a browser would +/// fetch. But it does reveal that someone launched the app, so declaring it plainly and letting +/// people decline is the honest treatment. +/// +/// +public sealed class GitHubUpdateChecker : IUpdateChecker +{ + private const string LatestReleaseUrl = + "https://api.github.com/repos/corecompiled/OpenKey/releases/latest"; + + /// Short: this runs at launch and must never delay the app becoming usable. + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly HttpClient _http; + + public GitHubUpdateChecker(HttpClient http) => _http = http; + + public async Task CheckAsync(string currentVersion, CancellationToken ct) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, LatestReleaseUrl); + req.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json"); + req.Headers.TryAddWithoutValidation("User-Agent", "OpenKey"); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(Timeout); + + using var resp = await _http.SendAsync(req, cts.Token); + if (!resp.IsSuccessStatusCode) return null; + + var release = await resp.Content.ReadFromJsonAsync( + UpdateJsonContext.Default.GitHubRelease, cts.Token); + + if (release?.TagName is not { Length: > 0 } tag) return null; + if (release.Draft || release.Prerelease) return null; + + return IsNewer(tag, currentVersion) + ? new UpdateInfo(Normalize(tag), release.HtmlUrl ?? "https://github.com/corecompiled/OpenKey/releases") + : null; + } + catch (Exception) + { + // Offline, rate-limited, captive portal, malformed response — none of it matters + // enough to tell the user about. Silence is the correct outcome. + return null; + } + } + + internal static string Normalize(string version) => + version.TrimStart('v', 'V').Split('-', '+')[0].Trim(); + + /// + /// Compares release numbers, not strings: "0.10.0" is newer than "0.9.0" even though it sorts + /// earlier alphabetically. Anything unparseable is treated as "not newer" — a bad comparison + /// should never nag someone about an update that isn't real. + /// + internal static bool IsNewer(string candidate, string current) + { + if (!Version.TryParse(Pad(Normalize(candidate)), out var a)) return false; + if (!Version.TryParse(Pad(Normalize(current)), out var b)) return false; + return a > b; + } + + // Version.TryParse rejects a bare "1"; pad to at least major.minor. + private static string Pad(string v) => v.Count(c => c == '.') switch + { + 0 => v + ".0.0", + 1 => v + ".0", + _ => v, + }; +} + +internal sealed record GitHubRelease( + [property: JsonPropertyName("tag_name")] string? TagName, + [property: JsonPropertyName("html_url")] string? HtmlUrl, + [property: JsonPropertyName("draft")] bool Draft, + [property: JsonPropertyName("prerelease")] bool Prerelease); + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(GitHubRelease))] +internal sealed partial class UpdateJsonContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/src/OpenKey.Gui/Program.cs b/src/OpenKey.Gui/Program.cs index e024b44..1180d78 100644 --- a/src/OpenKey.Gui/Program.cs +++ b/src/OpenKey.Gui/Program.cs @@ -4,6 +4,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Gui.ViewModels; using OpenKey.Providers.OpenRouter; using OpenKey.Windows; @@ -28,6 +29,8 @@ public static int Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); +services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs index 916d7e2..4933fc8 100644 --- a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs +++ b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Providers.OpenRouter; using OpenKey.Windows.OAuth; @@ -18,6 +19,7 @@ public sealed class MainWindowViewModel : ObservableObject private readonly IModelCatalog _catalog; private readonly IRotationPolicy _rotation; private readonly IConfigStore _config; + private readonly IUpdateChecker _updates; private readonly IAppPaths _paths; private readonly HttpClient _http; @@ -36,6 +38,7 @@ public MainWindowViewModel( IModelCatalog catalog, IRotationPolicy rotation, IConfigStore config, + IUpdateChecker updates, IAppPaths paths, HttpClient http) { @@ -44,6 +47,7 @@ public MainWindowViewModel( _catalog = catalog; _rotation = rotation; _config = config; + _updates = updates; _paths = paths; _http = http; @@ -163,6 +167,19 @@ public async Task InitializeAsync() } await LoadModelsAsync(); + + // Not awaited: the window is usable immediately, and a new version is never urgent. + _ = CheckForUpdateAsync(); + } + + private async Task CheckForUpdateAsync() + { + if (!_config.Current.CheckForUpdates) return; + + var found = await _updates.CheckAsync(Version, CancellationToken.None); + if (found is null) return; + + Show(StatusKind.Info, $"OpenKey {found.Version} is available — {found.Url}"); } public async Task LoadModelsAsync() diff --git a/src/OpenKey/ConsoleHost.cs b/src/OpenKey/ConsoleHost.cs index 1c1f82e..b6b64bf 100644 --- a/src/OpenKey/ConsoleHost.cs +++ b/src/OpenKey/ConsoleHost.cs @@ -4,6 +4,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Windows; using OpenKey.Windows.OAuth; using OpenKey.Providers.OpenRouter; @@ -26,6 +27,7 @@ public sealed class ConsoleHost private readonly IRotationPolicy _rotation; private readonly ChatEngine _engine; private readonly IConfigStore _config; + private readonly IUpdateChecker _updates; private readonly HttpClient _http; private CommandRouter _commands = default!; @@ -44,6 +46,7 @@ public ConsoleHost( IRotationPolicy rotation, ChatEngine engine, IConfigStore config, + IUpdateChecker updates, HttpClient http) { _paths = paths; @@ -53,6 +56,7 @@ public ConsoleHost( _rotation = rotation; _engine = engine; _config = config; + _updates = updates; _http = http; } @@ -94,6 +98,10 @@ public async Task RunAsync() await _engine.ResumeAsync(CancellationToken.None); ShowResumeRecapIfAny(); + // Deliberately not awaited: an update check must never stand between launching and typing. + // It prints only if it finds something, and only between turns. + _ = CheckForUpdateAsync(); + while (!_exiting) { string? line; @@ -110,6 +118,8 @@ public async Task RunAsync() if (_exiting) break; if (string.IsNullOrWhiteSpace(line)) continue; + ShowUpdateNoticeIfAny(); + var result = await _commands.HandleAsync(line, CancellationToken.None); if (result == CommandResult.Exit) break; if (result == CommandResult.Handled) @@ -283,6 +293,31 @@ private static void ShowChatError(ChatException ex) private static void ClearAndShowChatHeader() => Components.HomeHeader(); + private UpdateInfo? _pendingUpdate; + + private async Task CheckForUpdateAsync() + { + if (!_config.Current.CheckForUpdates) return; + + var found = await _updates.CheckAsync(Components.Version, CancellationToken.None); + if (found is not null) _pendingUpdate = found; + } + + /// + /// Shown once, at the prompt, never mid-reply. A new version is worth mentioning; it is not + /// worth interrupting anything for. + /// + private void ShowUpdateNoticeIfAny() + { + if (_pendingUpdate is not { } update) return; + _pendingUpdate = null; + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[{Theme.Muted}]OpenKey {Markup.Escape(update.Version)} is available.[/] {Markup.Escape(update.Url)}"); + Components.HintLine("Nothing is downloaded automatically. Turn this off with checkForUpdates in config.json."); + } + /// /// Past turns are rendered entirely grey and indented, so the resumed history reads as inert /// rather than as part of the live conversation. diff --git a/src/OpenKey/Program.cs b/src/OpenKey/Program.cs index 86e071d..a60e5db 100644 --- a/src/OpenKey/Program.cs +++ b/src/OpenKey/Program.cs @@ -6,6 +6,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Providers.OpenRouter; // LLM replies contain non-ASCII (em-dash, smart quotes, emoji). Without UTF-8 the @@ -33,6 +34,7 @@ services.AddSingleton(); services.AddSingleton(); +services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/OpenKey.Core.Tests/UpdateCheckerTests.cs b/tests/OpenKey.Core.Tests/UpdateCheckerTests.cs new file mode 100644 index 0000000..c114e87 --- /dev/null +++ b/tests/OpenKey.Core.Tests/UpdateCheckerTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Text; +using OpenKey.Core.Updates; +using Xunit; + +namespace OpenKey.Core.Tests; + +public sealed class UpdateCheckerTests +{ + private sealed class StubHandler : HttpMessageHandler + { + private readonly HttpStatusCode _status; + private readonly string _body; + private readonly Exception? _throw; + + public StubHandler(string body, HttpStatusCode status = HttpStatusCode.OK, Exception? toThrow = null) + { + _body = body; + _status = status; + _throw = toThrow; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + if (_throw is not null) throw _throw; + return Task.FromResult(new HttpResponseMessage(_status) + { + Content = new StringContent(_body, Encoding.UTF8, "application/json"), + }); + } + } + + private static GitHubUpdateChecker Checker(string body, HttpStatusCode status = HttpStatusCode.OK, Exception? toThrow = null) => + new(new HttpClient(new StubHandler(body, status, toThrow))); + + private static string Release(string tag, bool draft = false, bool prerelease = false) => + $$"""{"tag_name":"{{tag}}","html_url":"https://example.com/{{tag}}","draft":{{(draft ? "true" : "false")}},"prerelease":{{(prerelease ? "true" : "false")}}}"""; + + [Fact] + public async Task ReportsANewerRelease() + { + var found = await Checker(Release("v0.4.0")).CheckAsync("0.3.0", CancellationToken.None); + + Assert.NotNull(found); + Assert.Equal("0.4.0", found!.Version); + } + + [Fact] + public async Task SaysNothingWhenAlreadyCurrent() + { + Assert.Null(await Checker(Release("v0.3.0")).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task SaysNothingWhenRunningAheadOfTheRelease() + { + Assert.Null(await Checker(Release("v0.2.1")).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task IgnoresDraftsAndPrereleases() + { + Assert.Null(await Checker(Release("v9.9.9", draft: true)).CheckAsync("0.3.0", CancellationToken.None)); + Assert.Null(await Checker(Release("v9.9.9", prerelease: true)).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Forbidden)] // rate limited + [InlineData(HttpStatusCode.InternalServerError)] + public async Task StaysSilentOnAnyHttpFailure(HttpStatusCode status) + { + Assert.Null(await Checker("{}", status).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task StaysSilentWhenOffline() + { + // A failed check is not worth a word on screen, let alone an error card. + var checker = Checker("", toThrow: new HttpRequestException("no network")); + + Assert.Null(await checker.CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task StaysSilentOnNonsenseResponses() + { + Assert.Null(await Checker("not json").CheckAsync("0.3.0", CancellationToken.None)); + Assert.Null(await Checker("""{"tag_name":""}""").CheckAsync("0.3.0", CancellationToken.None)); + Assert.Null(await Checker("""{"tag_name":"banana"}""").CheckAsync("0.3.0", CancellationToken.None)); + } + + [Theory] + [InlineData("0.10.0", "0.9.0", true)] // numeric, not alphabetical + [InlineData("0.9.0", "0.10.0", false)] + [InlineData("1.0.0", "0.99.99", true)] + [InlineData("v0.4.0", "0.4.0", false)] + // Prerelease suffixes are stripped, so 0.4.0 and 0.4.0-beta compare equal rather than the + // former being treated as newer. OpenKey never publishes prereleases, so the case cannot + // arise in practice; full semver ordering would be complexity with no caller. + [InlineData("0.4.0", "0.4.0-beta", false)] + public void ComparesReleaseNumbersNotStrings(string candidate, string current, bool expected) + { + Assert.Equal(expected, GitHubUpdateChecker.IsNewer(candidate, current)); + } + + [Fact] + public void AnUnparseableVersionNeverCountsAsNewer() + { + // A bad comparison must not nag someone about an update that isn't real. + Assert.False(GitHubUpdateChecker.IsNewer("not-a-version", "0.3.0")); + Assert.False(GitHubUpdateChecker.IsNewer("0.4.0", "not-a-version")); + } + + [Theory] + [InlineData("v1.2.3", "1.2.3")] + [InlineData("1.2.3+abc123", "1.2.3")] + [InlineData("V1.2.3-beta", "1.2.3")] + public void TagsAreNormalisedBeforeComparing(string tag, string expected) + { + Assert.Equal(expected, GitHubUpdateChecker.Normalize(tag)); + } +}