v2 provider layer: the OpenAI adapters in
src/main/providers/openainever read or store keys — they delegate to the existing service modules, so the OpenAI key still flows exclusively throughservices/openai/client.ts(andrealtime.tsfor the socket header). Since v2.2 the other providers (Anthropic, Google Gemini, Groq, OpenRouter) follow the same shape through one store,services/security/providerKeys.ts— see “Keys per provider” below: main process only, safeStorage at rest, resolved per provider at call time, never over IPC. The architecture test pins that no key-store or API host markers enter the renderer bundle.
.env is gitignored; only .env.example exists).sk-...).process.env.OPENAI_API_KEY (from .env, loaded only in dev).Resolution order at call time: env var (if set) → decrypted stored key. If neither exists, OpenAI calls fail fast with a clear “No API key configured” error and the UI prompts the user to add one.
Primary backend: Electron safeStorage.
import { safeStorage } from 'electron';
// save:
const cipher = safeStorage.encryptString(plaintextKey); // Buffer
settings.set('openai_api_key_enc', cipher.toString('base64'));
settings.set('openai_api_key_present', '1');
// load (main only):
const enc = settings.get('openai_api_key_enc');
const key = enc ? safeStorage.decryptString(Buffer.from(enc, 'base64')) : null;
safeStorage uses the OS keychain/DPAPI under the hood (macOS Keychain,
Windows DPAPI, libsecret on Linux) to protect the encryption key.safeStorage.isEncryptionAvailable() is false (some Linux
setups), warn the user and optionally fall back to keytar, behind the same
ApiKeyStore interface. Never silently store plaintext.services/security/apiKey.ts)interface ApiKeyStore {
isPresent(): boolean; // safe to expose via IPC (boolean only)
set(plaintext: string): void; // encrypts + persists
clear(): void;
getDecrypted(): string | null; // MAIN ONLY — never crosses IPC
}
apiKeyPresent (from settings:get).sk-…last4 is not provided by default (last-4 could
be added later if desired; MVP exposes presence only).services/security/providerKeys.ts)Every cloud provider in shared/providers.ts gets its own key under exactly
the rules above — the principles are per key, not per vendor:
provider_key_enc:<provider> (safeStorage ciphertext, base64) +
provider_key_present:<provider> (‘1’/’0’) in the settings table. openai
delegates to apiKeyStore, so there is ONE OpenAI key wherever it was
entered (old field, Language Models panel, or the dev env var).isPresent(p) / presence() (booleans — the only shape that
crosses IPC), set(p, plaintext), clear(p), and getDecrypted(p) — MAIN
ONLY, never returned over IPC.providers/keys.ts
(requireProviderKey) when a task actually routes to it — the store is
imported lazily there so the registry stays loadable without the DB. No key
→ the call fails fast with “providers/testKey.ts does a cheap GET /models per provider
and returns { ok, model } or a user-safe { ok:false, error } — vendor
SDK errors are normalized (normalizeProviderError / normalizeCompatError)
and never carry the key.sk-… redaction covers OpenAI, Anthropic (sk-ant-)
and OpenRouter (sk-or-) keys; Google and Groq keys are never logged either
— adapters log nothing about a request but its outcome.settings:test-api-key does a cheap call (e.g. list models / tiny embedding) in
main and returns { ok, model } or { ok:false, error } — without revealing the
key.
sk-[A-Za-z0-9_\-]+ from all output.The local memory subsystem’s standing guarantees:
memory_enabled) defaults
OFF; extraction and recall are both gated on it (plus a per-Space opt-out).services/memory/sensitiveFilter.ts
rejects secrets/credentials, payment data, government IDs, health details,
and sensitive personal attributes BEFORE persistence — extraction drops the
candidate, and review/edit paths refuse the write. Prompt-level instructions
are defense in depth, the filter is the gate.memories via FK.The voice/summon layer’s standing guarantees:
listening, sent to the STT
provider once, and dropped; they are never persisted or echoed back.voiceService.test.ts).saveQuickAsks is enabled (see 04-DATABASE voice_prefs).Goal: memory content unreadable if app.db is copied off the machine,
without breaking migrations or packaging.
safeStorage (DPAPI / Keychain / libsecret) and stored in the
settings table (memory_key_enc). The PLAINTEXT data key exists only in
main-process memory. This is the same trust anchor as the API key — no new
primitives.iv ‖ tag ‖ ciphertext in a BLOB column),
Node crypto only — no native deps, so electron-builder packaging and the
better-sqlite3 ABI story are untouched (packaging-safe).memories.content (and future memory exports) ONLY.
Embedding vectors stay plaintext — they are not meaningfully invertible and
must remain scannable for recall; FTS/lexical search moves in-process after
decrypt (recall already loads candidate rows).content TEXT nullable +
content_enc BLOB nullable): new writes encrypt; reads prefer content_enc;
a one-time background pass re-encrypts old rows, then content is dropped in
a later migration. Rollback-safe at every step.safeStorage unavailable (some Linux
keyrings) would need an explicit user choice (plaintext with a warning vs no
memory); OS-profile loss orphans the wrapped key (memory becomes
unrecoverable — acceptable for memory, must be TOLD to the user). Shipping
this needs those two UX decisions, not more code.