Commit Graph
3 Commits
Author SHA1 Message Date
Bernd RodlerandClaude Sonnet 5 f121678e2a feat(ai): Paperclip-style env-var provider presets + zero-config local default
Two product decisions from tonight:

1. Public AI providers can now be published by an admin as named presets
   (lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar).
   The admin names an env var, never a secret value - the actual key is
   whatever ops has set in the server's real environment, same custody
   model as the existing AI_SERVER_BASE_URL var. A new server route
   (app/api/ai/public/chat) resolves it and makes the call itself, which
   also sidesteps the CORS/wrong-base-URL failure class chatPublic hit
   earlier tonight. Users pick a preset from a dropdown in Settings -
   Answer with - no key field at all; personal BYOK (paste your own key)
   stays available as a secondary "Add your own key" option, not removed.
   Admin UI: new "Public - org-managed presets" card in the AI policy tab.

2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts):
   on first load, if no provider is chosen yet, probe OpenCode (this app
   auto-spawns `opencode serve` itself, so it's the one local option with
   zero external install step) then Ollama via the existing auto-discovery,
   and adopt whichever answers. Never overrides an explicit choice - only
   fires while provider is still null. Wired into both AI entry points
   (the Ask button and the Settings pane) so it resolves before either
   renders its "not configured" state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:01:00 +02:00
Bernd Rodler 98dcd3b1e9 feat(ai): OpenCode provider class; fix retrieval reading the wrong account's index
Three things, all from running the real thing rather than trusting a status code.

1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
   A locally-running `opencode serve` — the same runtime Paperclip drives as
   an adapter. Its appeal over a BYOK profile is precisely what was broken
   before: opencode owns provider auth itself, so there is NO api key for
   this app to hold, and it reports a REAL model list (25 on this machine)
   instead of asking the user to type an exact provider-specific model id
   from memory. Typing "Sonnet 5" into a free-text box and getting a bare
   "Provider returned 401" is the failure this removes.

   IMPORTANT trap, documented in the module header and pinned by a test:
   opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
   both answer 200 — because a web-UI catch-all serves index.html for ANY
   unknown path. I built the first version against that assumed compatibility
   on the strength of two 200s and had to throw it away once I read a body.
   Every probe now validates the parsed shape and content-type, never the
   status alone. The real API is GET /api/model + POST /session +
   POST /session/{id}/message, and the reply's `reasoning` parts are stripped
   so a model's private chain of thought can never surface as the answer.

   Proxied through our own backend (like the `server` class) because the
   desktop renderer's origin is a random port that changes every launch;
   same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
   construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
   no keys, nothing leaves the device" is the whole point of this class.

2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
   active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
   omitted `?slot=`, so search resolved to whichever account the multi-slot
   resolver found first. Single-account installs never noticed; a real
   multi-account/shared-mailbox setup reads an empty store every time. Both
   call sites now pass the active slot.

3. "No local mail index available in this session" was shown even when the
   index existed and simply matched nothing — actively misleading, and it
   masked the missing-SESSION_SECRET bug for hours. AskResult now carries
   retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
   get different words: build the index, versus rephrase (with the honest
   caveat that keyword search answers content questions better than recency
   ones like "the last mail").

Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
2026-08-06 20:18:23 +02:00
Bernd Rodler b648c1c267 fix(electron): packaged app shipped without a session secret — index/AI auth was dead on real installs; add AI entry point to the mail view
Root cause of "No local mail index available in this session" on a real
mailbox in the packaged .app, found by probing the live packaged build:
getSessionSecret() has four sources (env, env file, wizard config,
config file) and the desktop shell provided NONE — getDesktopDefaults()
sets JMAP_SERVER_URL (which also skips the setup wizard that would have
persisted a secret) but never a SESSION_SECRET. So every login's POST
/api/auth/stalwart-context 500'd, the jmap_stalwart_ctx cookie was never
minted, and every server-side-identity feature 401'd forever: encrypted
local index, offline replica, S/MIME enrolment, AI server class. The AI
retrieval leg renders any non-OK as "no local index", so the failure was
completely silent. Every test had masked this by injecting its own
SESSION_SECRET into the child env.

Fix 1 — electron/main.ts ensureSessionSecretFile(): a 64-hex-char secret
generated once per install, persisted 0600 under userData, handed to the
server as SESSION_SECRET_FILE (value stays out of the env block; an
operator-provided SESSION_SECRET env var still wins by resolution order).

Fix 2 — page.tsx boot catch-up now RETRIES (4s/20s/60s) instead of one
silent shot: the first attempt races login's own auth-context POST, and a
401 on that race used to mean an empty index until the next app restart.
requestIndex() already separates permanent (404/503 unavailable) from
retryable failures, so the retry is cheap and self-limiting.

Fix 3 — new components/ai/ai-ask-button.tsx: the AI Assistant finally has
an entry point in the MAIN mail view (Sparkles button next to the search
filter) opening a compact Ask dialog — same askMail client, same persisted
provider settings as the Settings pane. When nothing is configured it
deep-links to Settings → AI Assistant, where local-discovery's one-click
Connect does setup.

e2e hardened to prove the whole thing honestly: SESSION_SECRET explicitly
EMPTY in the launch env (the per-install secret must carry auth), the
manual sync/reindex calls removed (the automatic boot catch-up must build
the index on its own — polled, not triggered), and the toolbar entry
point asserted. Passing: auto-built index, discovery banner, Connect, and
a grounded answer citing the one email containing the fact.

Gate: tsc clean, eslint clean, 2502/2502 unit tests, e2e passing.
2026-08-06 19:03:48 +02:00