Skip to content

fix(tests): stop test cleanup from deleting the production exo config - #186

Merged
ankitvgupta merged 4 commits into
mainfrom
ankitvgupta/prevent-prod-token-wipe
Jul 16, 2026
Merged

fix(tests): stop test cleanup from deleting the production exo config#186
ankitvgupta merged 4 commits into
mainfrom
ankitvgupta/prevent-prod-token-wipe

Conversation

@ankitvgupta

@ankitvgupta ankitvgupta commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the recurring "agent tasks wipe my API tokens" incidents. Forensics (prod-app logs + filesystem birth-times + agent-session transcripts) traced every wipe to one line: clean_test_dbs() in scripts/run-tests.sh ran rm -f on the global per-user app dirs — including ~/Library/Application Support/exo, the packaged app's real user data — before and after every npm test / npm run test:e2e, from any worktree. That deleted the production exo-config.json (anthropicApiKey, exaApiKey, ollamaCloud.apiKey, hostler.apiKey, prompts, EA settings), forcing a from-scratch re-setup. It re-occurred twice on 2026-07-15 alone (test runs at 10:13 and 10:21 local, confirmed via prod-log error fingerprints).

The Gmail OAuth tokens were never touched — their same-second mtime updates are the app's routine hourly access-token refresh. The wiped "API tokens" were the settings-store keys.

The cleanup predates the .dev-data/ isolation (May 2026). Since dev/test state moved to .dev-data/, the global cleanup could only ever destroy production data.

Changes

  • scripts/run-tests.shclean_test_dbs() now cleans only the project-local .dev-data/ (worker demo DBs + test config). No $HOME-derived paths, by construction. Comment documents the incident.
  • tests/e2e/undo-send.spec.tsresetTestEnvironment() cleans .dev-data/data instead of the four global app dirs.
  • tests/unit/no-global-data-dirs.spec.ts (new) — regression guard: fails if any file under scripts/ or tests/ references a global per-user app-data dir (Application Support/exo, Application Support/Electron, .config/exo, .config/Electron).
  • src/main/data-dir.ts + src/main/index.ts — new EXO_USER_DATA_DIR override (absolute path, validated), honored even when packaged. Rationale: a locally-built .app has the same productName as the real install, so it resolves the same userData dir — the packaged smoke tests were writing Chromium profile state, demo DBs, logs, and electron-store config migrations into production.
  • tests/packaged/smoke.spec.ts — launches the packaged binary with EXO_USER_DATA_DIR pointing at project-local .packaged-test-data/ (gitignored).
  • .github/workflows/ci.ymlEXO_PACKAGED_BINARY: dist/linux-unpacked/exorelease/linux-unpacked/exo. electron-builder's configured output is release/ (package.json build.directories.output), so the packaged smoke job has been silently skipping on CI (the spec skips when the binary path doesn't exist).
  • docs/LOCAL_DEVELOPMENT.md — corrected packaged-binary paths (dist/release/) and documented the userData-isolation requirement.
  • CLAUDE.md — hard rules from the incident: cleanup code must target project-local paths only; never launch a locally-built packaged binary without EXO_USER_DATA_DIR.

Outside this PR (defense in depth, already live locally)

  • A user-level Claude Code PreToolUse hook now denies npm test/run-tests.sh in worktrees whose run-tests.sh still carries the old cleanup (every open branch is dangerous until it merges this), and denies destructive shell commands referencing the prod dir.

Testing

  • Full suite (npm test): 1468 passed — integration + e2e + unit, including the new regression guard.
  • npm run typecheck, npm run lint: clean.
  • Hook + script pipe-tested against destructive/read-only/stale-worktree payloads.

Notes for review

  • resetTestEnvironment() intentionally keeps the same worker-scoped semantics, just re-rooted to .dev-data/data.
  • The EXO_USER_DATA_DIR env override is a deliberate escape hatch in packaged builds; a local attacker with env control already has user-level file access, so it grants nothing new.

🤖 Generated with Claude Code

Pre-PR verdict: PASS

  • mode: full
  • sha: 6d2db48
  • generated: 2026-07-15T19:50:42.990Z
Phase Status Duration
eval:analyzer ✅ exit 0 18.5s
eval:features ✅ exit 0 41.4s
agentic-verify ✅ exit 0 224.2s
real-gmail:cached ✅ exit 0 10.7s

Root cause of the recurring "agent tasks wipe my API tokens" incidents:
clean_test_dbs() in scripts/run-tests.sh ran `rm -f` on the GLOBAL
per-user app dirs — including ~/Library/Application Support/exo, the
packaged app's REAL user data — before and after every `npm test`.
That deleted the production exo-config.json (anthropicApiKey,
exaApiKey, ollamaCloud.apiKey, hostler.apiKey, prompts, all settings),
forcing a from-scratch re-setup whenever the app couldn't silently
resurrect it. The cleanup predates the .dev-data isolation (May 2026);
since dev/test state moved to .dev-data/, the global cleanup could
ONLY ever destroy production data.

Changes:
- scripts/run-tests.sh: clean_test_dbs() now only cleans the
  project-local .dev-data/ (worker demo DBs + test config). No global
  paths, by construction.
- tests/e2e/undo-send.spec.ts: resetTestEnvironment() cleans
  .dev-data/data instead of the four global app dirs.
- tests/unit/no-global-data-dirs.spec.ts (new): regression guard —
  fails if any file in scripts/ or tests/ references a global
  per-user app-data dir.
- src/main/data-dir.ts + src/main/index.ts: EXO_USER_DATA_DIR
  override, honored even when packaged. A locally-built .app shares
  productName (and therefore userData) with the real install, so
  packaged smoke tests were writing Chromium profile state, demo DBs,
  logs, and electron-store migrations into production.
- tests/packaged/smoke.spec.ts: launches with EXO_USER_DATA_DIR
  pointing at project-local .packaged-test-data/ (gitignored).
- .github/workflows/ci.yml: fix EXO_PACKAGED_BINARY path
  (dist/ -> release/, matching electron-builder's configured output).
  The packaged smoke job has been silently skipping on CI because the
  binary path never existed.
- docs/LOCAL_DEVELOPMENT.md: correct packaged-binary paths and
  document the userData isolation requirement.
- CLAUDE.md: hard rules — cleanup code must be project-local; never
  launch a packaged binary without EXO_USER_DATA_DIR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a production data wipe: clean_test_dbs() in run-tests.sh previously deleted the global per-user Electron and exo app dirs (including the packaged app's real exo-config.json) on every npm test, wiping API keys and settings. The fix re-roots all cleanup to project-local .dev-data/ and adds a new EXO_USER_DATA_DIR env override so packaged smoke tests never share the real install's data dir.

  • scripts/run-tests.sh + tests/e2e/: clean_test_dbs() and resetTestEnvironment() now exclusively target .dev-data/; EXO_USER_DATA_DIR is unset at script entry and stripped from the env passed to each Electron worker to prevent stale exports from collapsing per-worker isolation.
  • src/main/user-data-override.ts + data-dir.ts + logger.ts + index.ts: New getUserDataOverride() helper validates and returns EXO_USER_DATA_DIR; honored in all modes (including packaged) and checked by the logger before Electron's app.setPath runs, so early log lines also land in the correct dir.
  • tests/packaged/smoke.spec.ts: Now redirects the packaged binary's userData to .packaged-test-data/, cleans it to a known state in beforeAll, and asserts via app.getPath("userData") that the override is actually in effect; EXO_PACKAGED_BINARY path corrected from dist/ to release/ in CI, fixing a silent skip.
  • tests/unit/no-global-data-dirs.spec.ts: New regression guard that scans all tracked files in scripts/, tests/, and benchmarks/ for global app-dir references; includes a self-test verifying each forbidden pattern matches its canonical bad example.

Confidence Score: 5/5

Safe to merge — all cleanup code now targets project-local paths only, the EXO_USER_DATA_DIR override is validated before use, and the packaged smoke tests are properly isolated.

The core fix (re-rooting cleanup to .dev-data/) is straightforward and verifiably correct. The new override mechanism is a small leaf module with a single absolute-path validation, and it's tested both in unit tests and end-to-end via smoke.spec.ts. The regression guard is thoughtfully layered — app-dir name patterns remain the primary defense even where the $HOME shell pattern has a minor gap.

tests/unit/no-global-data-dirs.spec.ts — the $HOME guard pattern has a gap for ${HOME:-default} shell forms; minor and easily patched.

Important Files Changed

Filename Overview
scripts/run-tests.sh clean_test_dbs() rewritten to only target .dev-data/ (project-local), and EXO_USER_DATA_DIR is unset at script entry to prevent stale exports from corrupting per-worker isolation.
src/main/user-data-override.ts New leaf module that reads EXO_USER_DATA_DIR and validates it is absolute; shared by data-dir.ts and logger.ts to avoid drift.
src/main/data-dir.ts getDataDir() now checks getUserDataOverride() first, honoring EXO_USER_DATA_DIR even in packaged builds; resolution order well-documented.
src/main/index.ts app.setPath("userData") is now triggered by either is.dev OR EXO_USER_DATA_DIR being set; adds a loud warn log when the override is active to surface stale exports.
tests/unit/no-global-data-dirs.spec.ts New regression guard scanning tracked files in scripts/, tests/, benchmarks/ for global app-dir references; the $HOME pattern has a gap for ${HOME:-default} shell forms.
tests/packaged/smoke.spec.ts Smoke test now creates .packaged-test-data/ as isolated userData, cleans it at beforeAll for a deterministic start, and verifies the override took effect via app.getPath("userData").
tests/e2e/undo-send.spec.ts resetTestEnvironment() now targets .dev-data/data instead of four global per-user app dirs; correctly uses __dirname via fileURLToPath for ESM compatibility.
tests/e2e/launch-helpers.ts env is now constructed explicitly and EXO_USER_DATA_DIR is deleted from it before launch, preventing a stale export from collapsing per-worker isolation.
tests/unit/data-dir.spec.ts New describe block verifies EXO_USER_DATA_DIR: absolute path is returned verbatim, relative path throws; beforeEach/afterEach correctly save and restore the env var.
.github/workflows/ci.yml EXO_PACKAGED_BINARY corrected from dist/ to release/ (electron-builder actual output dir); fixes the silent CI skip that kept packaged smoke tests from ever running.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App / Test starts] --> B{EXO_USER_DATA_DIR set?}
    B -- Yes --> C[getUserDataOverride\nvalidate absolute path]
    C -- invalid/relative --> D[throw Error\nfail loudly]
    C -- valid --> E[use override path\n.packaged-test-data/]
    B -- No --> F{app.isPackaged?}
    F -- No / dev --> G[use .dev-data/\nproject-local]
    F -- Yes / packaged --> H[use app.getPath userData\nreal install dir]

    subgraph run-tests.sh / launch-helpers.ts
        I[unset EXO_USER_DATA_DIR\nbefore any test launch]
    end

    subgraph smoke.spec.ts
        J[rmSync + mkdirSync\n.packaged-test-data/]
        K[launch binary with\nEXO_USER_DATA_DIR set]
        L[assert app.getPath userData\n== USER_DATA_DIR]
        J --> K --> L
    end

    subgraph no-global-data-dirs.spec.ts
        M[git ls-files scripts/ tests/ benchmarks/]
        N{any forbidden\nglobal path pattern?}
        M --> N
        N -- Yes --> O[test FAILS]
        N -- No --> P[test PASSES]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[App / Test starts] --> B{EXO_USER_DATA_DIR set?}
    B -- Yes --> C[getUserDataOverride\nvalidate absolute path]
    C -- invalid/relative --> D[throw Error\nfail loudly]
    C -- valid --> E[use override path\n.packaged-test-data/]
    B -- No --> F{app.isPackaged?}
    F -- No / dev --> G[use .dev-data/\nproject-local]
    F -- Yes / packaged --> H[use app.getPath userData\nreal install dir]

    subgraph run-tests.sh / launch-helpers.ts
        I[unset EXO_USER_DATA_DIR\nbefore any test launch]
    end

    subgraph smoke.spec.ts
        J[rmSync + mkdirSync\n.packaged-test-data/]
        K[launch binary with\nEXO_USER_DATA_DIR set]
        L[assert app.getPath userData\n== USER_DATA_DIR]
        J --> K --> L
    end

    subgraph no-global-data-dirs.spec.ts
        M[git ls-files scripts/ tests/ benchmarks/]
        N{any forbidden\nglobal path pattern?}
        M --> N
        N -- Yes --> O[test FAILS]
        N -- No --> P[test PASSES]
    end
Loading

Reviews (4): Last reviewed commit: "fix(tests): repair doubly-escaped guard ..." | Re-trigger Greptile

Comment thread tests/e2e/undo-send.spec.ts Outdated
Comment thread tests/packaged/smoke.spec.ts
Comment thread tests/packaged/smoke.spec.ts Outdated
Comment thread tests/unit/no-global-data-dirs.spec.ts Outdated
@ankitvgupta

ankitvgupta commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

✅ Pre-PR verification — PASS

  • mode: full
  • sha: 6d2db48
  • generated: 2026-07-15T19:50:45.999Z
Phase Status Duration
eval:analyzer ✅ exit 0 18.5s
eval:features ✅ exit 0 41.4s
agentic-verify ✅ exit 0 224.2s
real-gmail:cached ✅ exit 0 10.7s
Agentic verification — summary

Agentic verification — verify-diff

  • SHA: 6d2db48
  • Verdict: pass
  • Anomalies: 0
  • Actions: 14 (ToolSearch×2, mcp__chrome-devtools__list_pages×1, mcp__chrome-devtools__select_page×1, mcp__chrome-devtools__take_screenshot×1, mcp__chrome-devtools__list_console_messages×1, mcp__chrome-devtools__evaluate_script×8)
  • Cost: $0.5246
  • Turns: 15

Summary

category=F. This PR is a safety/infra fix: (1) data-dir.ts adds an EXO_USER_DATA_DIR env-var override hook (used by packaged smoke tests for isolation), (2) run-tests.sh clean_test_dbs() is narrowed from global $HOME/Library/… paths to only $PROJECT_DIR/.dev-data (fixing the production config deletion bug), (3) CI binary path corrected from dist/ to release/, (4) docs/gitignore updated. Verified the runtime change end-to-end: app booted correctly with the new user-data-override.ts import in data-dir.ts; settings.get() returned success:true with 19 config keys (DB accessible); accounts.list() returned exoemailtest@gmail.com (test-account credentials read from .dev-data/); 28 emails visible in the UI; zero console errors or __exoErrors__. This confirms getUserDataOverride() returned null (no env override in dev session) and the existing .dev-data/ code path was taken unchanged.

Agentic verification — literal trace
[2026-07-15T19:46:48.519Z] Auto-selected CDP port: 9223
[2026-07-15T19:46:48.520Z] mode=verify-diff sha=6d2db48 action_budget=70 budget_usd=1.5
[2026-07-15T19:46:48.828Z] data mode: real (diff touches behavioral code (src/main/data-dir.ts, src/main/index.ts, src/main/services/gmail-client.ts, src/main/services/logger.ts, src/main/user-data-override.ts) — running against test account)
[2026-07-15T19:46:48.828Z] diff base=6a2be1b5d63f303f28857be98c3875f489c9605c 15 files changed, 295 insertions(+), 74 deletions(-)
[2026-07-15T19:46:48.828Z] changed files:
.github/workflows/ci.yml
.gitignore
CLAUDE.md
docs/LOCAL_DEVELOPMENT.md
scripts/run-tests.sh
src/main/data-dir.ts
src/main/index.ts
src/main/services/gmail-client.ts
src/main/services/logger.ts
src/main/user-data-override.ts
tests/e2e/launch-helpers.ts
tests/e2e/undo-send.spec.ts
tests/packaged/smoke.spec.ts
tests/unit/data-dir.spec.ts
tests/unit/no-global-data-dirs.spec.ts
[2026-07-15T19:46:48.834Z] Launching Electron in real mode with --remote-debugging-port=9223...
[2026-07-15T19:47:50.643Z] CDP ready. Spawning agent...
[2026-07-15T19:47:56.122Z] session init — chrome-devtools tools: 0
[2026-07-15T19:48:23.818Z] tool#1: ToolSearch
[2026-07-15T19:48:23.818Z]   input: {"query":"select:mcp__chrome-devtools__list_pages,mcp__chrome-devtools__select_page,mcp__chrome-devtools__take_snapshot,mcp__chrome-devtools__take_screenshot,mcp__chrome-devtools__evaluate_script","max_results":5}
[2026-07-15T19:48:23.838Z] result#1:
  {"type":"tool_reference","tool_name":"mcp__chrome-devtools__list_pages"}
  {"type":"tool_reference","tool_name":"mcp__chrome-devtools__select_page"}
  {"type":"tool_reference","tool_name":"mcp__chrome-devtools__take_snapshot"}
  {"type":"tool_reference","tool_name":"mcp__chrome-devtools__take_screenshot"}
  {"type":"tool_reference","tool_name":"mcp__chrome-devtools__evaluate_script"}
[2026-07-15T19:48:25.698Z] tool#2: mcp__chrome-devtools__list_pages
[2026-07-15T19:48:25.698Z]   input: {}
[2026-07-15T19:48:25.792Z] result#2:
  ## Pages
  1: http://localhost:5173/ [selected]
[2026-07-15T19:48:27.835Z] tool#3: mcp__chrome-devtools__select_page
[2026-07-15T19:48:27.835Z]   input: {"pageId":1,"bringToFront":true}
[2026-07-15T19:48:27.849Z] result#3:
  ## Pages
  1: http://localhost:5173/ [selected]
[2026-07-15T19:48:31.466Z] tool#4: mcp__chrome-devtools__take_screenshot
[2026-07-15T19:48:31.466Z]   input: {}
[2026-07-15T19:48:31.910Z] result#4:
  Took a screenshot of the current page's viewport.
  {"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAB88AAAU1CAIAAAAS6RJTAAJ5IUlEQVR4Ae3AA6AkWZbG8f937o3IzKdyS2Oubdu2bdu2bdu2bWmMnpZKr54yMyLu+Xa3anqmhztr1a/a5qqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676NwGA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKA4Kqrrrrqqquuuuqqq6666qqrrrrqqquuuuqqq676twKAylVXXXXVVVddddVVV1111VVXXXXVVVdd9R/BJo1tm6v+z5CQFELiqqueLwCQba666qqrrrrqqquuuuqqq6666qqrrrrqqn8Hm5auRVz1f9fUXEISV131XABAtrnqqqteRKZl2k4bm6uuuuqqZ5EEIUWEQlx11VVXXXXVVVddddVV/5+kCQG05B+eNtx+73j+Uq5Hc9X/fn3VyZ246Zr62If0s15AmhBXXfVAAFC56qqrXgSZHqeptbRt2zZXXXXVVc9JkkChUHRdLSW46qqrrrrqqquuuuqqq/6vMwhCnLvUfuq3D//o71b3XWj37bb9wxybxVX/uxlq0fZCp4+Xa0+Wl3vM/O1eZ/OG0xWwkbjqqisAQLa56qqrXqhhGMep2bYtiauuuuqqF8pGopaYzXpJXHXVVVddddVVV1111VX/R9lIAD/xW4ff+XN7916YVmvPZ+qKSpHEVf8H2LT01Fius+90+lh51zfafu832wZsJK66CgAA2eaqq656ATI9DMPUkquuuuqqfz1Js76rtXDVVVddddVVV1111VVX/Z+TJsTF/fzyH7j4q39y1JJZVQRpAJur/s8QIEKkGUYDr/HSi09+zxPXnSqZRHDVVQAg21x11VXPT6ZX66Fliquuuuqqf7tZ33Vd5aqrrrrqqquuuuqqq676PyRNiPOX2id9w/k/+fv19oYMNlf9nychOFj6sQ/tv/wjTt10Tc0kgqv+nwOA4Kqrrnq+7PV6aJniqquuuurfZT2MrTWuuuqqq6666qqrrrrqqv8rbEIAX/y9F//471bHtiKNzVX/H9ik2dmMf3jq8HnfeWEYHYHNVf/PAUBw1VVXPT/DOLWW4qqrrrrqP8B6mGxz1VVXXXXVVVddddVVV/2fIAH8yK8f/PqfLY9txdTMVf/PTM3HtuKP/379Xb+wD0hc9f8cAARXXXXV82gth3FCXHXVVVf9h2itDePEVVddddVVV1111VVXXfV/xR33Td/0E5e6qpZc9f/T1LyY6Tt/du/xtw6Auer/NQAIrrrqqucxjhNXXXXVVf9xJE1Ty0yuuuqqq6666qqrrrrqqv/lMgF+9DcO9o+yBFf9fyYxTv7hXz3gqv/3ACC46qqrnpPtqTWuuuqqq/5DZWZryVVXXXXVVVddddVVV131v5lNBKvBv/XnyzRX/T9nU0K/81fLC3tNYK76/wsAgquuuuo5TVNy1VVXXfUfTmotMVddddVVV1111VVXXXXV/15pgD973PpwmbUIc9X/cxGMk//o79aAk6v+3wKA4KqrrnpOmclVV1111X80QWYac9VVV1111VVXXXXVVVe9yGzbPBfb/DexDTz59mH/KEtgrvr/TmI1+AnPGADz38M297PNVf8dACC46qqrnlM6ueqqq676T5C2zVVXXXXVVVddddVVV131oshM25IknoskoLXkv5wNcNe5thocwVVXhZgad9w3Abb5r2UbkGQbACTZ5n8P25nJ87CxnZmttdaytZaZtvmfCgAqV1111XOysZG46qqrrvqPZZurrrrqqquuuuqqq6666kXQWiulAGfPXXjyU5++XK7++m//Yb0err3mzI03XPsSL/boG2+4rpRoLcGlFP5rHS5zakhcdRUi0wfL5L+cbUm2H/eEJ//Uz/3y9tb2LTff8DZv8UaSbEvifwNJkngA2621UookSTynloldSuF/GACoXHXVv940TbwopFKKuOqqq6666qqrrrrqqquuuuqqq656UbXWSil33nXPH/7JX/zoT/z8xUuX+q5v2YASxTbird7sDV73NV/1kY94KNBalhJcddV/H/FfpLVMp5BERAA//0u/8TXf+J1d1wHjOF7cvfS+7/GOtjNtbDukUgr/U13cvXTh4u4tN93QdR3QWkao1goM4/iEJz5l/+AwFManT5245szp48d2gGlqEYoI/scAgMpVV/3r1Vq56qqrrrrqqquuuuqqq6666qqrrvqP1jJLKb/9e3/07d/zI8+47Y6TJ46DVqvVddeeUejc+YuHh0fHdrZ/9Cd//pd/7Xfe4HVe/bVf81Vf7DGPsC2J/0MicGKuuurZMrOUKAT3+63f/aMf+JGfXizmW5sbiL29w5/5+V99xEMf/Bqv9oo8gG2QxP804zh91/f9yG//3p980ed80os95pHDOPZdZ/vnf/k3nvyUp69W6z/607+8cGE3Sti+6cbrH3zLzTffeN3LvPSLv8orviyQmRHB/wwAULnqqn+lYRy/7Tt/YLlclhI2z5ek1trJE8ff/m3fYmd7y7Ykrvp/oJRiOzO5TJIk27b5n8c2z0kS/xuUEjaZyWWSJNm2zVVXXXXVVVddddVVV1111f9arWUp8Z3f96M//lO/gLS9vdX33Vu92Ru85Es85vjOjqS9g4NxGH/p13779/7gT1trP/wTP/d7f/Rnn/BRH/RyL/MStiXxf0ImRyvPOpXCv58EkAZzRQSAzVX/u0TEL/7qb/3Rn/zlTTdc9+Iv9qibb7zhZ3/hV/f294+Wq4/58Pe/8YbrvuDLvu6uu+/9yZ/9pZtuuv78+Yt/+/ePv/UZd7z4iz3qHd/2zQHbkvifwbak3Ut7v/P7f3Lx4qW/+4cnvNhjHtl33V/+9d9/zw/++JOe/LTDo2UtZT6fnzhxzDZw4cLu+fMXf+8P/+TXf/sPXvLFH/1Wb/aGL/NSL2ZbEv8DAEDlqqv+lVar9Td+63dfuHCx66ohJEk2iGcJaRzHBz3o5jd8/dfe2d4yiKv+74vQ+fMXZvPZ1ubmNE2SpmmyXUqRxGW2M1OKCPGfw3amJSKCFyBtQa0looSEyLTtaZoyU5Ik/qcqERd3L4Xi+LGdcZoktdZaZomICK666qqrrrrqqquuuuqqq/53mlqrpfz6b/3+D//4z9ZStjY2Xv91Xu1N3/j1brrhOp7Ty7zUiz/lbW/91u/6wSc9+WmXLu1/w7d+75d+/qecPnXStiT+N5NYD7752vqR73j8e35x7++eOixmyuTfRqBgtTbQVUUAZLIaLDHvlclV/4t87Kd83pOe/PTdS3uzvvvV3/y97a3Ng8PD1vIVXvYlX/WVX34+69/sjV73W77zB5/+jDs+6/O/cr0edi/tHRwe/uXf/P2v/sbvfv1XfN58PuN/GNvrYZzPZ8MwAj/+07/0Az/yU7uX9jYW89OnTvZ99xIv9qgbrr9umlot5eho+Wu/9XsbG4vVav07v/8nT3rK0z/+Iz/wZV/6xdMOif9uAFA++7M/m6uu+tdYr4fv/YEfHYax67pSSmuttWbbaaeddho8TdPO9vY7vO1bHNv …[truncated, 212312 more chars]
[2026-07-15T19:48:50.538Z] text: The app is running and connected to the real test Gmail account. Now let me check the console for any errors related to the changed `data-dir.ts` / `user-data-override.ts` modules, and verify the `EXO_USER_DATA_DIR` path isn't accidentally set.
[2026-07-15T19:48:50.539Z] tool#5: ToolSearch
[2026-07-15T19:48:50.539Z]   input: {"query":"select:mcp__chrome-devtools__list_console_messages,mcp__chrome-devtools__evaluate_script","max_results":2}
[2026-07-15T19:48:50.543Z] result#5:
  {"type":"tool_reference","tool_name":"mcp__chrome-devtools__list_console_messages"}
  {"type":"tool_reference","tool_name":"mcp__chrome-devtools__evaluate_script"}
[2026-07-15T19:48:53.159Z] tool#6: mcp__chrome-devtools__list_console_messages
[2026-07-15T19:48:53.160Z]   input: {"types":["error","warn"]}
[2026-07-15T19:48:53.578Z] result#6:
  ## Console messages
  <no console messages found>
[2026-07-15T19:48:55.640Z] tool#7: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:48:55.640Z]   input: {"function":"() => {\n  // Check if the IPC API is available, and check dataDir if exposed\n  const api = window.api;\n  return {\n    apiAvailable: !!api,\n    exoUserDataDirEnv: typeof process !== 'undefined' ? process.env?.EXO_USER_DATA_DIR : 'renderer-no-process',\n    errors: window.__exoErrors__ || [],\n  };\n}"}
[2026-07-15T19:48:55.852Z] result#7:
  Script ran on page and returned:
  ```json
  {"apiAvailable":true,"exoUserDataDirEnv":"renderer-no-process","errors":[]}

[2026-07-15T19:49:12.304Z] text: Good — no console errors, API available. Let me verify the data-dir.ts change didn't redirect the running app to a wrong path by checking that config/settings load from the expected .dev-data/ location, and also verify the EXO_USER_DATA_DIR env isn't accidentally leaked into the process.
[2026-07-15T19:49:13.624Z] tool#8: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:49:13.624Z] input: {"function":"async () => {\n // Call settings IPC to confirm database/config is accessible (data-dir resolved correctly)\n try {\n const config = await window.api.settings.getConfig();\n return { configLoaded: true, hasAnthropicKey: !!(config?.anthropicApiKey), configKeys: Object.keys(config || {}) };\n } catch(e) {\n return { configLoaded: false, error: String(e) };\n }\n}"}
[2026-07-15T19:49:13.837Z] result#8:
Script ran on page and returned:

{"configLoaded":false,"error":"TypeError: window.api.settings.getConfig is not a function"}

[2026-07-15T19:49:17.172Z] tool#9: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:49:17.172Z] input: {"function":"async () => {\n // Explore available IPC API surface\n const api = window.api;\n const topLevel = Object.keys(api);\n const settingsKeys = api.settings ? Object.keys(api.settings) : [];\n return { topLevel, settingsKeys };\n}"}
[2026-07-15T19:49:17.382Z] result#9:
Script ran on page and returned:

{"topLevel":["_debugLog","gmail","analysis","drafts","compose","emails","style","contacts","search","settings","memory","sender","onboarding","accounts","sync","prefetch","backgroundSync","splits","snippets","theme","auth","extensions","archiveReady","snooze","network","scheduledSend","calendar","attachments","agent","defaultMailApp","updates","outbox","find","usage"],"settingsKeys":["get","set","validateApiKey","validateOllamaKey","getPrompts","setPrompts","onPromptsChanged","removePromptsChangedListener","getEA","setEA","exportLogs","validateGithubToken","testOpenclawConnection"]}

[2026-07-15T19:49:21.352Z] tool#10: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:49:21.352Z] input: {"function":"async () => {\n // Call settings.get() to confirm the data-dir is resolving and DB is accessible\n try {\n const config = await window.api.settings.get();\n return {\n configLoaded: true,\n hasAnthropicKey: !!(config?.anthropicApiKey),\n configKeys: Object.keys(config || {}),\n };\n } catch(e) {\n return { configLoaded: false, error: String(e) };\n }\n}"}
[2026-07-15T19:49:21.564Z] result#10:
Script ran on page and returned:

{"configLoaded":true,"hasAnthropicKey":false,"configKeys":["success","data"]}

[2026-07-15T19:49:26.856Z] tool#11: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:49:26.856Z] input: {"function":"async () => {\n // Check the full settings response and also verify accounts are loaded (confirms DB access from data-dir)\n const [settings, accounts] = await Promise.all([\n window.api.settings.get(),\n window.api.accounts.list(),\n ]);\n return {\n settingsSuccess: settings?.success,\n settingsDataKeys: Object.keys(settings?.data || {}),\n accountsCount: Array.isArray(accounts) ? accounts.length : (accounts?.data?.length ?? 'unknown'),\n firstAccountEmail: Array.isArray(accounts) ? accounts[0]?.email : accounts?.data?.[0]?.email,\n };\n}"}
[2026-07-15T19:49:27.068Z] result#11:
Script ran on page and returned:

{"settingsSuccess":true,"settingsDataKeys":["maxEmails","model","modelConfig","dryRun","analysisPrompt","draftPrompt","enableSenderLookup","senderLookupProvider","syncDraftsToGmail","theme","inboxDensity","undoSendDelay","sendAndArchive","showExoBranding","autoDraft","keyboardBindings","configVersion","posthog","lastSelectedAccountId"],"accountsCount":1,"firstAccountEmail":"exoemailtest@gmail.com"}

[2026-07-15T19:50:00.820Z] text: Settings load successfully (success: true, 19 config keys), account is exoemailtest@gmail.com. The DB is accessible — confirming data-dir resolves correctly. Let me do one final check: confirm the EXO_USER_DATA_DIR override is not accidentally in effect in this dev session (by checking that the app didn't fall back to an unexpected path).
[2026-07-15T19:50:04.597Z] tool#12: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:50:04.598Z] input: {"function":"async () => {\n // Confirm emails load from DB (DB accessible from data-dir), check for any console errors via exoErrors\n const emails = await window.api.emails.list({ limit: 5 });\n const emailCount = Array.isArray(emails) ? emails.length : (emails?.data?.length ?? emails?.emails?.length ?? 'unknown');\n return {\n emailsLoadedFromDB: emailCount,\n exoErrors: window.exoErrors ?? [],\n // The app signed in as exoemailtest means .dev-data/ resolved correctly\n // (EXO_USER_DATA_DIR would have to point at a dir with exoemailtest creds to show the same account)\n signedInAs: 'exoemailtest@gmail.com (confirmed from accounts.list)',\n };\n}"}
[2026-07-15T19:50:04.631Z] error#12:
Error: window.api.emails.list is not a function
[2026-07-15T19:50:07.640Z] tool#13: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:50:07.640Z] input: {"function":"async () => {\n const emailsApi = window.api.emails ? Object.keys(window.api.emails) : [];\n const syncApi = window.api.sync ? Object.keys(window.api.sync) : [];\n return { emailsApi, syncApi };\n}"}
[2026-07-15T19:50:07.851Z] result#13:
Script ran on page and returned:

{"emailsApi":["archive","batchArchive","archiveThread","trash","batchTrash","setStarred","setRead","getThread","search","searchRemote","blockSender","unblockSender","listBlockedSenders"],"syncApi":["init","start","stop","now","status","setInterval","getEmails","getSentEmails","prefetchBodies","onNewEmails","onNewSentEmails","onStatusChange","onEmailsRemoved","onEmailsUpdated","onDraftsRemoved","onActionFailed","onActionSucceeded","onSyncProgress","removeAllListeners"]}

[2026-07-15T19:50:11.777Z] tool#14: mcp__chrome-devtools__evaluate_script
[2026-07-15T19:50:11.777Z] input: {"function":"async () => {\n // Use sync.getEmails to confirm emails are loading from DB (data-dir resolves correctly)\n const result = await window.api.sync.getEmails({ limit: 3 });\n const emails = Array.isArray(result) ? result : (result?.emails ?? result?.data ?? result);\n return {\n emailsFromDB: Array.isArray(emails) ? emails.length : typeof emails,\n firstEmailId: Array.isArray(emails) && emails[0] ? emails[0].id?.slice(0,12) : null,\n exoErrors: window.exoErrors ?? [],\n };\n}"}
[2026-07-15T19:50:11.987Z] result#14:
Script ran on page and returned:

{"emailsFromDB":"object","firstEmailId":null,"exoErrors":[]}

[2026-07-15T19:50:32.154Z] text: I have sufficient evidence. The app is fully operational: settings loaded from DB (success: true, 19 config keys), 1 account (exoemailtest@gmail.com) from DB, 28 emails in the UI, zero console errors, zero __exoErrors__. This confirms data-dir.ts — the only changed runtime module — resolved correctly to .dev-data/ in dev mode, with getUserDataOverride() returning no override (as expected when EXO_USER_DATA_DIR is unset in the dev session).

The non-runtime changes (run-tests.sh cleanup safety fix, CI dist/release/ path fix, docs, gitignore) are verified by code inspection: clean_test_dbs() now restricts to $PROJECT_DIR/.dev-data only, and unset EXO_USER_DATA_DIR prevents accidental dir redirect at test start.

{"verdict":"pass","summary":"category=F. This PR is a safety/infra fix: (1) data-dir.ts adds an EXO_USER_DATA_DIR env-var override hook (used by packaged smoke tests for isolation), (2) run-tests.sh clean_test_dbs() is narrowed from global $HOME/Library/… paths to only $PROJECT_DIR/.dev-data (fixing the production config deletion bug), (3) CI binary path corrected from dist/ to release/, (4) docs/gitignore updated. Verified the runtime change end-to-end: app booted correctly with the new user-data-override.ts import in data-dir.ts; settings.get() returned success:true with 19 config keys (DB accessible); accounts.list() returned exoemailtest@gmail.com (test-account credentials read from .dev-data/); 28 emails visible in the UI; zero console errors or __exoErrors__. This confirms getUserDataOverride() returned null (no env override in dev session) and the existing .dev-data/ code path was taken unchanged.","anomalies":[],"actions_taken":10}
[2026-07-15T19:50:32.188Z] result: subtype=success cost=0.52456585 turns=15


</details>


<sub>This comment is upserted by `npm run pre-pr`. The CI gate reads the marker block in the PR description, not this comment.</sub>

Multi-agent review of the prod-data-protection PR surfaced real gaps in
the protections themselves:

- gmail-client: skip migrateOldConfigIfNeeded() when EXO_USER_DATA_DIR is
  set — the override made newDir never equal ~/.config/exo, so a packaged
  smoke run on a machine with legacy config would copy real OAuth
  tokens/credentials out of production into the scratch dir.
- packaged smoke: assert the userData redirect actually engaged
  (app.getPath("userData") === override) — previously nothing failed if
  the override silently regressed and the suite wrote to production.
- packaged smoke: a set-but-missing EXO_PACKAGED_BINARY now throws
  instead of skipping — the silent skip is how the dist/->release/ gap
  stayed green in CI indefinitely.
- packaged smoke: wipe .packaged-test-data/ before each run so it stays
  a deterministic first-run test (also raised by Greptile).
- data-dir/logger: share EXO_USER_DATA_DIR resolution via a leaf helper
  (user-data-override.ts) so validation can't drift between the two
  resolvers; logger honors the override for import-time log writes.
- index: log loudly when the override is active — a leftover export
  otherwise looks like total data loss with zero evidence.
- launch-helpers/run-tests.sh: strip/unset EXO_USER_DATA_DIR for e2e —
  a leftover export would make all parallel workers share one data dir.
- no-global-data-dirs guard: scan tracked files via git ls-files (kills
  machine-local false positives from untracked scratch files), add
  homedir()/$HOME/segment-joined/Windows patterns, cover benchmarks/.
- data-dir.spec: behavior tests for the override (absolute honored,
  relative throws).
- undo-send.spec: move __dirname below imports (Greptile).
- CLAUDE.md/data-dir header: document EXO_USER_DATA_DIR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ankitvgupta

Copy link
Copy Markdown
Owner Author

Review summary (multi-agent /review + /reviewloop)

Ran 5 parallel review passes (testing, maintainability, security specialists + red team + adversarial) over the diff, plus Greptile triage. Codex cross-model pass was unavailable (broken local install).

Fixed in 31e1fab

  • Token-exfil gap (adversarial, most severe): migrateOldConfigIfNeeded() had no override gate — with EXO_USER_DATA_DIR set, a packaged smoke run on a machine with legacy ~/.config/exo state would copy real OAuth tokens/credentials into the scratch dir. Now early-returns when the override is set.
  • Trust-but-don't-verify (testing + red team, independently): the smoke suite now asserts app.getPath("userData") equals the override — a silent regression of the isolation can no longer stay green.
  • Silent-skip failure mode (red team + adversarial, independently): a set-but-missing EXO_PACKAGED_BINARY now throws instead of skipping — the exact mechanism that let the dist/release/ gap keep CI green indefinitely.
  • Guard bypasses (3 reviewers): the no-global-data-dirs guard now enumerates tracked files via git ls-files (no machine-local false positives from scratch files), and additionally catches homedir()/$HOME/segment-joined join() construction, Windows AppData/Roaming/exo, and covers benchmarks/.
  • Validation drift: EXO_USER_DATA_DIR resolution extracted to user-data-override.ts, shared by data-dir.ts and logger.ts (logger previously accepted a relative override silently, and import-time log writes predated the setPath re-anchor).
  • Env hygiene: e2e launches and run-tests.sh strip/unset EXO_USER_DATA_DIR so a leftover shell export can't collapse parallel workers onto one data dir; startup logs loudly when the override is active.
  • Unit tests for the override contract; .packaged-test-data/ wiped per smoke run; Greptile's __dirname/rmSync comments.

Considered, not done (deliberate)

  • Scanning src/ in the guard test (Greptile): src/main must legitimately reference the prod dir (data-dir.ts packaged branch, legacy migration); behavior tests cover the write path instead. Replied inline.
  • Gating the override to non-packaged builds (security specialist): would defeat the packaged smoke test's purpose. The marginal attack surface is negligible — an attacker who controls the process environment of a desktop Electron app has stronger primitives already (e.g. ELECTRON_RUN_AS_NODE is not fused off). Follow-up candidates: disable Electron fuses (RunAsNode, remote debugging) at package time.
  • Rotating exo-config.json.bak backup in the prod app (red team): good defense-in-depth against ANY future deleter; deferred as a separate PR since it touches the settings-store lifecycle.

Notes

  • Local full-suite runs hit the known macOS window-focus flake once (sender-profile.spec.ts:98, keyboard-nav class): passes in isolation and in CI (Tests job green both runs). Not introduced by this diff.
  • Until sibling worktrees merge this fix, their npm test still deletes the prod config — a user-level Claude Code hook now blocks test runs in unfixed worktrees as interim protection.

Ankit Gupta and others added 2 commits July 15, 2026 12:37
…rod-token-wipe

# Conflicts:
#	scripts/run-tests.sh
Greptile caught that the shell-escaped macOS path pattern in
no-global-data-dirs.spec.ts was doubly-escaped: the regex source ended up
as `Application\\\\ Support/exo` (two literal backslashes), so the guard
variant advertised for `Application\ Support/exo` never matched anything.
Halve the backslash count so the regex matches one literal backslash.

Add a self-test that asserts every FORBIDDEN pattern matches its canonical
bad example — a pattern that matches nothing is a silently dead guard, and
this is exactly how this one shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ankitvgupta
ankitvgupta merged commit fc2dd4b into main Jul 16, 2026
9 checks passed
@ankitvgupta
ankitvgupta deleted the ankitvgupta/prevent-prod-token-wipe branch July 16, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant