diff --git a/backend/src/Taskdeck.Api/FirstRun/DesktopRuntime.cs b/backend/src/Taskdeck.Api/FirstRun/DesktopRuntime.cs index 83c996da9..407ba5f10 100644 --- a/backend/src/Taskdeck.Api/FirstRun/DesktopRuntime.cs +++ b/backend/src/Taskdeck.Api/FirstRun/DesktopRuntime.cs @@ -259,6 +259,20 @@ internal static void WriteRetiredProviderConfigurationIgnored() internal static IReadOnlyList FormatFatalStartup(Exception? exception) { + if (exception is InvalidOperationException validationFailure + && validationFailure.Message.StartsWith( + FirstRunBootstrapper.MissingConnectorEncryptionKeyMessagePrefix, + StringComparison.Ordinal)) + { + return + [ + "TASKDECK_DESKTOP_FATAL code=connector_encryption_key_missing", + "Taskdeck could not start because Connectors__EncryptionKey is missing. Set a stable " + + "base64-encoded 256-bit key (or reuse the existing key for this data) and restart. " + + "No settings were printed." + ]; + } + if (exception is RetiredLlmProviderConfigurationException) { return diff --git a/backend/src/Taskdeck.Api/FirstRun/FirstRunBootstrapper.cs b/backend/src/Taskdeck.Api/FirstRun/FirstRunBootstrapper.cs index 41312bb76..4994db485 100644 --- a/backend/src/Taskdeck.Api/FirstRun/FirstRunBootstrapper.cs +++ b/backend/src/Taskdeck.Api/FirstRun/FirstRunBootstrapper.cs @@ -27,6 +27,13 @@ internal readonly record struct BootstrapIdentityLifecycle( /// public static class FirstRunBootstrapper { + /// + /// Ordinal prefix of the Production missing-connector-key failure. + /// classifies on this exact prefix, so the throw site and the classifier must share it. + /// + internal const string MissingConnectorEncryptionKeyMessagePrefix = + "SECURITY: The Connectors:EncryptionKey is not configured."; + private const string LocalConfigFileName = "appsettings.local.json"; // Placeholder values that indicate "not configured". @@ -648,7 +655,7 @@ public static WebApplicationBuilder ValidateProductionSecrets( if (string.IsNullOrWhiteSpace(connectorKey)) { throw new InvalidOperationException( - "SECURITY: The Connectors:EncryptionKey is not configured. " + + MissingConnectorEncryptionKeyMessagePrefix + " " + "Generate a base64-encoded 256-bit key with 'openssl rand -base64 32' and set it via the " + "Connectors__EncryptionKey environment variable. The application cannot start without a " + $"real encryption key in Production. (If this used to run as a desktop install, an existing " + diff --git a/backend/tests/Taskdeck.Api.Tests/FirstRun/DesktopRuntimeTests.cs b/backend/tests/Taskdeck.Api.Tests/FirstRun/DesktopRuntimeTests.cs index dd1607266..599c9d372 100644 --- a/backend/tests/Taskdeck.Api.Tests/FirstRun/DesktopRuntimeTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/FirstRun/DesktopRuntimeTests.cs @@ -162,6 +162,26 @@ public void FormatFatalStartup_MapsTypedRetiredProviderFailureToStaticActionable Assert.DoesNotContain(exception.Message, output); } + [Fact] + public void FormatFatalStartup_MapsMissingConnectorKeyToStaticActionableOutput() + { + const string secretLikeContent = "synthetic-secret-never-print"; + var exception = new InvalidOperationException( + FirstRunBootstrapper.MissingConnectorEncryptionKeyMessagePrefix + " " + secretLikeContent); + + var output = DesktopRuntime.FormatFatalStartup(exception); + + Assert.Equal( + [ + "TASKDECK_DESKTOP_FATAL code=connector_encryption_key_missing", + "Taskdeck could not start because Connectors__EncryptionKey is missing. Set a stable " + + "base64-encoded 256-bit key (or reuse the existing key for this data) and restart. " + + "No settings were printed." + ], + output); + Assert.All(output, line => Assert.DoesNotContain(secretLikeContent, line)); + } + [Fact] public void FormatFatalStartup_MapsUnrelatedExceptionToGenericOutputWithoutContentLeak() { diff --git a/docs/releases/WINDOWS_QUICK_START.md b/docs/releases/WINDOWS_QUICK_START.md index ef48f5db0..a2534323e 100644 --- a/docs/releases/WINDOWS_QUICK_START.md +++ b/docs/releases/WINDOWS_QUICK_START.md @@ -180,6 +180,11 @@ created inside Taskdeck, and authenticate local API/MCP clients; never put a `td [UPGRADING.md](../../UPGRADING.md#version-notes). v0.1.2 and later print the accurate diagnostic below instead. - **From v0.3.0-rc.1 onward** — `TASKDECK_DESKTOP_WARNING code=retired_provider_configuration_ignored`: Taskdeck started normally after ignoring retired Gemini settings inherited from this machine's environment (environment variables only — a retired selector passed on the command line still fails closed); no retired value was kept, logged, or printed, and the provider actually in use is the one shown in Taskdeck's provider status. Clear those leftover variables with the commands below when convenient. **The published v0.2.0 archive does not have this behaviour**: there the same leftover variables produce the fatal below, and the commands are a required workaround rather than optional tidying. +- **Unreleased (`main` after 2026-09-05, the build after `v0.3.0-rc.1`)** `TASKDECK_DESKTOP_FATAL + code=connector_encryption_key_missing`: you started the packaged exe with `TASKDECK_HEADLESS` set (the + supply-your-own-key contract) and no `Connectors__EncryptionKey` was configured. Set a stable + base64-encoded 256-bit key, or reuse the key already stored for this data folder, and start again. The + message prints no settings. Earlier builds report this case as the generic `code=startup_failed`. - `TASKDECK_DESKTOP_FATAL code=retired_provider_configuration`: Taskdeck found configuration for the retired Gemini provider and refused to switch providers silently. **In v0.2.0 and earlier this fires for retired Gemini settings from any source, including leftover `Llm__Gemini__*` variables