diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6639b4ad..e7782a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index ff22ba6a..c4548f76 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index c76b37b2..ea6decaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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) diff --git a/docs/LOCAL_DEVELOPMENT.md b/docs/LOCAL_DEVELOPMENT.md index 75a41fa8..56e503ba 100644 --- a/docs/LOCAL_DEVELOPMENT.md +++ b/docs/LOCAL_DEVELOPMENT.md @@ -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: diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 4ec88c91..23e375ef 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -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' @@ -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 diff --git a/src/main/data-dir.ts b/src/main/data-dir.ts index 7446863c..4fd0a81b 100644 --- a/src/main/data-dir.ts +++ b/src/main/data-dir.ts @@ -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 @@ -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); @@ -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.). diff --git a/src/main/index.ts b/src/main/index.ts index 99b37bf0..a0cff875 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 99082d80..6322c449 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -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 { + // 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 diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 2d81fb24..f9e6e51b 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -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 @@ -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 diff --git a/src/main/user-data-override.ts b/src/main/user-data-override.ts new file mode 100644 index 00000000..a3dd31ff --- /dev/null +++ b/src/main/user-data-override.ts @@ -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; +} diff --git a/tests/e2e/launch-helpers.ts b/tests/e2e/launch-helpers.ts index 49600ea5..ea50093f 100644 --- a/tests/e2e/launch-helpers.ts +++ b/tests/e2e/launch-helpers.ts @@ -23,15 +23,22 @@ export async function launchElectronApp( ): Promise<{ app: ElectronApplication; page: Page }> { const { workerIndex = 0, extraEnv = {}, waitAfterLoad } = options; + const env: Record = { + ...(process.env as Record), + 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(); diff --git a/tests/e2e/undo-send.spec.ts b/tests/e2e/undo-send.spec.ts index 8d78b76a..7f3a1575 100644 --- a/tests/e2e/undo-send.spec.ts +++ b/tests/e2e/undo-send.spec.ts @@ -1,5 +1,6 @@ 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, @@ -7,6 +8,8 @@ import { closeApp, } from "./launch-helpers"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + /** * E2E Tests for Undo Send feature * @@ -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 */ } } diff --git a/tests/packaged/smoke.spec.ts b/tests/packaged/smoke.spec.ts index 94e0baad..09a12dc1 100644 --- a/tests/packaged/smoke.spec.ts +++ b/tests/packaged/smoke.spec.ts @@ -10,21 +10,36 @@ * - electron-builder packaging quirks * * Requires the binary path in EXO_PACKAGED_BINARY. CI sets this to - * dist/linux-unpacked/exo after `npm run pack`. Locally on macOS, - * use: `npm run pack && EXO_PACKAGED_BINARY="dist/mac-arm64/Exo.app/Contents/MacOS/Exo" \ + * release/linux-unpacked/exo after `npm run pack`. Locally on macOS, + * use: `npm run pack && EXO_PACKAGED_BINARY="release/mac-arm64/Exo.app/Contents/MacOS/Exo" \ * npx playwright test --project=packaged`. + * + * The packaged binary resolves its data dir to the real per-user app dir + * (same productName as the actual install), so we MUST redirect it with + * EXO_USER_DATA_DIR or the smoke test writes into — and can corrupt — the + * user's production config and database. */ import { test, expect, _electron as electron, type Page, type ElectronApplication } from "@playwright/test"; -import { existsSync } from "fs"; +import { existsSync, mkdirSync, rmSync } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const BINARY = process.env.EXO_PACKAGED_BINARY ?? ""; +const USER_DATA_DIR = path.join(__dirname, "../../.packaged-test-data"); test.beforeAll(() => { if (!BINARY) { test.skip(true, "EXO_PACKAGED_BINARY not set — skipping packaged smoke"); } if (!existsSync(BINARY)) { - test.skip(true, `EXO_PACKAGED_BINARY does not exist at ${BINARY} — did you run 'npm run pack'?`); + // A set-but-wrong path must FAIL, not skip: the dist/->release/ incident + // proved a silent skip keeps CI green while the packaged suite never + // runs. Skipping is only acceptable when the suite wasn't requested. + throw new Error( + `EXO_PACKAGED_BINARY is set but does not exist at ${BINARY} — ` + + `did you run 'npm run pack'? (electron-builder outputs to release/)`, + ); } }); @@ -35,6 +50,10 @@ test.describe("Packaged app smoke", () => { let page: Page; test.beforeAll(async () => { + // Start from a clean slate — stale Chromium profile state or config from + // a previous run would make the smoke test non-deterministic. + rmSync(USER_DATA_DIR, { recursive: true, force: true }); + mkdirSync(USER_DATA_DIR, { recursive: true }); app = await electron.launch({ executablePath: BINARY, env: { @@ -43,6 +62,9 @@ test.describe("Packaged app smoke", () => { // in CI. The packaging itself is what we're verifying, not // real-Gmail behavior. EXO_DEMO_MODE: "true", + // Isolate ALL data (config, db, logs, Chromium profile) from the + // real install's user-data dir — see header comment. + EXO_USER_DATA_DIR: USER_DATA_DIR, // Test worker isolation pattern from launch-helpers.ts TEST_WORKER_INDEX: "0", }, @@ -70,6 +92,15 @@ test.describe("Packaged app smoke", () => { } }); + test("data dir is redirected away from the real install", async () => { + // The whole reason this suite is safe to run locally: EXO_USER_DATA_DIR + // must actually take effect. If the override regresses, the packaged + // binary reads and writes the user's production data dir while every + // other assertion here still passes — so verify it, don't trust it. + const userData = await app.evaluate(({ app: electronApp }) => electronApp.getPath("userData")); + expect(userData).toBe(USER_DATA_DIR); + }); + test("app launches within 30s and shows the Exo brand", async () => { await expect(page.locator("text=Exo").first()).toBeVisible({ timeout: 30_000 }); }); diff --git a/tests/unit/data-dir.spec.ts b/tests/unit/data-dir.spec.ts index f2e00db1..51ce20f4 100644 --- a/tests/unit/data-dir.spec.ts +++ b/tests/unit/data-dir.spec.ts @@ -9,7 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); * Regression guard for the dev/prod data sever (May 2026). * * The old `initDevData()` bootstrap copied the user's real Gmail tokens, - * credentials, and database from `~/Library/Application Support/exo/` + * credentials, and database from the packaged app's real user-data dir * into `.dev-data/` on first dev run — which meant a fresh worktree could * silently re-import real-account state. That's now banned: dev signs in * as the dedicated test account (configured via `EXOEMAILTEST_EMAIL` @@ -32,3 +32,37 @@ test("data-dir.ts has no prod-to-dev copy bootstrap", () => { expect(source).not.toContain("mkdirSync"); expect(source).not.toContain("writeFileSync"); }); + +/** + * Behavior tests for the EXO_USER_DATA_DIR override (July 2026). + * + * The override is the only thing keeping packaged smoke tests out of the + * real install's data dir, so its two contracts — absolute path honored + * verbatim, relative path rejected loudly — get direct coverage. The + * override branch runs before any Electron access, so getDataDir() is + * testable under plain Node. + */ +test.describe("EXO_USER_DATA_DIR override", () => { + let saved: string | undefined; + + test.beforeEach(() => { + saved = process.env.EXO_USER_DATA_DIR; + }); + + test.afterEach(() => { + if (saved === undefined) delete process.env.EXO_USER_DATA_DIR; + else process.env.EXO_USER_DATA_DIR = saved; + }); + + test("absolute override is returned verbatim, in any mode", async () => { + process.env.EXO_USER_DATA_DIR = "/tmp/exo-override-test"; + const { getDataDir } = await import("../../src/main/data-dir"); + expect(getDataDir()).toBe("/tmp/exo-override-test"); + }); + + test("relative override fails loudly", async () => { + process.env.EXO_USER_DATA_DIR = "relative/scratch-dir"; + const { getDataDir } = await import("../../src/main/data-dir"); + expect(() => getDataDir()).toThrow(/absolute/); + }); +}); diff --git a/tests/unit/no-global-data-dirs.spec.ts b/tests/unit/no-global-data-dirs.spec.ts new file mode 100644 index 00000000..216fd9e4 --- /dev/null +++ b/tests/unit/no-global-data-dirs.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "fs"; +import { execFileSync } from "child_process"; +import { fileURLToPath } from "url"; +import { dirname, join, relative } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "..", ".."); + +/** + * Regression guard for the prod-config wipe (July 2026). + * + * `clean_test_dbs()` in scripts/run-tests.sh used to `rm -f` the + * electron-store config from the GLOBAL per-user app dirs — including the + * packaged app's real install dir — so every `npm test` deleted the user's + * production API keys and settings. Dev/test state lives exclusively in the + * project-local `.dev-data/` (src/main/data-dir.ts), so no script or test + * has any business referencing global app-data locations — or constructing + * paths from the home directory at all. + * + * Like data-dir.spec.ts, this guards at the file-content level: any mention + * of a global app-data path in scripts/, tests/, or benchmarks/ is a bug + * waiting to fire, regardless of how it's used. + * + * Scans TRACKED files only (git ls-files): untracked local scratch files + * (agent run artifacts, incident notes) can't hurt CI and must not turn + * this test into a machine-local flake. + */ + +const SELF = "tests/unit/no-global-data-dirs.spec.ts"; + +// Fragments assembled by concatenation so this file doesn't flag itself. +const AS = "Application" + " " + "Support"; +const FORBIDDEN: { pattern: RegExp; description: string }[] = [ + { pattern: new RegExp(`${AS}/exo`, "i"), description: "macOS prod data dir (exo)" }, + { pattern: new RegExp(`${AS}/Electron`), description: "macOS Electron default data dir" }, + // Shell-escaped space variant: Application\ Support/exo + // (regex source `Application\\ Support/exo` — one literal backslash + space) + { pattern: new RegExp("Application\\\\ Support/exo", "i"), description: "escaped macOS prod data dir" }, + { pattern: /\.config\/exo/i, description: "Linux prod data dir (exo)" }, + { pattern: /\.config\/Electron/, description: "Linux Electron default data dir" }, + { pattern: /AppData\/Roaming\/exo/i, description: "Windows prod data dir (exo)" }, + // Segment-wise construction: join(home, "Library", "Application Support", ...) + { pattern: new RegExp(`"${AS}"\\s*,`), description: "segment-joined global data dir" }, + // The root cause: home-anchored path construction in cleanup-capable code. + { pattern: /\bhomedir\s*\(/, description: "homedir() path construction" }, + { pattern: /\bos\.homedir\b/, description: "os.homedir path construction" }, + { pattern: /\$\{?HOME[}/]/, description: "$HOME path construction" }, +]; + +// Files allowed to contain specific patterns (inert fixtures, not path code). +const ALLOWLIST: { file: string; description: string }[] = [ + // Bash-hook unit tests assert that the agent-sandbox hook DENIES commands + // containing $HOME — the strings are adversarial fixtures, not paths. + { file: "tests/unit/bash-hook.spec.ts", description: "$HOME path construction" }, +]; + +const SCAN_ROOTS = ["scripts", "tests", "benchmarks"]; + +function trackedFiles(): string[] { + const out = execFileSync("git", ["ls-files", "-z", "--", ...SCAN_ROOTS], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + return out.split("\0").filter((f) => f.length > 0 && f !== SELF); +} + +// A pattern that matches nothing is a silently dead guard (this happened: the +// escaped-space variant shipped doubly-escaped and never matched anything). +// Prove every FORBIDDEN entry catches its canonical bad example. +test("every forbidden pattern matches its canonical bad example", () => { + const BS = "\\"; + const samples: [string, string][] = [ + [`rm -f "$dir/${AS}/exo/exo-config.json"`, "macOS prod data dir (exo)"], + [`"${AS}/Electron/data"`, "macOS Electron default data dir"], + [`rm -rf $HOME/Library/Application${BS} Support/exo`, "escaped macOS prod data dir"], + [`rm -f "$home/.config/exo/exo-config.json"`, "Linux prod data dir (exo)"], + [`"$home/.config/Electron"`, "Linux Electron default data dir"], + [`join(appData, "AppData/Roaming/exo")`, "Windows prod data dir (exo)"], + [`join(home, "Library", "${AS}", "exo")`, "segment-joined global data dir"], + [`const dir = join(homedir(), ".config")`, "homedir() path construction"], + [`const dir = os.homedir()`, "os.homedir path construction"], + [`rm -rf "$HOME/Library"`, "$HOME path construction"], + ]; + for (const [sample, description] of samples) { + const entry = FORBIDDEN.find((f) => f.description === description); + expect(entry, `pattern registered: ${description}`).toBeDefined(); + expect(entry!.pattern.test(sample), `"${description}" must match: ${sample}`).toBe(true); + } +}); + +test("scripts/, tests/, benchmarks/ never reference global per-user app-data dirs", () => { + const files = trackedFiles(); + expect(files.length).toBeGreaterThan(0); + + const violations: string[] = []; + for (const file of files) { + const content = readFileSync(join(REPO_ROOT, file), "utf8"); + for (const { pattern, description } of FORBIDDEN) { + if (!pattern.test(content)) continue; + const allowed = ALLOWLIST.some((a) => a.file === file && a.description === description); + if (!allowed) { + violations.push(`${relative(REPO_ROOT, join(REPO_ROOT, file))} contains ${description} (${pattern})`); + } + } + } + expect(violations).toEqual([]); +});