Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelogs/v2.5.9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# v2.5.9

## Added

- **Save and switch between AI providers.** The AI settings (Settings → AI, Cloud / Local API) now let you save several named OpenAI-compatible connections — each with its own name, base URL, API key, and default model — and switch the active one from a dropdown instead of retyping your endpoint every time you change services. Add a provider for OpenAI, another for OpenRouter or Together, one for a local Ollama / LM Studio server, and flip between them in a click; the active provider fills the endpoint, key, and model fields below and is used everywhere the app talks to the cloud/local API (chat, inline AI, PRD writer, summaries). Add, edit, and delete providers inline. Your existing endpoint carries over unchanged — nothing to reconfigure. The On-Device Llama option is unaffected and keeps its own toggle.
- **The Coding Agent shares the same saved providers.** The Coding Agent settings (Settings → Agents → Cairn Coding Agent) use the *same* list of saved providers as AI Chat — add or edit a connection once and it's available in both places. The agent keeps its own active selection, so you can point it at a higher-capacity model while chat stays on a cheaper one. Deleting or editing a provider updates both consistently.

## Changed

- **Cleaner endpoint settings.** Now that saved providers cover switching endpoints, the old OpenAI / Ollama / LM Studio quick-preset pills under the Base URL field have been removed from both the AI settings and the Coding Agent settings — pick or add a saved provider instead. The provider picker now uses Cairn's standard dropdown menu (matching the model picker and other settings) instead of a plain OS select box, and shows each provider's model beneath its name.
- **Consistent model picker everywhere.** The model selector from the in-chat quick settings — a searchable dropdown of the endpoint's models with a Refresh action and a "Custom model…" option for anything the endpoint doesn't list — is now used across the app: in Settings → AI, in the Coding Agent settings, and in the saved-provider Add/Edit form. This replaces the older text box + Fetch button + wall of model pills, so choosing a model looks and behaves the same in every place.
- **The model dropdown only shows real models.** It no longer pre-fills with a hardcoded list of guessed model names. Instead each endpoint's model list is fetched and cached per endpoint (base URL + key), so it appears instantly the next time you open the picker; if nothing is cached yet, it fetches once automatically. You can still Refresh to re-query or enter a custom model id. Cached lists refresh after a week.

## Fixed

- **AI settings no longer crash with an infinite render loop.** Opening Settings → AI could throw "The result of getSnapshot should be cached to avoid an infinite loop" because the saved-providers list was rebuilt on every render. The provider switcher now reads a stable list reference, so the settings panel renders cleanly.
- **Local endpoints work with `127.0.0.1`, not just `localhost`.** Pointing an endpoint at `http://127.0.0.1:<port>` (instead of `http://localhost:<port>`) previously failed to fetch models or test the connection — the app's content-security-policy allowed `localhost` but not the equivalent `127.0.0.1` loopback address (CSP matches the origin string literally, so the two are treated as different). Both loopback spellings are now allowed, so a local server (Ollama, LM Studio, llama.cpp, …) works with whichever address you use.
14 changes: 14 additions & 0 deletions electron/lib/config-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface CachedConfig {
contextLimit?: number;
aiEnabled?: boolean;
subagentsEnabled?: boolean;
// Saved cloud/local API connections the user can switch between, plus the
// id of the active one. Persisted so the switcher survives restarts.
savedProviders?: Array<{ id: string; name: string; baseUrl: string; apiKey: string; model: string }>;
activeProviderId?: string;
};
agentConfig?: {
baseUrl?: string;
Expand All @@ -32,6 +36,9 @@ export interface CachedConfig {
maxSteps?: number;
temperature?: number;
autoApprove?: boolean;
// The coding agent's active saved-provider id. The provider LIST itself is
// shared and persisted under aiConfig.savedProviders (single source of truth).
activeProviderId?: string;
};
embeddingsConfig?: CachedEmbeddingsConfig;
theme?: string;
Expand Down Expand Up @@ -96,6 +103,12 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" |
contextLimit: typeof configRecord.contextLimit === "number" ? configRecord.contextLimit : current.aiConfig?.contextLimit,
aiEnabled: typeof configRecord.aiEnabled === "boolean" ? configRecord.aiEnabled : current.aiConfig?.aiEnabled,
subagentsEnabled: typeof configRecord.subagentsEnabled === "boolean" ? configRecord.subagentsEnabled : current.aiConfig?.subagentsEnabled,
// Saved-provider switcher state (array + active id). `configRecord` is
// typed for scalars, so read the raw config object for these.
savedProviders: Array.isArray((config as { savedProviders?: unknown }).savedProviders)
? (config as { savedProviders: NonNullable<CachedConfig["aiConfig"]>["savedProviders"] }).savedProviders
: current.aiConfig?.savedProviders,
activeProviderId: typeof configRecord.activeProviderId === "string" ? configRecord.activeProviderId : current.aiConfig?.activeProviderId,
Comment on lines +106 to +111

@coderabbitai coderabbitai Bot Jul 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo root files relevant =="
git ls-files | rg '(^|/)(config-cache\.ts|ui\.ts)$|(^|/)package\.json$|node_modules/next/dist/docs/' || true

echo
echo "== electron/lib/config-cache.ts outline =="
ast-grep outline electron/lib/config-cache.ts || true

echo
echo "== electron/lib/config-cache.ts sections =="
wc -l electron/lib/config-cache.ts
sed -n '1,180p' electron/lib/config-cache.ts

echo
echo "== src/store/slices/ui.ts sections =="
wc -l src/store/slices/ui.ts
sed -n '1,130p' src/store/slices/ui.ts

echo
echo "== search savedProviders definitions/usages =="
rg -n "savedProviders|activeProviderId|AI_CONFIG_KEY|saveAiSettings|safeStorage|encryptString|decryptString" -S .

Repository: ddutchie/cairn

Length of output: 23053


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== storage and encryption implementation candidates =="
fd -a 'storage\.(ts|tsx|js)$|secure-store\.ts$|settings-handlers\.ts$' . | sed 's#^\./##'

echo
echo "== src/lib/storage.ts =="
wc -l src/lib/storage.ts
sed -n '1,220p' src/lib/storage.ts

echo
echo "== electron/lib/secure-store.ts relevant =="
wc -l electron/lib/secure-store.ts
sed -n '1,260p' electron/lib/secure-store.ts

echo
echo "== electron/ipc/settings-handlers.ts =="
wc -l electron/ipc/settings-handlers.ts
sed -n '1,120p' electron/ipc/settings-handlers.ts

echo
echo "== package metadata next/electron versions =="
node - <<'JS'
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('package.json','utf8'));
console.log({dependencies:p.dependencies?.next, devDependencies: p.devDependencies?.electron, engines:p.engines});
JS

Repository: ddutchie/cairn

Length of output: 14080


Store saved-provider secrets through the encrypted store. saavedProviders carries apiKey, and persistAi() writes the full object to localStorage plus calls saveAiSettings(), which persists it to ai-settings-cache.json via plain JSON. Use the same OS-keychain-backed secret store used for MCP/service secrets, or store only provider references in localStorage/cache and keep the raw keys on the Electron side encrypted.

📍 Affects 2 files
  • electron/lib/config-cache.ts#L106-L111 (this comment)
  • src/store/slices/ui.ts#L72-L86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/lib/config-cache.ts` around lines 106 - 111, Update saved-provider
persistence across electron/lib/config-cache.ts lines 106-111 and
src/store/slices/ui.ts lines 72-86 so provider apiKey values never enter
localStorage or the plain JSON ai-settings-cache.json. Reuse the existing
OS-keychain-backed secret store used for MCP/service secrets, storing only
provider references in the renderer/cache and resolving raw keys on the Electron
side while preserving savedProviders and activeProviderId behavior.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will follow up with quick PR

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ddutchie, sounds good. The finding will remain open until the follow-up PR moves saved-provider API keys out of renderer/plain-JSON persistence and into the encrypted secret store.

Would you like me to open a GitHub issue to track it?

};
} else if (type === "agent" && configRecord) {
current.agentConfig = {
Expand All @@ -106,6 +119,7 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" |
maxSteps: typeof configRecord.maxSteps === "number" ? configRecord.maxSteps : current.agentConfig?.maxSteps,
temperature: typeof configRecord.temperature === "number" ? configRecord.temperature : current.agentConfig?.temperature,
autoApprove: typeof configRecord.autoApprove === "boolean" ? configRecord.autoApprove : current.agentConfig?.autoApprove,
activeProviderId: typeof configRecord.activeProviderId === "string" ? configRecord.activeProviderId : current.agentConfig?.activeProviderId,
};
} else if (type === "embeddings" && configRecord) {
current.embeddingsConfig = {
Expand Down
10 changes: 8 additions & 2 deletions electron/lib/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ export function setupProtocol(outDir: string): void {
"style-src-elem 'self' 'unsafe-inline'",
"img-src 'self' data: blob: asset: https:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self' https: http://localhost:* ws://localhost:*",
// Loopback LLM endpoints (fetched from the renderer) need both spellings
// — CSP matches origins literally. Scope the expansion to connect-src;
// script/default only need the dev server (localhost:3000).
"connect-src 'self' https: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
"worker-src blob: 'self'",
"frame-src blob: https:",
].join("; ")
Expand All @@ -144,7 +147,10 @@ export function setupProtocol(outDir: string): void {
"style-src-elem 'self' 'unsafe-inline' app:",
"img-src 'self' data: blob: app: asset: https:",
"font-src 'self' data: app:",
"connect-src 'self' app: http://localhost:* https:",
// Allow loopback LLM endpoints over plain http (local servers like
// Ollama / LM Studio). Both spellings must be listed — CSP matches the
// origin string literally, so localhost and 127.0.0.1 are distinct.
"connect-src 'self' app: http://localhost:* http://127.0.0.1:* https:",
"worker-src blob: 'self' app:",
"frame-src blob: https:",
].join("; ");
Expand Down
1 change: 1 addition & 0 deletions mobile/changelogs/v0.1.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Polish for the workspace switcher and Knowledge Graph controls.

### New

- **Save and switch between AI providers**: In **Settings → AI**, the OpenAI-compatible backend now lets you save several named connections — each with its own name, base URL, API key, and default model — and switch between them with a tap instead of retyping your endpoint each time. Save one for OpenAI, another for OpenRouter or a local server, and flip between them from the row of provider chips; the active one fills the fields below and is used for chat. Add a new provider, rename it, or delete it right from the settings screen. Each provider's API key is stored securely in the device keychain (never synced), and your existing endpoint carries over automatically as your first saved provider — nothing to reconfigure.
- **The assistant can search and read the web**: Chat can now go beyond your notes and tasks to answer questions about current events, external facts, and documentation — it searches the live web and can open a result to read the full page, citing its sources as links. Add a web provider from the new **Settings → AI → Tools & Services** screen — connect **Tavily** (sign in, no key to copy) or add **Brave Search** with an API key. Credentials are stored securely in the device keychain and never sync. Works with any chat backend (Apple Intelligence, Rork, or OpenAI-compatible).
- **Add community services and see the assistant's tools**: A new **Settings → AI → Tools & Services** screen lets you browse Cairn's community connector catalog and install HTTP services (like **Brave Search**) with just an API key — no app update needed, and an installed service works in every project on your device. Each installed service has an on/off switch so you can silence one you don't want the assistant to reach for. The screen also lists Cairn's built-in tools (collapsed, grouped into Read and Write) so you can see exactly what the assistant can do with your notes and tasks — these are always on and match the desktop app. Services and toggles live only on your device and never sync; API keys are stored in the device keychain.
- **Connect MCP servers to the assistant**: Tools & Services can now also install **MCP servers** from the community catalog — including ones that need you to sign in (OAuth). Tap **Connect**, approve access in the browser, and the server's tools become available to the assistant in chat (across every project on your device). Each connected server shows its tools in an expandable list, each with its own on/off switch, plus a refresh to re-discover tools; sign-in tokens are stored securely in the device keychain and never sync, and you can sign out or remove a server anytime. Connectors show their brand logo. (Streamable-HTTP MCP servers are supported. A few providers only allow sign-in from a pre-registered desktop app and can't be connected from mobile yet — the screen notes this.)
Expand Down
Loading