From 49edd92f5a2913aa339d4e68abaafcaa9f9bfd79 Mon Sep 17 00:00:00 2001 From: Baldri Date: Fri, 28 Aug 2026 11:04:31 +0200 Subject: [PATCH] feat(settings): make the Swiss endpoint configurable from the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Infomaniak product id was read from process.env only. A packaged, GUI-launched app never has it, so the Swiss endpoint silently failed to register and there was no way for a user to fix that. It is now a setting, with the environment kept as a development fallback. Settings win: a value someone typed into the app beats one the process happened to inherit. Entering or clearing it takes effect immediately — `applyInfomaniakConfig` runs at startup and on every settings update, so no restart is needed. Clearing the id REMOVES the registry entry rather than leaving the previous URL in place. A leftover entry would keep claiming Swiss residency for an endpoint the user has disowned, and the policy would keep routing sensitive requests to it. That is the case the tests guard hardest. Making the setting real meant closing three gaps behind it, each of which would have left a field that looks configured and does nothing: - The registry decides who MAY receive a request; the client manager is what can actually send one. Only the first was being registered, so the endpoint was selectable and unreachable. - `validateProvider` was a hardcoded list of four names and gates both saving an API key and loading it back at startup. The token field would have failed silently. A provider now qualifies by being built in or registered — earned rather than enumerated, and an arbitrary string still cannot reach the keychain. - A SECOND `validateProvider`, with its own copy of that list, gated the send path in ipc-handlers.ts. A configured endpoint could have held a key and still been refused a message. The two are now one function. UI lives on the Privacy tab, where the residency story already is: product id, token, and a warning when one is set without the other. Sabotages, each verified red: - drop the removal on clear -> the stale-endpoint test fails - read the environment only -> the settings-precedence tests fail 1426 tests green, typecheck exit 0, build:main and build:renderer exit 0. Co-Authored-By: Claude Opus 5 --- src/main/config/infomaniak-config.ts | 67 +++++++++++ src/main/index.ts | 12 ++ src/main/ipc-handlers.ts | 8 +- src/main/ipc/conversation-handlers.ts | 6 + src/main/ipc/ipc-utils.ts | 23 +++- src/main/routing/provider-registry.ts | 12 ++ .../components/PrivacySettingsTab.tsx | 107 ++++++++++++++++++ src/shared/types.ts | 7 ++ tests/unit/infomaniak-config.test.ts | 107 ++++++++++++++++++ tests/unit/provider-validation.test.ts | 70 ++++++++++++ 10 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 src/main/config/infomaniak-config.ts create mode 100644 tests/unit/infomaniak-config.test.ts create mode 100644 tests/unit/provider-validation.test.ts diff --git a/src/main/config/infomaniak-config.ts b/src/main/config/infomaniak-config.ts new file mode 100644 index 0000000..7f9ed3d --- /dev/null +++ b/src/main/config/infomaniak-config.ts @@ -0,0 +1,67 @@ +/** + * Where the Infomaniak product id comes from. + * + * The base URL is account-specific — one AI product per organisation, and the + * id is part of the path — so it is configuration, never a constant. It used + * to be read from `process.env` alone, which meant a packaged, GUI-launched + * app never had it and the Swiss endpoint silently failed to register. + * + * It belongs in the settings, where a user can actually enter it. The + * environment stays as a fallback for development and for headless runs. + * Settings win: a value someone typed into the app beats one the process + * happened to inherit. + */ + +import { getProviderRegistry, seedSwissProviders } from '../routing/provider-registry' +import { getClientManager } from '../llm-clients/client-manager' + +const ENV_VAR = 'INFOMANIAK_PRODUCT_ID' + +/** Blank, whitespace-only and unset all mean the same thing: not configured. */ +function normalise(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +/** + * Resolve the product id, settings first. + * + * `getConfigured` is injected so this stays testable without the settings + * store and its Electron dependencies. + */ +export function resolveInfomaniakProductId( + getConfigured: () => string | undefined +): string | undefined { + return normalise(getConfigured()) ?? normalise(process.env[ENV_VAR]) +} + +/** + * Bring the registry in line with the configured id. + * + * Called at startup and again whenever the setting changes, so a user does + * not have to restart the app to make a Swiss endpoint appear — or disappear. + * Clearing the id REMOVES the entry rather than leaving the previous URL in + * place; a stale entry would still claim Swiss residency and still receive + * the requests that claim earns it. + */ +export function applyInfomaniakConfig(productId: string | undefined): void { + const registry = getProviderRegistry() + const id = normalise(productId) + + if (!id) { + registry.remove('infomaniak') + // Drop the in-memory key too: with no endpoint there is nothing to send, + // and a leftover client must not be able to. The keychain copy stays, so + // re-entering the id does not require re-entering the token. + getClientManager().clearApiKey('infomaniak') + return + } + + seedSwissProviders(registry, id) + + // The registry decides whether a provider MAY receive a request; the client + // manager is what can actually send one. Registering only the first would + // leave the endpoint selectable but unreachable. + const entry = registry.get('infomaniak') + if (entry) getClientManager().registerCustomProvider(entry.config) +} diff --git a/src/main/index.ts b/src/main/index.ts index 8d1114c..ca83b2a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,8 @@ import { getLocalAIBridge } from './network/local-ai-bridge' import { getDocMindIntegration } from './integrations/docmind-integration' import { getAutoUpdater } from './updater/auto-updater' import { applyEnvFile } from './config/env-file' +import { applyInfomaniakConfig, resolveInfomaniakProductId } from './config/infomaniak-config' +import { SimpleStore } from './utils/simple-store' import { createLogger } from '../shared/logger' const logger = createLogger('Main') @@ -121,6 +123,16 @@ app.whenReady().then(async () => { // Register all IPC handlers await registerIPCHandlers() + // Register the Swiss endpoint from the stored settings (falling back to the + // environment). Without this the registry would only ever see the env var, + // which a packaged app does not have. + try { + const stored = SimpleStore.create().get('settings') as { infomaniakProductId?: string } | undefined + applyInfomaniakConfig(resolveInfomaniakProductId(() => stored?.infomaniakProductId)) + } catch (error) { + logger.warn('Could not apply the Infomaniak configuration', { error: String(error) }) + } + // Initialize deployment manager (auto-starts server if in server mode) try { await getDeploymentManager().initialize() diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 045d933..48049f4 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -32,15 +32,17 @@ import { registerMCPHandlers } from './ipc/mcp-handlers' import { registerRAGHandlers } from './ipc/rag-handlers' import { registerPrivacyHandlers } from './ipc/privacy-handlers' import { registerAgentHandlers } from './ipc/agent-handlers' +// Same rule as the one gating credential storage: a provider qualifies by +// being built in or registered. Two copies of this check with two different +// lists is how a configured endpoint ends up able to hold a key and unable to +// receive a message. +import { validateProvider } from './ipc/ipc-utils' // ============================================================ // Helpers (only needed for SEND_MESSAGE orchestration) // ============================================================ /** Validate provider string is valid LLMProvider */ -function validateProvider(provider: string): provider is LLMProvider { - return ['anthropic', 'openai', 'google', 'local'].includes(provider) -} const clientManager = getClientManager() const systemPromptManager = getSystemPromptManager() diff --git a/src/main/ipc/conversation-handlers.ts b/src/main/ipc/conversation-handlers.ts index d5ce092..56c29af 100644 --- a/src/main/ipc/conversation-handlers.ts +++ b/src/main/ipc/conversation-handlers.ts @@ -9,6 +9,7 @@ import { MessageModel } from '../database/models/message' import { SimpleStore } from '../utils/simple-store' import { getFeatureGateManager } from '../services/feature-gate-manager' import { wrapHandler, requirePermission, requireFeature } from './ipc-utils' +import { applyInfomaniakConfig, resolveInfomaniakProductId } from '../config/infomaniak-config' export function registerConversationHandlers(): void { const store = SimpleStore.create() @@ -88,6 +89,11 @@ export function registerConversationHandlers(): void { const currentSettings = (store.get('settings') || {}) as AppSettings const merged = { ...currentSettings, ...settings } store.set('settings', merged) + + // Bring the provider registry in line immediately: entering — or clearing + // — the product id must take effect without restarting the app. + applyInfomaniakConfig(resolveInfomaniakProductId(() => merged.infomaniakProductId)) + return { success: true, settings: merged } }) } diff --git a/src/main/ipc/ipc-utils.ts b/src/main/ipc/ipc-utils.ts index 33755fe..004b4fc 100644 --- a/src/main/ipc/ipc-utils.ts +++ b/src/main/ipc/ipc-utils.ts @@ -6,6 +6,7 @@ import { ipcMain } from 'electron' import { getRateLimiter } from '../security/rate-limiter' import { getRBACManager } from '../security/rbac-manager' import { getFeatureGateManager } from '../services/feature-gate-manager' +import { getProviderRegistry } from '../routing/provider-registry' /** * Wrap an IPC handler with consistent error handling + rate limiting. @@ -53,6 +54,26 @@ export function requireFeature(feature: string): void { } /** Validate provider string is valid LLMProvider */ +/** Built-ins are explicit so a registry change cannot lock a user out of them. */ +const BUILT_IN_PROVIDERS = ['anthropic', 'openai', 'google', 'local'] + +/** + * May this provider hold credentials? + * + * This gates both saving an API key and loading one back at startup. It used + * to be the four names above and nothing else, so a provider the user had + * just configured — the Swiss endpoint, say — could not hold a token, and the + * settings field for it failed silently. + * + * The rule is now earned rather than enumerated: a provider qualifies by + * being in the provider registry. That covers endpoints we registered and + * endpoints a tenant added (bring-your-own-key is the point of those), while + * an arbitrary string still cannot reach the keychain. Invariant I2 governs + * what such an endpoint may CLAIM about its residency — a separate question + * from whether the user may store a key for it. + */ export function validateProvider(provider: string): boolean { - return ['anthropic', 'openai', 'google', 'local'].includes(provider) + if (BUILT_IN_PROVIDERS.includes(provider)) return true + if (!provider) return false + return getProviderRegistry().get(provider) !== undefined } diff --git a/src/main/routing/provider-registry.ts b/src/main/routing/provider-registry.ts index aa7476a..6522e80 100644 --- a/src/main/routing/provider-registry.ts +++ b/src/main/routing/provider-registry.ts @@ -70,6 +70,18 @@ export class ProviderRegistry { return this.entries.get(id) } + /** + * Forget a provider. + * + * Needed because configuration can be withdrawn: when a user clears the + * Infomaniak product id, the entry must go. Leaving it would keep an + * endpoint claiming Swiss residency for a URL nobody stands behind any + * more, and the policy would keep routing sensitive requests to it. + */ + remove(id: string): void { + this.entries.delete(id) + } + all(): RegistryEntry[] { return Array.from(this.entries.values()) } diff --git a/src/renderer/components/PrivacySettingsTab.tsx b/src/renderer/components/PrivacySettingsTab.tsx index 442bd07..a7bac5c 100644 --- a/src/renderer/components/PrivacySettingsTab.tsx +++ b/src/renderer/components/PrivacySettingsTab.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, memo, useCallback } from 'react' import { usePrivacyStore } from '../stores/privacy-store' import { useChatStore } from '../stores/chat-store' +import { useSettingsStore } from '../stores/settings-store' type PrivacyMode = 'shield' | 'vault' | 'transparent' | 'local_only' @@ -11,6 +12,109 @@ const PRIVACY_MODES: { mode: PrivacyMode; label: string; description: string; co { mode: 'local_only', label: 'Local Only', description: 'Nachrichten nur an lokale LLMs senden', color: 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' } ] +/** + * Swiss endpoint (Infomaniak). + * + * Two inputs, not one: the base URL is account-specific — Infomaniak exposes + * one AI product per organisation and its id is part of the path — so the id + * is configuration, not a constant. Without it nothing is registered, and the + * policy has no Swiss endpoint to route sensitive requests to. + */ +const SwissEndpointSection = memo(function SwissEndpointSection() { + const settings = useSettingsStore((s) => s.settings) + const updateSettings = useSettingsStore((s) => s.updateSettings) + const saveAPIKey = useSettingsStore((s) => s.saveAPIKey) + const checkAPIKeys = useSettingsStore((s) => s.checkAPIKeys) + const apiKeysConfigured = useSettingsStore((s) => s.apiKeysConfigured) + + const [productId, setProductId] = useState('') + const [token, setToken] = useState('') + const [saving, setSaving] = useState(false) + + useEffect(() => { + setProductId(settings?.infomaniakProductId ?? '') + }, [settings?.infomaniakProductId]) + + const configured = Boolean(settings?.infomaniakProductId) + const hasToken = Boolean(apiKeysConfigured?.infomaniak) + + const handleSave = useCallback(async () => { + setSaving(true) + try { + await updateSettings({ infomaniakProductId: productId.trim() }) + if (token.trim()) { + await saveAPIKey('infomaniak', token.trim()) + setToken('') + await checkAPIKeys() + } + } finally { + setSaving(false) + } + }, [productId, token, updateSettings, saveAPIKey, checkAPIKeys]) + + return ( +
+
+

+ Schweizer Endpunkt (Infomaniak) +

+ + {configured ? 'registriert' : 'nicht eingerichtet'} + +
+ +

+ Ohne Produkt-ID wird kein Schweizer Endpunkt registriert — Anfragen mit + hohem Schutzbedarf bleiben dann auf dem lokalen Modell oder werden + abgelehnt. Die ID steht im Infomaniak Manager beim Produkt «AI Tools». +

+ + + setProductId(e.target.value)} + placeholder="z. B. 110908" + className="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white" + /> + + + setToken(e.target.value)} + placeholder={hasToken ? 'Zum Ersetzen neuen Token eingeben' : 'Token aus dem Infomaniak Manager'} + className="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white" + /> + + + + {configured && !hasToken && ( +

+ Produkt-ID gesetzt, aber kein Token hinterlegt — der Endpunkt ist + registriert und wird Anfragen nicht beantworten koennen. +

+ )} +
+ ) +}) + /** PII Privacy Mode Switcher */ const PrivacyModeSwitcher = memo(function PrivacyModeSwitcher() { const mode = usePrivacyStore((s) => s.mode) @@ -282,6 +386,9 @@ export function PrivacySettingsTab() { {/* NER Model Management */} + {/* Swiss endpoint */} + + {/* Statistics */} {stats && (
diff --git a/src/shared/types.ts b/src/shared/types.ts index cfc57fe..1b59d3f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -328,6 +328,13 @@ export interface AppSettings { routingMode?: 'manual' | 'auto' /** Custom display name for the external RAG server (default: "RAG-Wissen") */ ragServerName?: string + /** + * Infomaniak AI product id. The Swiss endpoint's base URL contains it + * (`/2/ai/{id}/openai/v1`), so it is account-specific configuration, not a + * constant. Empty means no Swiss endpoint is registered — see + * `src/main/config/infomaniak-config.ts`. + */ + infomaniakProductId?: string } // Prompt Template Types diff --git a/tests/unit/infomaniak-config.test.ts b/tests/unit/infomaniak-config.test.ts new file mode 100644 index 0000000..0e57cfa --- /dev/null +++ b/tests/unit/infomaniak-config.test.ts @@ -0,0 +1,107 @@ +/** + * Where the Infomaniak product id comes from, and what happens when it changes. + * + * Until now it was read from process.env only, which meant a packaged app + * never had it and the Swiss endpoint silently failed to register. It belongs + * in the settings, where a user can actually put it. + * + * The case that matters most is REMOVAL: clearing the setting has to take the + * endpoint out of the registry. A leftover entry would keep claiming Swiss + * residency, and the policy would keep routing sensitive requests to a URL + * the user has disowned. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { resolveInfomaniakProductId, applyInfomaniakConfig } from '../../src/main/config/infomaniak-config' +import { ProviderRegistry, setProviderRegistry, getProviderRegistry } from '../../src/main/routing/provider-registry' + +const ENV = 'INFOMANIAK_PRODUCT_ID' + +describe('resolveInfomaniakProductId', () => { + const original = process.env[ENV] + + beforeEach(() => { delete process.env[ENV] }) + afterEach(() => { + if (original === undefined) delete process.env[ENV] + else process.env[ENV] = original + }) + + it('prefers the configured setting over the environment', () => { + process.env[ENV] = 'from-env' + expect(resolveInfomaniakProductId(() => 'from-settings')).toBe('from-settings') + }) + + it('falls back to the environment when nothing is configured', () => { + process.env[ENV] = 'from-env' + expect(resolveInfomaniakProductId(() => undefined)).toBe('from-env') + }) + + it('returns undefined when neither is set', () => { + expect(resolveInfomaniakProductId(() => undefined)).toBeUndefined() + }) + + it('treats blank and whitespace-only values as unset', () => { + process.env[ENV] = ' ' + expect(resolveInfomaniakProductId(() => ' ')).toBeUndefined() + }) + + it('trims a pasted value', () => { + expect(resolveInfomaniakProductId(() => ' 110908\n')).toBe('110908') + }) +}) + +describe('applyInfomaniakConfig', () => { + let registry: ProviderRegistry + + beforeEach(() => { + registry = new ProviderRegistry() + setProviderRegistry(registry) + }) + afterEach(() => setProviderRegistry(null)) + + it('registers the Swiss endpoint with the configured id', () => { + applyInfomaniakConfig('110908') + + expect(getProviderRegistry().get('infomaniak')?.config.apiBase).toBe( + 'https://api.infomaniak.com/2/ai/110908/openai/v1' + ) + expect(getProviderRegistry().get('infomaniak')?.origin.residency).toBe('CH') + }) + + it('picks up a changed id without a restart', () => { + applyInfomaniakConfig('110908') + applyInfomaniakConfig('222333') + + expect(getProviderRegistry().get('infomaniak')?.config.apiBase).toBe( + 'https://api.infomaniak.com/2/ai/222333/openai/v1' + ) + }) + + it('REMOVES the endpoint when the id is cleared', () => { + applyInfomaniakConfig('110908') + expect(getProviderRegistry().get('infomaniak')).toBeDefined() + + applyInfomaniakConfig(undefined) + + // A leftover entry would keep claiming Swiss residency for a URL the user + // has disowned — and the policy would keep routing sensitive requests to it. + expect(getProviderRegistry().get('infomaniak')).toBeUndefined() + }) + + it('leaves other providers alone when clearing', () => { + registry.registerVerified( + { id: 'anthropic', name: 'A', type: 'built-in', apiKeyRequired: true, supportsStreaming: true, models: [] }, + { residency: 'US', operator: 'Anthropic PBC', weightsLicense: 'closed', hostingMode: 'rented', dpaStatus: 'signed' }, + { code: 0.9, creative: 0.9, analysis: 0.9, conversation: 0.9 } + ) + applyInfomaniakConfig('110908') + applyInfomaniakConfig(undefined) + + expect(getProviderRegistry().get('anthropic')).toBeDefined() + }) + + it('is a no-op when clearing something that was never registered', () => { + expect(() => applyInfomaniakConfig(undefined)).not.toThrow() + expect(getProviderRegistry().get('infomaniak')).toBeUndefined() + }) +}) diff --git a/tests/unit/provider-validation.test.ts b/tests/unit/provider-validation.test.ts new file mode 100644 index 0000000..9269164 --- /dev/null +++ b/tests/unit/provider-validation.test.ts @@ -0,0 +1,70 @@ +/** + * Which providers may hold credentials. + * + * `validateProvider` was a hardcoded list of four names. It gates both saving + * an API key and loading one back at startup, so a provider outside the list + * could not hold credentials at all — including the Swiss endpoint the user + * had just configured in the settings. The token field would have failed + * silently. + * + * The rule is now principled rather than enumerated: a provider earns the + * right to hold a key by being in the provider registry. Built-ins stay + * explicit so removing a registry entry cannot lock a user out of Anthropic. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { validateProvider } from '../../src/main/ipc/ipc-utils' +import { ProviderRegistry, setProviderRegistry } from '../../src/main/routing/provider-registry' + +describe('validateProvider', () => { + let registry: ProviderRegistry + + beforeEach(() => { + registry = new ProviderRegistry() + setProviderRegistry(registry) + }) + afterEach(() => setProviderRegistry(null)) + + it('accepts the built-in providers without a registry entry', () => { + for (const p of ['anthropic', 'openai', 'google', 'local']) { + expect(validateProvider(p), p).toBe(true) + } + }) + + it('accepts a provider we registered ourselves', () => { + registry.registerVerified( + { id: 'infomaniak', name: 'Infomaniak (CH)', type: 'custom', apiBase: 'https://x.invalid/v1', apiKeyRequired: true, supportsStreaming: true, models: [] }, + { residency: 'CH', operator: 'Infomaniak Network SA, Genf', weightsLicense: 'open', hostingMode: 'rented', dpaStatus: 'signed' }, + { code: 0.5, creative: 0.5, analysis: 0.5, conversation: 0.5 } + ) + expect(validateProvider('infomaniak')).toBe(true) + }) + + it('accepts an endpoint the tenant registered — bring-your-own-key is the point', () => { + registry.registerTenant({ + id: 'own-endpoint', name: 'Eigener Endpunkt', type: 'custom', + apiBase: 'https://own.invalid/v1', apiKeyRequired: true, supportsStreaming: true, models: [] + }) + // I2 governs what residency such an endpoint may CLAIM, not whether the + // user may store a key for something they configured themselves. + expect(validateProvider('own-endpoint')).toBe(true) + }) + + it('rejects a provider that is neither built in nor registered', () => { + // The gate still exists: an arbitrary string must not reach the keychain. + expect(validateProvider('irgendwas')).toBe(false) + expect(validateProvider('')).toBe(false) + }) + + it('stops accepting a provider once it is removed from the registry', () => { + registry.registerVerified( + { id: 'infomaniak', name: 'I', type: 'custom', apiBase: 'https://x.invalid/v1', apiKeyRequired: true, supportsStreaming: true, models: [] }, + { residency: 'CH', operator: 'I', weightsLicense: 'open', hostingMode: 'rented', dpaStatus: 'signed' }, + { code: 0.5, creative: 0.5, analysis: 0.5, conversation: 0.5 } + ) + expect(validateProvider('infomaniak')).toBe(true) + + registry.remove('infomaniak') + expect(validateProvider('infomaniak')).toBe(false) + }) +})