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:
@@ -68,7 +68,13 @@ test.describe('Electron desktop shell - local LLM answers from the real encrypte
|
||||
VNCMAIL_TEST_FIXED_PORT: String(FIXED_PORT),
|
||||
DEV_MOCK_JMAP: 'true',
|
||||
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
|
||||
// module header) — the server embeddings leg 404s cleanly instead
|
||||
// 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 appWindow.waitForURL((url) => !url.pathname.includes('login'), { timeout: 20000 });
|
||||
|
||||
// ── 2. Build the real encrypted local index: delta-sync the mock
|
||||
// account's mail into the replica store, then write it into SQLite/FTS5.
|
||||
// Chains /api/offline/sync while unfinishedWork is true, capped so a
|
||||
// real bug can't hang the test forever. ──
|
||||
const syncOutcome = await appWindow.evaluate(async () => {
|
||||
let unfinished = true;
|
||||
let calls = 0;
|
||||
const statuses: number[] = [];
|
||||
while (unfinished && calls < 10) {
|
||||
const res = await fetch('/api/offline/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
statuses.push(res.status);
|
||||
if (!res.ok) break;
|
||||
const body = await res.json();
|
||||
unfinished = body.unfinishedWork === true;
|
||||
calls++;
|
||||
}
|
||||
const reindexRes = await fetch('/api/offline/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catchUp: true }) });
|
||||
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));
|
||||
expect(syncOutcome.syncStatuses.every((s) => s === 200)).toBe(true);
|
||||
expect(syncOutcome.reindexStatus).toBe(200);
|
||||
// ── 2. Wait for the AUTOMATIC boot catch-up to build the encrypted
|
||||
// index — NO manual /api/offline/sync or /api/offline/reindex calls.
|
||||
// This is the load-bearing change from this spec's first version: the
|
||||
// real user experience is "log in, index appears on its own", and the
|
||||
// first version's manual calls proved only that the plumbing COULD
|
||||
// work, not that anything actually drives it. page.tsx schedules the
|
||||
// first attempt ~4s after the authenticated mail page mounts (with
|
||||
// retries at 20s/60s for the login race), so poll generously. ──
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
appWindow.evaluate(async () => {
|
||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent('Villa sul Lago check-in')}&limit=6`);
|
||||
if (!res.ok) return `http-${res.status}`;
|
||||
const body = await res.json().catch(() => null);
|
||||
const hits = (body?.hits ?? []) as Array<{ title?: string }>;
|
||||
return hits.some((h) => /villa sul lago/i.test(h.title ?? '')) ? 'hit' : 'indexed-but-empty';
|
||||
}),
|
||||
{
|
||||
timeout: 90000,
|
||||
intervals: [2000],
|
||||
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
|
||||
// touching the LLM at all — isolates "is the SQLite/FTS5 index working"
|
||||
// from "did the model use it correctly". ──
|
||||
const directSearch = await appWindow.evaluate(async () => {
|
||||
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);
|
||||
// ── 3. The new toolbar entry point must be present in the main mail
|
||||
// view — the AI Assistant is a feature of the app, not of the Settings
|
||||
// page. ──
|
||||
await expect(appWindow.getByRole('button', { name: 'AI Assistant' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ── 4. Navigate to the real AI Assistant settings UI and use the
|
||||
// local-discovery "Connect" banner — the exact flow a real user takes,
|
||||
|
||||
Reference in New Issue
Block a user