What Problem This Solves
getEnvPath() and getEmailFolderPath() derived the account directory from email.split('@')[0], discarding the domain. Three different accounts collapsed onto the same directory:
alice@personal.com -> alice
alice@work.com -> alice
alice@corp.internal -> aliceThose accounts shared one .env file holding all three sets of API keys, and one MCP config directory. A malformed address such as @nodomain.com produced an empty name, which made the account directory ~/.eigent itself.
Reading the code also turned up two things the issue did not mention:
.replace('.', '_')has nogflag, so it replaced only the first dot. The issue assumed all dots were replaced.- The same expression was duplicated verbatim in
subscriptionAuth/credentialStore, with a comment noting it had to be kept in sync by hand.test/mocks/electronMocks.tscarried a third copy.
Solution Details
The directory name is now <readable>~<hash>:
readable— the sanitized address, so a path can still be traced back to the account. This addresses the "impossible to trace" part of the issue.hash— a truncated SHA-256 of the exact original address.
The digest is what makes the mapping injective. Sanitization alone is lossy: a b@x.com and a_b@x.com both reduce to a_b@x.com. Injectivity is the property that matters here, because these directories hold credentials, so where readability and distinctness conflict, distinctness wins and the readable part is kept for debugging.
Also:
- Spaces are sanitized. The old expression handled them; my first attempt did not, and a name containing spaces needs quoting in every shell and is rejected at the tail on Windows. A test caught this.
- Traversal and empty names are impossible by construction, with
assertUsableAccountDirNameas a backstop. - The derivation is now in one module —
electron/main/utils/accountPath.ts, side-effect free socredentialStorecan import it without pulling inenvUtil's filesystem side effects. The three copies, including the test mocks, now use it.
Why old directories are not migrated
A directory created under the old scheme may contain configuration from several accounts merged together. It cannot be attributed to any one of them — that ambiguity is the bug — so this leaves it in place rather than handing it to whichever account signs in next. Deleting it would throw away MCP configuration that may still be needed. Happy to migrate explicitly if you would rather have it, but it would need a decision about how to split or discard merged contents.
Evidence
13 tests in test/unit/electron/main/accountPath.test.ts, including:
| Test | Pins |
|---|---|
keeps accounts apart when only the domain differs | the reported defect |
does not reproduce the legacy collisions | asserts the old scheme collapsed to 1 of 3 |
keeps apart addresses that differ only by an unsafe character | digest is what prevents the collision |
never produces an empty name | the @nodomain.com case |
cannot escape its parent directory | traversal |
stays within the filesystem component limit | 255-byte component limit |
replaces characters that are unsafe on Windows | reserved characters |
vitest run test/unit/electron/main/accountPath.test.ts— 13 passedvitest run accountPath + index + port— 112 passednpm run type-check— cleaneslinton the touched files — clean
Note: test/unit/electron/main/envUtil.test.ts has 14 failures on main before this change, unrelated to it. They fail with util.isString is not a function — the jsdom test environment resolves path to a browser polyfill whose util.isString the shimmed util does not provide. The same environment issue is why the new tests target the pure module rather than envUtil. Worth a separate fix; I have not touched it here to keep the diff focused.
User Impact
Accounts on a shared machine stop sharing credentials, and a path in the UI can be traced back to the account it belongs to. Existing .env.<name> files stay where they are under their old name, so an account may need to re-enter an API key once; the new file is created on next write and the old one is left untouched rather than deleted.
Closes #1669
Rebased onto current
mainand re-verified while it waited for review:npm run type-checkandeslintare clean.mainbaseline.mainresolved by hand; the fix is now scoped to just the files it needs.The linked issue is still open, and this is ready for a look whenever you have time.