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:
@@ -9,6 +9,7 @@
|
||||
import { app, BrowserWindow, ipcMain, Notification } from "electron";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:net";
|
||||
import { get as httpGet } from "node:http";
|
||||
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
|
||||
* 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
|
||||
// using the OS keychain at all. The fd NUMBER below is not a secret; only
|
||||
// what travels over it is.
|
||||
const sessionSecretFile = ensureSessionSecretFile();
|
||||
|
||||
serverProcess = spawn(process.execPath, [serverEntry], {
|
||||
env: {
|
||||
// First, so any real deployment env (a future per-install override,
|
||||
// or this same binary run somewhere JMAP_SERVER_URL is already set)
|
||||
// wins over these desktop-shell defaults - see 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,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
PORT: String(port),
|
||||
|
||||
Reference in New Issue
Block a user