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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/05-persistence-and-reset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
6 changes: 5 additions & 1 deletion docs/07-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` — add another OpenRouter key under a label
Expand Down
6 changes: 5 additions & 1 deletion docs/08-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions src/OpenKey.Core/Storage/IConfigStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@ namespace OpenKey.Core.Storage;
/// </param>
/// <param name="Theme">Palette name: <c>default</c>, <c>dark</c>, <c>light</c>, or <c>mono</c>.</param>
/// <param name="MaxTokens">Upper bound on reply length requested from the model.</param>
/// <param name="CheckForUpdates">
/// 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.
/// </param>
public sealed record OpenKeyConfig(
IReadOnlyList<string> 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<string>(), DefaultTheme, DefaultMaxTokens);
new(Array.Empty<string>(), DefaultTheme, DefaultMaxTokens, CheckForUpdates: true);

/// <summary>
/// The pinned model, or null when rotation is free to choose. A view over
Expand Down
2 changes: 1 addition & 1 deletion src/OpenKey.Core/Storage/JsonConfigStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
109 changes: 109 additions & 0 deletions src/OpenKey.Core/Updates/UpdateChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;

namespace OpenKey.Core.Updates;

/// <summary>A newer release than the one running.</summary>
public sealed record UpdateInfo(string Version, string Url);

public interface IUpdateChecker
{
/// <summary>
/// The newest release if it is newer than <paramref name="currentVersion"/>, otherwise null.
/// Never throws: a failed check is not worth a word on screen, let alone an error.
/// </summary>
Task<UpdateInfo?> CheckAsync(string currentVersion, CancellationToken ct);
}

/// <summary>
/// Asks GitHub whether a newer release exists.
/// <para>
/// <b>Notify only — OpenKey never downloads or installs anything by itself.</b> 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.
/// </para>
/// <para>
/// This is the only request OpenKey makes to anywhere other than OpenRouter, which is why it is
/// switchable off in <c>config.json</c> 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.
/// </para>
/// </summary>
public sealed class GitHubUpdateChecker : IUpdateChecker
{
private const string LatestReleaseUrl =
"https://api.github.com/repos/corecompiled/OpenKey/releases/latest";

/// <summary>Short: this runs at launch and must never delay the app becoming usable.</summary>
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);

private readonly HttpClient _http;

public GitHubUpdateChecker(HttpClient http) => _http = http;

public async Task<UpdateInfo?> 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();

/// <summary>
/// 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.
/// </summary>
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;
3 changes: 3 additions & 0 deletions src/OpenKey.Gui/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,8 @@ public static int Main(string[] args)
services.AddSingleton<IKeyStore, DpapiKeyStore>();
services.AddSingleton<ISessionStore, JsonSessionStore>();
services.AddSingleton<IConfigStore, JsonConfigStore>();
services.AddSingleton<IUpdateChecker, GitHubUpdateChecker>();
services.AddSingleton<IUpdateChecker, GitHubUpdateChecker>();
services.AddSingleton<IRotationPolicy, RotationPolicy>();
services.AddSingleton<ITokenCounter, TiktokenCounter>();

Expand Down
17 changes: 17 additions & 0 deletions src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -36,6 +38,7 @@ public MainWindowViewModel(
IModelCatalog catalog,
IRotationPolicy rotation,
IConfigStore config,
IUpdateChecker updates,
IAppPaths paths,
HttpClient http)
{
Expand All @@ -44,6 +47,7 @@ public MainWindowViewModel(
_catalog = catalog;
_rotation = rotation;
_config = config;
_updates = updates;
_paths = paths;
_http = http;

Expand Down Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions src/OpenKey/ConsoleHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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!;
Expand All @@ -44,6 +46,7 @@ public ConsoleHost(
IRotationPolicy rotation,
ChatEngine engine,
IConfigStore config,
IUpdateChecker updates,
HttpClient http)
{
_paths = paths;
Expand All @@ -53,6 +56,7 @@ public ConsoleHost(
_rotation = rotation;
_engine = engine;
_config = config;
_updates = updates;
_http = http;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Shown once, at the prompt, never mid-reply. A new version is worth mentioning; it is not
/// worth interrupting anything for.
/// </summary>
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.");
}

/// <summary>
/// Past turns are rendered entirely grey and indented, so the resumed history reads as inert
/// rather than as part of the live conversation.
Expand Down
Loading
Loading