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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ jobs:
- name: Smoke test packaged binary
run: xvfb-run --auto-servernum npx playwright test --project=packaged
env:
EXO_PACKAGED_BINARY: dist/linux-unpacked/exo
EXO_PACKAGED_BINARY: release/linux-unpacked/exo

- name: Upload report
uses: actions/upload-artifact@v4
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ tests/screenshots/
# Dev mode data directory (isolated from production)
.dev-data/

# Packaged smoke-test data directory (tests/packaged/, isolated from production)
.packaged-test-data/

# Private extensions and agents (copied in at build time by mail-app)
src/extensions-private/
src/agents-private/
Expand Down
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,13 @@ All config lives under `app.getPath("userData")` — `~/Library/Application Supp
- **Database**: `data/exo.db`
- **App config**: `config.json` (electron-store)

**IMPORTANT:** Reading from `~/Library/Application Support/exo/` is always fine, but **never write to or modify files in that production directory without explicitly asking first**. This is real user data shared across packaged app installs. Dev runs use `.dev-data/` instead.
**IMPORTANT:** Reading from `~/Library/Application Support/exo/` is always fine, but **never write to, modify, or delete files in that production directory without explicitly asking first**. This is real user data shared across packaged app installs. Dev runs use `.dev-data/` instead.

Hard rules learned from a real incident (July 2026 — `clean_test_dbs()` in `scripts/run-tests.sh` deleted the production `exo-config.json`, wiping the user's API keys and settings, on every `npm test`):

- **Cleanup code in scripts/tests must only ever target project-local paths** (`.dev-data/`, `.packaged-test-data/`, `test-results/`, tmp dirs). Never construct a cleanup path from `$HOME` or `homedir()`. The `no-global-data-dirs` unit test enforces this for `scripts/` and `tests/`.
- **Never launch a locally-built packaged binary without `EXO_USER_DATA_DIR`** — it shares the real install's data dir (same productName). `tests/packaged/` sets this automatically.
- If a task seems to require touching the production directory, stop and ask — describe exactly what would be written or deleted.

## Recent Bug Fixes (Jan 2025)

Expand Down Expand Up @@ -374,3 +380,4 @@ Run `npm run eval` before any prompt change. The eval harness (`tests/evals/`) r
- `ANTHROPIC_API_KEY` - Required for Claude API
- `EXO_TEST_MODE=true` - Use mock data for testing
- `EXO_DEMO_MODE=true` - Use demo data without real API calls
- `EXO_USER_DATA_DIR` - Absolute-path override for the app data dir, honored even when packaged (used by `tests/packaged/` for isolation; never `export` it persistently)
15 changes: 13 additions & 2 deletions docs/LOCAL_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,24 @@ Catches PATH / native-module / asar bugs that dev never sees.
npm run build
npm run pack
# On macOS:
EXO_PACKAGED_BINARY="dist/mac-arm64/Exo.app/Contents/MacOS/Exo" \
EXO_PACKAGED_BINARY="release/mac-arm64/Exo.app/Contents/MacOS/Exo" \
npx playwright test --project=packaged
# On Linux (also what CI does):
EXO_PACKAGED_BINARY="dist/linux-unpacked/exo" \
EXO_PACKAGED_BINARY="release/linux-unpacked/exo" \
npx playwright test --project=packaged
```

(electron-builder outputs to `release/`, per `build.directories.output` in
package.json.)

The smoke spec launches the binary with `EXO_USER_DATA_DIR` pointing at the
project-local `.packaged-test-data/`. This is critical on macOS: a
locally-built .app has the same productName as the real install, so without
the override the packaged test would read and write the user's production
data dir. Never launch a locally-built packaged binary against production
data — if you need to run it by hand, set `EXO_USER_DATA_DIR` to an absolute
scratch path.

### Soak test

Long-running memory growth detector. Default 60 min, configurable:
Expand Down
56 changes: 26 additions & 30 deletions scripts/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"

# A leftover `export EXO_USER_DATA_DIR` (meant for one-off packaged runs)
# would redirect every Electron test instance to one shared dir, breaking
# per-worker isolation. Tests always use the project-local .dev-data/.
unset EXO_USER_DATA_DIR

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
Expand Down Expand Up @@ -105,39 +110,30 @@ run_with_display() {
}

# Clean up per-worker test databases and stale config left by parallel E2E runs.
# Config files (electron-store) are shared global state — we only clean them
# before/after the full test suite, never during parallel execution.
# Config files (electron-store) are shared state across workers — we only clean
# them before/after the full test suite, never during parallel execution.
#
# ONLY the dev Electron binary's dirs are cleaned. Tests launch via
# node_modules/electron, whose userData dir is "Electron" — never the packaged
# app's "exo" dir. exo-config.json under ".../Application Support/exo" is the
# PRODUCTION config (real API keys and settings); an earlier version of this
# list included the exo dirs and deleted it on every test run. Do not re-add.
# ONLY the project-local .dev-data/ may be cleaned here (test launches resolve
# their data dir there via src/main/data-dir.ts — the global "Electron" dirs
# main's hotfix still cleaned are legacy and no longer written). An earlier
# version of this function cleaned the global per-user app dirs — the packaged
# app's REAL user data — which deleted the production exo-config.json (all API
# keys and settings) on every test run. Never add global paths back; the
# no-global-data-dirs unit test enforces this.
clean_test_dbs() {
local home="${HOME:-/root}"
local dev_data="$PROJECT_DIR/.dev-data"
local cleaned=0
local data_dirs=(
"$home/Library/Application Support/Electron/data"
"$home/.config/Electron/data"
)
local config_dirs=(
"$home/Library/Application Support/Electron"
"$home/.config/Electron"
)
for dir in "${data_dirs[@]}"; do
if [ -d "$dir" ]; then
for f in "$dir"/exo-demo-w*.db*; do
[ -f "$f" ] && rm -f "$f" && cleaned=$((cleaned + 1))
done
fi
done
for dir in "${config_dirs[@]}"; do
if [ -d "$dir" ]; then
for f in "$dir"/exo-config.json; do
[ -f "$f" ] && rm -f "$f" && cleaned=$((cleaned + 1))
done
fi
done
if [ -d "$dev_data/data" ]; then
for f in "$dev_data/data"/exo-demo-w*.db*; do
[ -f "$f" ] && rm -f "$f" && cleaned=$((cleaned + 1))
done
fi
# Note: this intentionally resets any settings configured via `npm run
# dev` in this worktree — .dev-data/ is disposable test-account state,
# and e2e suites need a deterministic default config.
if [ -f "$dev_data/exo-config.json" ]; then
rm -f "$dev_data/exo-config.json" && cleaned=$((cleaned + 1))
fi
if [ $cleaned -gt 0 ]; then
log_info "Cleaned up $cleaned test artifact file(s)"
fi
Expand Down
18 changes: 13 additions & 5 deletions src/main/data-dir.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
/**
* Centralized data directory resolution.
*
* Non-packaged runs (`!app.isPackaged`) use a project-local `.dev-data/`
* directory so development never touches production data in
* `~/Library/Application Support/exo/`.
*
* Only packaged (released) builds use `app.getPath("userData")`.
* Resolution order:
* 1. `EXO_USER_DATA_DIR` (absolute path) — explicit override, honored in ALL
* modes including packaged builds. Used by the packaged smoke tests so a
* locally-built .app (same productName as the real install) never shares
* the production data dir. See user-data-override.ts.
* 2. Non-packaged runs (`!app.isPackaged`) use a project-local `.dev-data/`
* directory so development never touches production data in
* `~/Library/Application Support/exo/`.
* 3. Only packaged (released) builds use `app.getPath("userData")`.
*
* As of 2026-05-20, dev runs start with an empty `.dev-data/` and
* authenticate fresh against the dedicated test Gmail account (set via
Expand All @@ -25,6 +29,7 @@ import { join, dirname } from "path";
import { tmpdir } from "os";
import { existsSync } from "fs";
import { createRequire } from "module";
import { getUserDataOverride } from "./user-data-override";

const requireFromHere = createRequire(import.meta.url);

Expand Down Expand Up @@ -80,6 +85,9 @@ function findProjectRoot(start: string): string | null {
}

export function getDataDir(): string {
const override = getUserDataOverride();
if (override) return override;

const electron = tryLoadElectron();
if (!electron) {
// Non-Electron caller (eval runner, unit test under tsx, etc.).
Expand Down
17 changes: 12 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,20 @@ import * as calendarExtension from "../extensions/mail-ext-calendar/src/index";

// Anchor Electron's framework userData (SingletonLock, sessions, cache, IDB,
// LocalStorage, ServiceWorkers, GPUCache) to the per-worktree `.dev-data/` in
// dev. Without this, Electron defaults to `~/Library/Application Support/exo/`
// — the same dir the packaged app uses — so dev runs both pollute real user
// data and collide on the singleton lock across parallel worktrees. Must run
// before any `app.getPath("userData")` call below.
if (is.dev) {
// dev, or to EXO_USER_DATA_DIR when set (packaged smoke tests). Without this,
// Electron defaults to the packaged app's real user-data dir, so dev runs both
// pollute real user data and collide on the singleton lock across parallel
// worktrees. Must run before any `app.getPath("userData")` call below.
if (is.dev || process.env.EXO_USER_DATA_DIR) {
app.setPath("userData", getDataDir());
}
// Surface an active override loudly: a leftover `export EXO_USER_DATA_DIR`
// silently redirects a production launch to a scratch dir — the user sees an
// "empty" app and the logs land in the override dir, so without this line
// nothing anywhere records why.
if (process.env.EXO_USER_DATA_DIR) {
log.warn(`[Config] Data dir overridden by EXO_USER_DATA_DIR: ${process.env.EXO_USER_DATA_DIR}`);
}

// Skip Keychain for Chromium's internal cookie/localStorage encryption.
// Without this, macOS prompts "wants to access data from other apps" on first launch
Expand Down
5 changes: 5 additions & 0 deletions src/main/services/gmail-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ const OLD_CONFIG_DIR = join(homedir(), ".config", "exo");
* Safe to call multiple times — skips files that already exist at the destination.
*/
export async function migrateOldConfigIfNeeded(): Promise<void> {
// An explicitly redirected data dir (packaged smoke tests, scratch runs)
// must never be seeded from the legacy location — that would copy real
// OAuth tokens/credentials out of production into a disposable dir.
if (process.env.EXO_USER_DATA_DIR) return;

const newDir = getConfigDir();
if (OLD_CONFIG_DIR === newDir) return; // Linux: paths are the same, nothing to do

Expand Down
10 changes: 10 additions & 0 deletions src/main/services/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { mkdirSync, readdirSync, unlinkSync, statSync, writeFileSync } from "fs"
import { tmpdir } from "os";
import { Writable } from "stream";
import { createRequire } from "module";
import { getUserDataOverride } from "../user-data-override";

// This file uses CommonJS `require` to defer-load Electron so it can be
// imported in non-Electron test contexts. In ESM mode `require` is undefined
Expand Down Expand Up @@ -72,6 +73,15 @@ function getLogDir(): string {
// Resolve the data directory inline to avoid a circular dependency
// with data-dir.ts (which imports createLogger at module scope).
// NOTE: Keep this path logic in sync with getDataDir() in data-dir.ts.
// EXO_USER_DATA_DIR must be honored here too: loggers created at module
// import time run before index.ts re-anchors userData via app.setPath,
// so without this a packaged smoke run would write its first log lines
// into the real install's data dir. Shared with getDataDir() via the
// leaf helper so validation can't drift; a relative override throws,
// lands in the catch below, and falls back to tmpdir — the app then
// crashes with the real error when data-dir.ts validates it.
const override = getUserDataOverride();
if (override) return join(override, "logs");
const { app } = requireFromHere("electron");
// Use app.isPackaged directly — the previous isDev() wrapper used
// require("@electron-toolkit/utils") which could fail and fall back
Expand Down
21 changes: 21 additions & 0 deletions src/main/user-data-override.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { isAbsolute } from "path";

/**
* EXO_USER_DATA_DIR: explicit absolute-path override for the app data dir,
* honored in ALL modes including packaged builds. Exists so packaged smoke
* tests (tests/packaged/) never share the real install's data dir — a
* locally-built .app has the same productName as the real install, so
* without the override it would read and write production data.
*
* Kept as a leaf module (no imports beyond path) so both data-dir.ts and
* logger.ts resolve the override identically without creating an import
* cycle (logger is imported at module scope elsewhere in main).
*/
export function getUserDataOverride(): string | null {
const override = process.env.EXO_USER_DATA_DIR;
if (!override) return null;
if (!isAbsolute(override)) {
throw new Error(`EXO_USER_DATA_DIR must be an absolute path, got: ${override}`);
}
return override;
}
21 changes: 14 additions & 7 deletions tests/e2e/launch-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@ export async function launchElectronApp(
): Promise<{ app: ElectronApplication; page: Page }> {
const { workerIndex = 0, extraEnv = {}, waitAfterLoad } = options;

const env: Record<string, string> = {
...(process.env as Record<string, string>),
NODE_ENV: "test",
EXO_DEMO_MODE: "true",
TEST_WORKER_INDEX: String(workerIndex),
...extraEnv,
};
// A leftover `export EXO_USER_DATA_DIR` (e.g. from a manual packaged run)
// would make every parallel e2e worker share one data dir — concurrent
// electron-store writes and a shared Chromium profile. E2E isolation comes
// from .dev-data + per-worker DBs, never from the override.
delete env.EXO_USER_DATA_DIR;

const app = await electron.launch({
args: [path.join(__dirname, "../../out/main/index.js")],
env: {
...process.env,
NODE_ENV: "test",
EXO_DEMO_MODE: "true",
TEST_WORKER_INDEX: String(workerIndex),
...extraEnv,
},
env,
});

const window = await app.firstWindow();
Expand Down
34 changes: 16 additions & 18 deletions tests/e2e/undo-send.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { test, expect, Page, ElectronApplication } from "@playwright/test";
import path from "path";
import { fileURLToPath } from "url";
import { existsSync, unlinkSync, readdirSync } from "fs";
import {
launchElectronApp as _launchElectronApp,
takeScreenshot,
closeApp,
} from "./launch-helpers";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

/**
* E2E Tests for Undo Send feature
*
Expand All @@ -30,28 +33,23 @@ import {
* The demo DB is recreated by sync:init on every app launch, so deleting it is safe.
*/
function resetTestEnvironment(workerIndex: number) {
const home = process.env.HOME || "/root";

// Only delete THIS worker's demo database to avoid interfering with parallel workers.
// Config files are shared global state and must NOT be deleted during parallel runs.
// Config files are shared state and must NOT be deleted during parallel runs.
//
// Dev/test launches always resolve their data dir to the project-local
// .dev-data/ (src/main/data-dir.ts) — never clean global per-user app dirs
// here, those belong to the packaged app's real install.
const workerDbPattern = `exo-demo-w${workerIndex}.db`;
const demoDirs = [
path.join(home, "Library/Application Support/Electron/data"),
path.join(home, "Library/Application Support/exo/data"),
path.join(home, ".config/Electron/data"),
path.join(home, ".config/exo/data"),
];
for (const dir of demoDirs) {
if (!existsSync(dir)) continue;
try {
for (const file of readdirSync(dir)) {
if (file.startsWith(workerDbPattern)) {
unlinkSync(path.join(dir, file));
}
const demoDataDir = path.join(__dirname, "../../.dev-data/data");
if (!existsSync(demoDataDir)) return;
try {
for (const file of readdirSync(demoDataDir)) {
if (file.startsWith(workerDbPattern)) {
unlinkSync(path.join(demoDataDir, file));
}
} catch {
/* dir may not exist */
}
} catch {
/* dir may have been removed concurrently */
}
}

Expand Down
Loading
Loading