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 ( +
+ 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. +
+ )} +