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.
This commit is contained in:
@@ -67,6 +67,7 @@ import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from
|
|||||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
|
import { AiAskButton } from "@/components/ai/ai-ask-button";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -1070,37 +1071,58 @@ export default function Home() {
|
|||||||
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
|
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
|
||||||
// SieveScript only). So backfill a bounded recent window once per session,
|
// SieveScript only). So backfill a bounded recent window once per session,
|
||||||
// after push is wired. Fire-and-forget; a no-op outside Electron.
|
// after push is wired. Fire-and-forget; a no-op outside Electron.
|
||||||
const catchUpTimer = setTimeout(() => {
|
//
|
||||||
void (async () => {
|
// RETRIED, not one-shot. The first attempt races the login flow's own
|
||||||
try {
|
// POST /api/auth/stalwart-context (stores/auth-store.ts's
|
||||||
const { catchUpIndex } = await import('@/lib/mail-index-client');
|
// syncStalwartAuthContext) - if the index route is hit before that cookie
|
||||||
await catchUpIndex(
|
// is minted it 401s, and a single silent attempt would leave the index
|
||||||
useAccountStore.getState().getActiveAccount()?.cookieSlot,
|
// empty until the next app restart with nothing telling anyone why (this
|
||||||
);
|
// exact silence hid the packaged app's missing-SESSION_SECRET bug against
|
||||||
} catch {
|
// a real mailbox). requestIndex() already distinguishes the permanent
|
||||||
/* the index is optional */
|
// cases (404/503 -> unavailable) from the retryable ones, so retrying is
|
||||||
|
// cheap and self-limiting.
|
||||||
|
const catchUpRetryDelaysMs = [4000, 20000, 60000];
|
||||||
|
let catchUpCancelled = false;
|
||||||
|
let catchUpTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const runCatchUp = async (attempt: number) => {
|
||||||
|
if (catchUpCancelled) return;
|
||||||
|
try {
|
||||||
|
const { catchUpIndex } = await import('@/lib/mail-index-client');
|
||||||
|
const result = await catchUpIndex(
|
||||||
|
useAccountStore.getState().getActiveAccount()?.cookieSlot,
|
||||||
|
);
|
||||||
|
if (!result.ok && !result.unavailable && attempt + 1 < catchUpRetryDelaysMs.length) {
|
||||||
|
catchUpTimer = setTimeout(() => void runCatchUp(attempt + 1), catchUpRetryDelaysMs[attempt + 1]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// The offline REPLICA's launch catch-up. Same reasoning as the index's,
|
} catch {
|
||||||
// plus one of its own: a `/changes` cursor cannot tell us about anything
|
/* the index is optional */
|
||||||
// that happened while the process was dead, so a cycle at launch is what
|
}
|
||||||
// drains the backlog. One cycle is bounded, so a first sync of a large
|
// The offline REPLICA's launch catch-up. Same reasoning as the index's,
|
||||||
// mailbox needs several - `chainSync` runs them with a hard cap.
|
// plus one of its own: a `/changes` cursor cannot tell us about anything
|
||||||
//
|
// that happened while the process was dead, so a cycle at launch is what
|
||||||
// Sequenced AFTER the index rather than in parallel: both write the same
|
// drains the backlog. One cycle is bounded, so a first sync of a large
|
||||||
// SQLite file, and although `busy_timeout` makes concurrent writers safe,
|
// mailbox needs several - `chainSync` runs them with a hard cap.
|
||||||
// there is no reason to spend the contention during first paint.
|
//
|
||||||
try {
|
// Sequenced AFTER the index (including its retries) rather than in
|
||||||
const { chainSync } = await import('@/lib/offline-replica-client');
|
// parallel: both write the same SQLite file, and although `busy_timeout`
|
||||||
await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot });
|
// makes concurrent writers safe, there is no reason to spend the
|
||||||
} catch {
|
// contention during first paint.
|
||||||
/* the replica is optional */
|
if (catchUpCancelled) return;
|
||||||
}
|
try {
|
||||||
})();
|
const { chainSync } = await import('@/lib/offline-replica-client');
|
||||||
// Deliberately after the initial mailbox fetch settles: the catch-up is a
|
await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot });
|
||||||
// background nicety and must not compete with first paint.
|
} catch {
|
||||||
}, 4000);
|
/* the replica is optional */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Deliberately after the initial mailbox fetch settles: the catch-up is a
|
||||||
|
// background nicety and must not compete with first paint.
|
||||||
|
catchUpTimer = setTimeout(() => void runCatchUp(0), catchUpRetryDelaysMs[0]);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
catchUpCancelled = true;
|
||||||
clearTimeout(catchUpTimer);
|
clearTimeout(catchUpTimer);
|
||||||
cleanups.forEach((fn) => fn());
|
cleanups.forEach((fn) => fn());
|
||||||
};
|
};
|
||||||
@@ -3164,6 +3186,7 @@ export default function Home() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<AiAskButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// The AI Assistant's entry point in the MAIN mail view — a Sparkles button in
|
||||||
|
// the search toolbar that opens a compact Ask dialog. Until this existed, the
|
||||||
|
// only way to ask the assistant anything was the "Try it" box buried in
|
||||||
|
// Settings → AI Assistant, which is a configuration screen, not a workflow.
|
||||||
|
//
|
||||||
|
// Deliberately reuses the exact same wire client (lib/ai/local-client's
|
||||||
|
// askMail) and the exact same persisted provider settings as the Settings
|
||||||
|
// pane — this is a second door to the same room, not a second room. When no
|
||||||
|
// provider is configured yet, the dialog deep-links to the Settings pane
|
||||||
|
// (where local-discovery offers the one-click Connect) instead of duplicating
|
||||||
|
// that setup flow here.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AlertTriangle, Loader2, Settings2, Sparkles, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||||
|
import { supportsLocalLlm } from '@/lib/platform-capabilities';
|
||||||
|
import { getAiApiKey } from '@/lib/ai/key-store';
|
||||||
|
import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings';
|
||||||
|
import { askMail, type AskResult } from '@/lib/ai/local-client';
|
||||||
|
|
||||||
|
function useAiPolicy(): { policy: AiPolicy; loaded: boolean } {
|
||||||
|
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/ai/policy');
|
||||||
|
if (res.ok && !cancelled) setPolicy(await res.json());
|
||||||
|
} catch {
|
||||||
|
/* stays at DEFAULT (disabled) — the button simply doesn't render */
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoaded(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return { policy, loaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the Settings pane's canAsk gating: is any provider actually ready? */
|
||||||
|
function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolean {
|
||||||
|
const classes = policy.entitlement.classes;
|
||||||
|
switch (settings.provider) {
|
||||||
|
case 'local':
|
||||||
|
return supportsLocalLlm() && classes.includes('local') && !!settings.localModel;
|
||||||
|
case 'server':
|
||||||
|
return classes.includes('server') && !!settings.serverModel;
|
||||||
|
case 'public': {
|
||||||
|
const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||||
|
return classes.includes('public') && !!active && settings.publicConsentAccepted;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiAskButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { policy, loaded } = useAiPolicy();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// Re-read on every open: the user may have just configured a provider in
|
||||||
|
// Settings and come straight back here — a mount-time snapshot would still
|
||||||
|
// say "not configured".
|
||||||
|
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
|
||||||
|
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [asking, setAsking] = useState(false);
|
||||||
|
const [askResult, setAskResult] = useState<AskResult | null>(null);
|
||||||
|
const [askError, setAskError] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const openDialog = useCallback(() => {
|
||||||
|
setSettings(loadAiSettings());
|
||||||
|
setAskResult(null);
|
||||||
|
setAskError(null);
|
||||||
|
setOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const configured = providerConfigured(settings, policy);
|
||||||
|
const canAsk = configured && question.trim().length > 0 && !asking;
|
||||||
|
|
||||||
|
const runAsk = useCallback(async () => {
|
||||||
|
if (!canAsk) return;
|
||||||
|
setAsking(true);
|
||||||
|
setAskError(null);
|
||||||
|
setAskResult(null);
|
||||||
|
try {
|
||||||
|
const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||||
|
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||||
|
const result = await askMail(question.trim(), {
|
||||||
|
provider: settings.provider as 'local' | 'server' | 'public',
|
||||||
|
localBaseUrl: settings.localBaseUrl,
|
||||||
|
localModel: settings.localModel,
|
||||||
|
serverModel: settings.serverModel,
|
||||||
|
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||||
|
});
|
||||||
|
setAskResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
setAskError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setAsking(false);
|
||||||
|
}
|
||||||
|
}, [canAsk, question, settings]);
|
||||||
|
|
||||||
|
const goToSettings = useCallback(() => {
|
||||||
|
// The Settings page's one-shot deep-link channel (see readPersistedTab in
|
||||||
|
// app/(main)/[locale]/settings/page.tsx) — lands directly on the AI pane,
|
||||||
|
// where local-discovery's Connect banner does the actual setup.
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('settings-deep-link-tab', 'ai_assistant');
|
||||||
|
} catch {
|
||||||
|
/* private mode — the settings page just opens on its default tab */
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
router.push('/settings');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Hidden entirely when the admin gate is off or no provider class is
|
||||||
|
// allowed — same visibility rule as the Settings pane itself.
|
||||||
|
if (!loaded || !policy.enabled || policy.entitlement.classes.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openDialog}
|
||||||
|
className="flex-shrink-0 p-2 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="AI Assistant"
|
||||||
|
aria-label="AI Assistant"
|
||||||
|
data-tour="ai-assistant"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[10vh]"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setOpen(false);
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="AI Assistant"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-xl rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary" />
|
||||||
|
<h2 className="text-sm font-semibold">AI Assistant</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
{!configured ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No AI provider is set up yet. Pick one in Settings — if Ollama is running on this machine, a
|
||||||
|
one-click Connect is waiting there.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button size="sm" onClick={goToSettings}>
|
||||||
|
<Settings2 className="w-3.5 h-3.5 me-1.5" />
|
||||||
|
Open AI settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void runAsk();
|
||||||
|
}}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ask a question about your mail…"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" onClick={() => void runAsk()} disabled={!canAsk}>
|
||||||
|
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||||
|
Ask
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">⌘⏎ to send</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{askError && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
|
||||||
|
<p className="text-sm text-destructive">{askError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{askResult && (
|
||||||
|
<div className={cn('flex flex-col gap-2 rounded-lg border border-border p-4', 'max-h-[45vh] overflow-y-auto')}>
|
||||||
|
{askResult.unaugmented && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
No local mail index available in this session — answered without retrieval context.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||||
|
{askResult.sources.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Sources</span>
|
||||||
|
{askResult.sources.map((s, i) => (
|
||||||
|
<span key={s.id} className="text-xs text-muted-foreground truncate">
|
||||||
|
[{i + 1}] {s.subject}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,7 +68,13 @@ test.describe('Electron desktop shell - local LLM answers from the real encrypte
|
|||||||
VNCMAIL_TEST_FIXED_PORT: String(FIXED_PORT),
|
VNCMAIL_TEST_FIXED_PORT: String(FIXED_PORT),
|
||||||
DEV_MOCK_JMAP: 'true',
|
DEV_MOCK_JMAP: 'true',
|
||||||
JMAP_SERVER_URL: `${ORIGIN}/api/dev-jmap`,
|
JMAP_SERVER_URL: `${ORIGIN}/api/dev-jmap`,
|
||||||
SESSION_SECRET: 'electron-ai-local-index-verify-32-chars-min',
|
// Deliberately NO SESSION_SECRET: the desktop shell must supply its
|
||||||
|
// own per-install secret (electron/main.ts's ensureSessionSecretFile)
|
||||||
|
// or the auth-context cookie can never be minted and every index
|
||||||
|
// route 401s. Up to 1.7.8 the packaged app shipped exactly that way,
|
||||||
|
// and every test masked it by injecting a secret here — this test
|
||||||
|
// now proves the shell stands on its own.
|
||||||
|
SESSION_SECRET: '',
|
||||||
// Deliberately UNSET: isolates grounding to the local FTS leg (see
|
// Deliberately UNSET: isolates grounding to the local FTS leg (see
|
||||||
// module header) — the server embeddings leg 404s cleanly instead
|
// module header) — the server embeddings leg 404s cleanly instead
|
||||||
// of silently also being able to answer the question.
|
// of silently also being able to answer the question.
|
||||||
@@ -93,41 +99,37 @@ test.describe('Electron desktop shell - local LLM answers from the real encrypte
|
|||||||
await devLoginContainer.getByRole('button').click();
|
await devLoginContainer.getByRole('button').click();
|
||||||
await appWindow.waitForURL((url) => !url.pathname.includes('login'), { timeout: 20000 });
|
await appWindow.waitForURL((url) => !url.pathname.includes('login'), { timeout: 20000 });
|
||||||
|
|
||||||
// ── 2. Build the real encrypted local index: delta-sync the mock
|
// ── 2. Wait for the AUTOMATIC boot catch-up to build the encrypted
|
||||||
// account's mail into the replica store, then write it into SQLite/FTS5.
|
// index — NO manual /api/offline/sync or /api/offline/reindex calls.
|
||||||
// Chains /api/offline/sync while unfinishedWork is true, capped so a
|
// This is the load-bearing change from this spec's first version: the
|
||||||
// real bug can't hang the test forever. ──
|
// real user experience is "log in, index appears on its own", and the
|
||||||
const syncOutcome = await appWindow.evaluate(async () => {
|
// first version's manual calls proved only that the plumbing COULD
|
||||||
let unfinished = true;
|
// work, not that anything actually drives it. page.tsx schedules the
|
||||||
let calls = 0;
|
// first attempt ~4s after the authenticated mail page mounts (with
|
||||||
const statuses: number[] = [];
|
// retries at 20s/60s for the login race), so poll generously. ──
|
||||||
while (unfinished && calls < 10) {
|
await expect
|
||||||
const res = await fetch('/api/offline/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
.poll(
|
||||||
statuses.push(res.status);
|
async () =>
|
||||||
if (!res.ok) break;
|
appWindow.evaluate(async () => {
|
||||||
const body = await res.json();
|
const res = await fetch(`/api/offline/search?q=${encodeURIComponent('Villa sul Lago check-in')}&limit=6`);
|
||||||
unfinished = body.unfinishedWork === true;
|
if (!res.ok) return `http-${res.status}`;
|
||||||
calls++;
|
const body = await res.json().catch(() => null);
|
||||||
}
|
const hits = (body?.hits ?? []) as Array<{ title?: string }>;
|
||||||
const reindexRes = await fetch('/api/offline/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catchUp: true }) });
|
return hits.some((h) => /villa sul lago/i.test(h.title ?? '')) ? 'hit' : 'indexed-but-empty';
|
||||||
return { syncStatuses: statuses, syncCalls: calls, reindexStatus: reindexRes.status, reindexBody: await reindexRes.json().catch(() => null) };
|
}),
|
||||||
});
|
{
|
||||||
console.log('[ai-local-index] sync+reindex outcome:', JSON.stringify(syncOutcome));
|
timeout: 90000,
|
||||||
expect(syncOutcome.syncStatuses.every((s) => s === 200)).toBe(true);
|
intervals: [2000],
|
||||||
expect(syncOutcome.reindexStatus).toBe(200);
|
message:
|
||||||
|
'the boot catch-up (page.tsx) must build the index automatically after login — http-401 here means the auth-context cookie was never minted (the missing-SESSION_SECRET class of bug), http-404 means the key channel/store dir never activated',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBe('hit');
|
||||||
|
|
||||||
// ── 3. Prove the local index itself is real and queryable BEFORE
|
// ── 3. The new toolbar entry point must be present in the main mail
|
||||||
// touching the LLM at all — isolates "is the SQLite/FTS5 index working"
|
// view — the AI Assistant is a feature of the app, not of the Settings
|
||||||
// from "did the model use it correctly". ──
|
// page. ──
|
||||||
const directSearch = await appWindow.evaluate(async () => {
|
await expect(appWindow.getByRole('button', { name: 'AI Assistant' })).toBeVisible({ timeout: 10000 });
|
||||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent('Villa sul Lago check-in')}&limit=6`);
|
|
||||||
return { status: res.status, body: await res.json().catch(() => null) };
|
|
||||||
});
|
|
||||||
console.log('[ai-local-index] direct /api/offline/search result:', JSON.stringify(directSearch.body));
|
|
||||||
expect(directSearch.status, 'the encrypted local index must be reachable (200), not 404 (feature disabled) or 503 (no key channel)').toBe(200);
|
|
||||||
expect(directSearch.body?.ok).toBe(true);
|
|
||||||
const hitTitles = (directSearch.body?.hits ?? []).map((h: { title?: string }) => h.title ?? '');
|
|
||||||
expect(hitTitles.some((t: string) => /villa sul lago/i.test(t)), `expected a "Villa sul Lago" hit in the real index, got: ${JSON.stringify(hitTitles)}`).toBe(true);
|
|
||||||
|
|
||||||
// ── 4. Navigate to the real AI Assistant settings UI and use the
|
// ── 4. Navigate to the real AI Assistant settings UI and use the
|
||||||
// local-discovery "Connect" banner — the exact flow a real user takes,
|
// local-discovery "Connect" banner — the exact flow a real user takes,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { app, BrowserWindow, ipcMain, Notification } from "electron";
|
import { app, BrowserWindow, ipcMain, Notification } from "electron";
|
||||||
import { autoUpdater } from "electron-updater";
|
import { autoUpdater } from "electron-updater";
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import { get as httpGet } from "node:http";
|
import { get as httpGet } from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -115,6 +116,50 @@ function getDesktopDefaults(): Record<string, string> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-install session secret for the standalone server.
|
||||||
|
*
|
||||||
|
* The server's cookie crypto (lib/auth/crypto.ts) refuses to mint the
|
||||||
|
* `jmap_stalwart_ctx` auth-context cookie without a >=32-char SESSION_SECRET,
|
||||||
|
* and every server-side-identity feature hangs off that cookie: the encrypted
|
||||||
|
* local search index and offline replica (their routes 401 without it),
|
||||||
|
* S/MIME enrolment, and the AI `server` class. A web deployment gets the
|
||||||
|
* secret from an operator (env var or the setup wizard); the desktop shell
|
||||||
|
* has NO operator, and up to 1.7.8 the packaged app simply shipped without
|
||||||
|
* one — so every login's stalwart-context POST failed with 500, the index
|
||||||
|
* stayed permanently empty, and the AI assistant answered "No local mail
|
||||||
|
* index available in this session" against real accounts. Caught live on a
|
||||||
|
* real mailbox, not by tests: every test run had injected its own
|
||||||
|
* SESSION_SECRET into the child env, masking exactly this.
|
||||||
|
*
|
||||||
|
* Generated once per install (64 hex chars, comfortably over the minimum),
|
||||||
|
* persisted 0600 under userData next to the rest of the per-user state, and
|
||||||
|
* handed to the server as SESSION_SECRET_FILE rather than SESSION_SECRET so
|
||||||
|
* the value itself stays out of the child's environment block. A
|
||||||
|
* deployment-provided SESSION_SECRET env var still wins — getSessionSecret()
|
||||||
|
* resolves the env var before the file.
|
||||||
|
*/
|
||||||
|
function ensureSessionSecretFile(): string | null {
|
||||||
|
const secretPath = path.join(app.getPath("userData"), "session-secret");
|
||||||
|
try {
|
||||||
|
const existing = fs.readFileSync(secretPath, "utf8").trim();
|
||||||
|
if (existing.length >= 32) return secretPath;
|
||||||
|
} catch {
|
||||||
|
/* first run — generate below */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(secretPath), { recursive: true });
|
||||||
|
fs.writeFileSync(secretPath, randomBytes(32).toString("hex"), { mode: 0o600 });
|
||||||
|
return secretPath;
|
||||||
|
} catch (cause) {
|
||||||
|
// Loud, because the downstream symptom is otherwise "index/AI features
|
||||||
|
// return 401" with no hint of why — but never fatal: reading mail does
|
||||||
|
// not depend on this cookie.
|
||||||
|
console.error("[electron] could not persist a session secret:", cause);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locates the standalone server's entrypoint. Packaged builds ship it as an
|
* Locates the standalone server's entrypoint. Packaged builds ship it as an
|
||||||
* extraResource (see electron-builder.config.js) because .next/standalone
|
* extraResource (see electron-builder.config.js) because .next/standalone
|
||||||
@@ -210,12 +255,19 @@ async function startStandaloneServer(): Promise<string> {
|
|||||||
// readable by any process running as the same OS user, which would defeat
|
// readable by any process running as the same OS user, which would defeat
|
||||||
// using the OS keychain at all. The fd NUMBER below is not a secret; only
|
// using the OS keychain at all. The fd NUMBER below is not a secret; only
|
||||||
// what travels over it is.
|
// what travels over it is.
|
||||||
|
const sessionSecretFile = ensureSessionSecretFile();
|
||||||
|
|
||||||
serverProcess = spawn(process.execPath, [serverEntry], {
|
serverProcess = spawn(process.execPath, [serverEntry], {
|
||||||
env: {
|
env: {
|
||||||
// First, so any real deployment env (a future per-install override,
|
// First, so any real deployment env (a future per-install override,
|
||||||
// or this same binary run somewhere JMAP_SERVER_URL is already set)
|
// or this same binary run somewhere JMAP_SERVER_URL is already set)
|
||||||
// wins over these desktop-shell defaults - see getDesktopDefaults().
|
// wins over these desktop-shell defaults - see getDesktopDefaults().
|
||||||
...getDesktopDefaults(),
|
...getDesktopDefaults(),
|
||||||
|
// Also before ...process.env: an operator-provided SESSION_SECRET or
|
||||||
|
// SESSION_SECRET_FILE must win over the per-install default (and a
|
||||||
|
// SESSION_SECRET env var outranks any file in getSessionSecret()'s
|
||||||
|
// resolution order regardless).
|
||||||
|
...(sessionSecretFile ? { SESSION_SECRET_FILE: sessionSecretFile } : {}),
|
||||||
...process.env,
|
...process.env,
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
|
|||||||
Reference in New Issue
Block a user