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