Keep your WSL Claude profile when switching accounts - #12226
Conversation
…unts Selecting a managed Claude account on a WSL runtime pointed CLAUDE_CONFIG_DIR at the account's auth slot, which only ever holds `.credentials.json` + `oauth-account.json`. The switched session therefore launched against an empty profile: no settings.json, statusline, plugins, skills, CLAUDE.md, or MCP/project state. WSL managed accounts now do the same auth-only swap the host runtime already does: Orca materializes the account's credentials into the distro's own `~/.claude/.credentials.json`, merges `oauthAccount` into `~/.claude.json`, and launches with CLAUDE_CONFIG_DIR unset so the distro's real profile stays in place. A per-distro system-default snapshot is captured before the first managed write, so deselecting restores the user's own login. Supporting changes: - New per-surface auth state (host / wsl:<distro>) so a host switch and a WSL switch cannot clobber each other's ownership proofs. - wsl.exe launches now translate or drop CLAUDE_CONFIG_DIR instead of exporting a Windows path into the distro via WSLENV. - The hidden usage probe only pins CLAUDE_CONFIG_DIR when the preparation actually carries one. - Rollback and post-re-auth read-back suppression are routed to the account's own surface. Closes #11824
📝 WalkthroughWalkthroughClaude authentication now maintains independent host and WSL surface state. Runtime paths, snapshots, credentials, OAuth metadata, rollback, and read-back operations use the active surface. WSL profile handling preserves native metadata and supports distro isolation and fallback paths. WSL UNC existence probing handles uncertain filesystem access. PTY launch code normalizes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ogin Three ways the WSL profile swap could still destroy a user's own distro login: - The surface state seeded `lastSyncedAccountId` from the persisted selection, so an already-selected WSL account skipped the snapshot capture on its first sync after upgrading and a later deselect deleted the user's credentials. A distro profile was never written before #11824, so only the surface's snapshot may license that claim; the host surface keeps its old seeding. - `~/.claude.json` reads treated a Win32 `existsSync` miss as "absent", which over the 9P share replaced the whole profile with a one-key file. Absence now has to be confirmed inside the distro, mirroring `wslUncDirectoryExists`. - The oauth-account write was skipped on cached last-write bookkeeping, so a `/login` inside the distro left credentials and identity out of sync. Compare the file instead, and reuse the async read's bytes so the write path no longer re-reads a multi-MB config synchronously on the main thread.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
src/main/wsl-unc-path-probe.ts (1)
30-53: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winConsider an async probe variant for the async config path.
wslUncPathExistsblocks the main thread for up to 5 seconds per call.readRuntimeConfigJsoninsrc/main/claude-accounts/runtime-auth-service.tswas deliberately converted toreadFileto avoid blocking on 9P, then callswslUncFileExistssynchronously in the same path. The probe only runs whenexistsSyncreports absence, so the frequency is low, but an unresponsive distro still freezes the UI for the full timeout.An
execFile-based async twin used byreadRuntimeConfigJsonwould keep that path non-blocking. The synchronous variant can stay for the existing synchronous callers.src/main/claude-accounts/runtime-auth-service.ts (1)
585-591: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReuse
getClaudeSelectionTargetForAccountinstead of duplicating it.
src/main/claude-accounts/runtime-selection.tsalready exportsgetClaudeSelectionTargetForAccountwith identical logic, andsrc/main/claude-accounts/service.tscalls it. Two copies of the host/WSL target mapping can drift.
getSelectionTargetForAccounthere also acceptsnull, so a small wrapper keeps that behavior.♻️ Proposed deduplication
private getSelectionTargetForAccount( account: ClaudeManagedAccount | null ): ClaudeAccountSelectionTarget { - return account?.managedAuthRuntime === 'wsl' - ? { runtime: 'wsl', wslDistro: account.wslDistro ?? null } - : { runtime: 'host' } + return account ? getClaudeSelectionTargetForAccount(account) : { runtime: 'host' } }src/main/claude-accounts/runtime-auth-service.wsl-profile.test.ts (1)
580-610: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd host-state coverage to the unreachable-distro test.
This test confirms the isolated-slot fallback and that the host
.credentials.jsonis not written. It does not confirm that the host surface's tracked state survives.That gap hides the defect flagged at
src/main/claude-accounts/runtime-auth-service.tslines 415-420: the unreachable-distro branch callsclearLastWrittenRuntimeState()whilethis.surfaceis the host surface.Extend the scenario: select a host account and sync the host target, sync the unreachable WSL target, then deselect the host account and sync the host target again. Assert that the host
~/.claude/.credentials.jsonreturns to the pre-managed login.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 67479dd1-9b80-4e7e-b632-c7ba81f1a875
📒 Files selected for processing (14)
src/main/claude-accounts/auth-surface.tssrc/main/claude-accounts/runtime-auth-service.tssrc/main/claude-accounts/runtime-auth-service.wsl-profile.test.tssrc/main/claude-accounts/runtime-paths.tssrc/main/claude-accounts/service.tssrc/main/daemon/pty-subprocess.tssrc/main/providers/local-pty-provider.tssrc/main/pty/claude-config-dir-wsl-env.test.tssrc/main/pty/claude-config-dir-wsl-env.tssrc/main/rate-limits/claude-pty.test.tssrc/main/rate-limits/claude-pty.tssrc/main/wsl-unc-path-probe.tssrc/main/wsl.test.tssrc/main/wsl.ts
| const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') | ||
|
|
||
| describe('applyWslClaudeConfigDirEnv', () => { | ||
| beforeEach(() => { | ||
| Object.defineProperty(process, 'platform', { | ||
| configurable: true, | ||
| value: 'win32' | ||
| }) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| if (originalPlatform) { | ||
| Object.defineProperty(process, 'platform', originalPlatform) | ||
| } | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Isolate WSLENV for these exact assertions.
addWslEnvKeys inherits process.env.WSLENV when the test environment does not set env.WSLENV. A runner with WSLENV already set makes Lines 39 and 60 fail.
Save and clear process.env.WSLENV in beforeEach, then restore it in afterEach.
Proposed fix
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+const originalWslenv = process.env.WSLENV
describe('applyWslClaudeConfigDirEnv', () => {
beforeEach(() => {
+ delete process.env.WSLENV
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
@@
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
+ if (originalWslenv === undefined) {
+ delete process.env.WSLENV
+ } else {
+ process.env.WSLENV = originalWslenv
+ }
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') | |
| describe('applyWslClaudeConfigDirEnv', () => { | |
| beforeEach(() => { | |
| Object.defineProperty(process, 'platform', { | |
| configurable: true, | |
| value: 'win32' | |
| }) | |
| }) | |
| afterEach(() => { | |
| if (originalPlatform) { | |
| Object.defineProperty(process, 'platform', originalPlatform) | |
| } | |
| }) | |
| const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') | |
| const originalWslenv = process.env.WSLENV | |
| describe('applyWslClaudeConfigDirEnv', () => { | |
| beforeEach(() => { | |
| delete process.env.WSLENV | |
| Object.defineProperty(process, 'platform', { | |
| configurable: true, | |
| value: 'win32' | |
| }) | |
| }) | |
| afterEach(() => { | |
| if (originalPlatform) { | |
| Object.defineProperty(process, 'platform', originalPlatform) | |
| } | |
| if (originalWslenv === undefined) { | |
| delete process.env.WSLENV | |
| } else { | |
| process.env.WSLENV = originalWslenv | |
| } | |
| }) |
Co-authored-by: Orca <help@stably.ai>
The 9P-safe guard only covered `readRuntimeConfigJson`, so the snapshot that makes a managed write recoverable could still be built from a spurious Win32 ENOENT. Every read of a distro profile that feeds a snapshot or an ownership decision now goes through one guard: - `readSurfaceFile` returns `unknown` when only Win32 says the file is absent, and `captureSystemDefaultSnapshot(ForManagedEntry)` returns `unconfirmed` instead of recording `credentialsJson: null` / `configOauthAccount: null`. An unconfirmed entry writes nothing and degrades to the isolated slot, i.e. pre-#11824 behaviour. - Ownership is persisted as `materializedAccountId` in the surface snapshot rather than inferred from the snapshot file existing, and is only claimed back when the login sitting there still carries that account's identity. The file outlives a restore, so its existence never proved anything. - An unreachable distro no longer clears the *host* surface's last-written state on its way past. Perf: one read + one parse of `~/.claude.json` per serialized mutation instead of two or three, and the `wsl.exe` existence probe is memoized per mutation like the ownership probe. The WSL suite splits along the 9P seam and its fixture now models the share failing reads, not just `existsSync`; a guard on the stat alone is defeated by the read. Co-authored-by: Orca <help@stably.ai>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/main/claude-accounts/wsl-auth-surface.test-fixtures.ts (1)
67-75: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd an explicit return type to
createStore.
createStoreis exported and its return type is inferred. Both suites cast the result withas neverat the call site, so a drift in the stub shape stays invisible. An explicit type documents the stub contract.♻️ Proposed refactor
-export function createStore(settings: GlobalSettings) { +export type FakeClaudeStore = { + getSettings: () => GlobalSettings + updateSettings: (updates: Partial<GlobalSettings>) => GlobalSettings +} + +export function createStore(settings: GlobalSettings): FakeClaudeStore { return { getSettings: vi.fn(() => settings),
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c3b9698d-37b0-40f0-8941-048331c1580d
📒 Files selected for processing (5)
config/tsconfig.tc.web.jsonsrc/main/claude-accounts/runtime-auth-service.tssrc/main/claude-accounts/runtime-auth-service.wsl-9p-guard.test.tssrc/main/claude-accounts/runtime-auth-service.wsl-profile.test.tssrc/main/claude-accounts/wsl-auth-surface.test-fixtures.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/main/claude-accounts/runtime-auth-service.wsl-profile.test.ts
| private readSurfaceFile(targetPath: string, surfaceKey: string): ClaudeSurfaceFileRead { | ||
| if (existsSync(targetPath)) { | ||
| return { status: 'read', contents: readFileSync(targetPath, 'utf-8') } | ||
| } | ||
| if (surfaceKey === HOST_AUTH_SURFACE_KEY) { | ||
| return { status: 'read', contents: null } | ||
| } | ||
| return this.wslFileConfirmedAbsent(targetPath) | ||
| ? { status: 'read', contents: null } | ||
| : { status: 'unknown' } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Map a failing readFileSync to unknown instead of throwing.
readSurfaceFile is documented as the single sanctioned surface read, and it returns a status so callers can fail closed. Line 1673 can still throw. Two reachable cases produce that:
- The file is removed between
existsSyncandreadFileSync(TOCTOU). - The 9P share answers the stat but fails the read.
The throw escapes to callers that do not guard it. hasUnchangedRuntimeCredentials (line 1743) is called from restoreSystemDefaultSnapshot (line 1476), so a restore aborts mid-way. seedWslLastSyncedAccountId (line 250) runs inside the stateFor factory in getWslSurface, so a throw there aborts the whole sync. runtimeCredentialsChangedSinceLastWrite (lines 1746-1757) already wraps the read in try/catch, which shows the read is expected to fail.
Return { status: 'unknown' } on a read error so the fail-closed contract holds for every caller.
🛡️ Proposed fix
private readSurfaceFile(targetPath: string, surfaceKey: string): ClaudeSurfaceFileRead {
- if (existsSync(targetPath)) {
- return { status: 'read', contents: readFileSync(targetPath, 'utf-8') }
- }
+ if (existsSync(targetPath)) {
+ try {
+ return { status: 'read', contents: readFileSync(targetPath, 'utf-8') }
+ } catch {
+ // Why: a stat that succeeded and a read that failed is exactly the unconfirmed case.
+ return { status: 'unknown' }
+ }
+ }
if (surfaceKey === HOST_AUTH_SURFACE_KEY) {
return { status: 'read', contents: null }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private readSurfaceFile(targetPath: string, surfaceKey: string): ClaudeSurfaceFileRead { | |
| if (existsSync(targetPath)) { | |
| return { status: 'read', contents: readFileSync(targetPath, 'utf-8') } | |
| } | |
| if (surfaceKey === HOST_AUTH_SURFACE_KEY) { | |
| return { status: 'read', contents: null } | |
| } | |
| return this.wslFileConfirmedAbsent(targetPath) | |
| ? { status: 'read', contents: null } | |
| : { status: 'unknown' } | |
| } | |
| private readSurfaceFile(targetPath: string, surfaceKey: string): ClaudeSurfaceFileRead { | |
| if (existsSync(targetPath)) { | |
| try { | |
| return { status: 'read', contents: readFileSync(targetPath, 'utf-8') } | |
| } catch { | |
| // Why: a stat that succeeded and a read that failed is exactly the unconfirmed case. | |
| return { status: 'unknown' } | |
| } | |
| } | |
| if (surfaceKey === HOST_AUTH_SURFACE_KEY) { | |
| return { status: 'read', contents: null } | |
| } | |
| return this.wslFileConfirmedAbsent(targetPath) | |
| ? { status: 'read', contents: null } | |
| : { status: 'unknown' } | |
| } |
| // Regression coverage for the WSL 9P share reporting ENOENT for files that exist (see | ||
| // `wslUncDirectoryExists`). Every read that feeds a snapshot or an ownership decision must fail | ||
| // closed on an unconfirmed answer, because a snapshot that records a real login as absent is what | ||
| // makes a later restore delete it. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: List the WSL UNC probe exports and their consumers.
set -euo pipefail
fd -t f 'wsl-unc-path-probe.ts' | while IFS= read -r file; do
echo "== $file =="
cat -n "$file"
done
echo '== consumers =='
rg -nP -C2 '\bwslUnc(File|Directory|Path)Exists\b' --type=tsRepository: stablyai/orca
Length of output: 2626
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locating target and related files =="
git ls-files | rg '(^|/)runtime-auth-service|wsl-unc-path-probe|readSurfaceFile|loadRuntimeConfig'
echo "== target file lines =="
cat -n src/main/claude-accounts/runtime-auth-service.wsl-9p-guard.test.ts | sed -n '1,120p'
echo "== consumers of probes =="
rg -n -C2 '\bwslUnc(FileExists|DirectoryExists|PathExists)\b' --type=tsRepository: stablyai/orca
Length of output: 6214
Update the regression comment to reference wslUncFileExists.
This suite mocks and drives the file existence probe, not wslUncDirectoryExists, so the documented symbol should match the guarded reads.
📝 Proposed fix
// Regression coverage for the WSL 9P share reporting ENOENT for files that exist (see
-// `wslUncDirectoryExists`). Every read that feeds a snapshot or an ownership decision must fail
+// `wslUncFileExists`). Every read that feeds a snapshot or an ownership decision must fail
// closed on an unconfirmed answer, because a snapshot that records a real login as absent is what
// makes a later restore delete it.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Regression coverage for the WSL 9P share reporting ENOENT for files that exist (see | |
| // `wslUncDirectoryExists`). Every read that feeds a snapshot or an ownership decision must fail | |
| // closed on an unconfirmed answer, because a snapshot that records a real login as absent is what | |
| // makes a later restore delete it. | |
| // Regression coverage for the WSL 9P share reporting ENOENT for files that exist (see | |
| // `wslUncFileExists`). Every read that feeds a snapshot or an ownership decision must fail | |
| // closed on an unconfirmed answer, because a snapshot that records a real login as absent is what | |
| // makes a later restore delete it. |
Converting to draft — not ready to mergeThis fixes a real, reproduced bug, but after three adversarial review rounds it still carries blocking findings, and each round's fix has surfaced a new one. I'd rather say that plainly than keep looping. CI is green (44 checks). That is not the problem. The problem is data loss on a platform this work could not be tested on. Where it standsRound 1 and 2 blockers were genuinely fixed — the 9P-blind
Why I'm stopping here rather than doing a fourth roundThis code writes to a real user's A change with this blast radius should not merge on simulated evidence. It needs a real Windows + WSL host, exercising at minimum: the upgrade path with an account already selected, a The reproduction, root cause, and regression tests are sound and worth keeping — this is a solid base for whoever picks it up with the right hardware. Happy to hand off or keep going with access to one. |
Verified on real Windows + WSL — needs a redesign before it can landVerified on a real Windows 10.0.26200.8875 host with WSL 2.7.10.0 and a live Ubuntu distro, at this PR's head. The direction is right and demonstrably works; one wrong assumption at the centre of the ownership model produces real data loss, by a different route than review predicted. The premise review flagged is correct — confirmed against a real credentials fileNo The suite passes only because the fixture invents the field — (That file was read on macOS — this host has no OAuth login on either surface. Labelled honestly rather than presented as a win-old read.) Correction to the review: the predicted upgrade-path data loss does not happenRunning the real
The unprovable ownership is what saves that path: the surface re-enters as a fresh managed entry and snapshots before overwriting. But that same property destroys the login one step later — new blockerBecause ownership can never be proven, every restart re-enters as a fresh managed entry, so Reproduced end-to-end on the host: select → token refresh → restart → deselect permanently loses the user's own WSL Claude login, and it needs only about an hour of normal use to trigger. Second new finding — the fix silently reverts to the bug it fixes
When it returns null, The 9P premise the guard is built on did not reproduce, and the guard can fail open
And the guard is wrong in both directions on real hardware:
Confirmed as predictedA distro I/O error still aborts terminal creation: What a redesign needs
Also worth recording: the earlier major finding about Suites on Windows: 5 files, 77 tests, all pass. Not verifiedNo real Claude OAuth login exists on that host, so all tokens in these runs were synthetic and no real user credential was exercised end-to-end. The Electron app could not be driven with this build ( Safety: the distro profile was backed up before any write (415 files, md5-verified) and re-verified clean after both destructive runs. |
Summary
On Windows + WSL, picking a second Claude account swapped the whole profile, not just the login.
ClaudeRuntimeAuthService.getPreparation(src/main/claude-accounts/runtime-auth-service.ts:611-626before this change) returned the account's auth slot asconfigDirand exportedCLAUDE_CONFIG_DIR=<slot>into the distro, whiledoSyncForCurrentSelection(:258-295) deliberately materialized nothing. That slot only ever contains the Orca marker plus.credentials.json/oauth-account.json(src/main/claude-accounts/service.ts:906-951,:806-839), so the switched session launched against an empty profile — nosettings.json, statusline, plugins, skills,CLAUDE.md, MCP servers, or project history. The host runtime already did the right thing (an auth-only swap,:655-671); WSL never got the equivalent.WSL managed accounts now do the same auth-only swap against the distro's own
~/.claude:resolveWslProfilePaths(src/main/claude-accounts/runtime-paths.ts:52) resolves the distro profile from the cachedgetWslHome+parseWslUncPath— the same resolution the WSL-system branch already used — and exposes it as an auth surface.doSyncForCurrentSelectionroutes a reachable WSL distro through the shared materialization path: snapshot capture on first managed entry, read-back of CLI token refreshes,writeRuntimeCredentialsinto<distro>:~/.claude/.credentials.json, and anoauthAccountmerge into<distro>:~/.claude.json(other keys preserved).getPreparationreturns whatever sync actually materialized (src/main/claude-accounts/runtime-auth-service.ts:838-846), so the two branches cannot diverge again.envPatchis empty, so the session reads the distro's real profile.src/main/claude-accounts/auth-surface.ts) keyslastSynced/lastWritten/ snapshot files byhostorwsl:<distro>, so host and each distro own their credentials independently. Snapshot file names usesha256(surfaceKey).slice(0,8)because distro names are user-chosen atwsl --importand can contain characters illegal in Windows filenames.applyWslClaudeConfigDirEnv(src/main/pty/claude-config-dir-wsl-env.ts) replaces the bareaddWslEnvKeys(['CLAUDE_CONFIG_DIR'])atsrc/main/providers/local-pty-provider.ts:733andsrc/main/daemon/pty-subprocess.ts:704. An emptyenvPatchdoes not unset anything (src/main/claude-accounts/environment.ts:27only assigns, andstripAuthEnvdoes not coverCLAUDE_CONFIG_DIR), so a user's Windows-sideCLAUDE_CONFIG_DIRwould otherwise have survived into WSLENV and handed the distro's Claude aC:\...directory. It is now translated when it is a UNC path for the launch distro and deleted when it is aC:\...or foreign-distro path. This also narrows the same pre-existing leak on the WSL-system (no managed account) path. It does not close the leak completely: a Windows-side value that already looks POSIX (CLAUDE_CONFIG_DIR=/opt/claude) still falls through toaddWslEnvKeysand crosses into the distro — see outstanding finding 3.src/main/rate-limits/claude-pty.ts:256,282-284exportsCLAUDE_CONFIG_DIRfor the hidden usage probe only when the preparation carries one, so the probe and the interactive session agree on where.claude.jsonlives.wslUncDirectoryExistswas added for), and a snapshot that records a real login asnullis what lets a later deselect delete it.readSurfaceFilereturnsunknownunless the distro itself confirms absence,captureSystemDefaultSnapshotreturnsunconfirmedrather than snapshotting a guess, and an unconfirmed entry writes nothing at all — it degrades to the isolated slot, which is exactly pre-[Bug]: WSL account switching replaces the entire CLAUDE_CONFIG_DIR profile (host runtime does an auth-only swap) — switched sessions lose settings, plugins, statusline #11824 behaviour.materializedAccountIdin the surface snapshot records which account Orca last wrote there and is cleared on restore. The snapshot file is deliberately never deleted (except when it fails its shape check), so "a snapshot exists" stopped being evidence after the first restore. Re-claiming that ownership after a restart is currently broken — see outstanding finding 2.clearLastWrittenCredentialsJsonandforceMaterializeCurrentSelectionForRollbacknow resolve the account's own surface. WSL selections deliberately never touchsettings.activeClaudeManagedAccountId, so both were previously host-only — meaning a WSL re-auth could be silently undone on the next sync, and a failed WSL switch had nothing to roll back.If the distro cannot be reached (
getWslHomefails), behaviour is exactly as before: the isolated slot is used as the config dir with the correct identity. A stopped distro must never block a launch.Targets the whole-profile swap reported in #11824. Refs #11824 rather than closing it, for two reasons:
activeClaudeManagedAccountIdsByRuntime.wsl[<distro>]can disagree with the single globalsettings.localAccountWslDistrofor a workspace on a non-default distro — is untouched here and still needs its own check.ELI5
Orca lets you keep several Claude logins and switch between them. On Windows machines that run Claude inside WSL, switching accounts didn't just change who you were logged in as — it replaced your entire Claude setup with an empty one. Your status bar, plugins, project memory and settings all vanished until you switched back. This change makes Orca only swap the login file inside your Linux home directory and leave everything else exactly where it was. It also takes a copy of your original login first, so choosing "system default" again puts your own account back. It is not finished: it has never been run on a real Windows + WSL machine, and review found cases where it can still fail badly.
Fix proof
The starting point was a throwaway repro,
runtime-auth-service.bug-11824.test.ts. It is not committed — it was folded into the durable suites below, so this output is a historical record, not something you can re-run from this branch:The permanent coverage is
src/main/claude-accounts/runtime-auth-service.wsl-profile.test.ts, with the fail-closed half split out intoruntime-auth-service.wsl-9p-guard.test.ts(shared harness inwsl-auth-surface.test-fixtures.ts). The original repro'sgetWslHomereturned a non-UNC temp dir, which lands in the degraded slot fallback; the permanent suites additionally stubparseWslUncPathso the temp dir stands in for a real\\wsl.localhost\distro home:Fail-closed coverage
runtime-auth-service.wsl-9p-guard.test.tsmodels the share failing stat and read — a guard onexistsSyncalone is defeated by the read that follows it. All four fail when the same suites are runagainst the previous head of this branch (
bac25f1, i.e. new tests over old source):and pass on this one (
Tests 14 passed (14), above).What this evidence does not cover
Every WSL assertion above is simulated on macOS. Specifically, in these suites:
process.platformis redefined to'win32';../wslis replaced wholesale (getWslHome,getDefaultWslDistro,wslUncFileExists, andtoWindowsWslPathas identity), so nowsl.exeever runs;../../shared/wsl-pathsis stubbed so amkdtemptemp directory stands in for\\wsl.localhost\<distro>\home\<user>;node:fs/node:fs/promisesare partially stubbed to fake the 9P share's spurious ENOENT;node:os,electron,./keychainand./oauth-refreshare mocked.There is therefore no evidence at all about real 9P timing, UNC case folding on NTFS,
wsl.exeexit codes on a booting distro, or file permissions inside a distro. One consequence worth calling out: the fixture's.credentials.jsonincludes anemailfield, which is what makes the ownership proof in outstanding finding 2 pass in tests and fail against real Claude credential files.Suite results
src/main/claude-accounts,src/main/rate-limits,src/main/providers,src/main/daemon,src/main/pty:Adding the downstream consumers of the preparation shape (
src/main/claude,src/main/ipc,src/main/wsl.test.ts, agent auth restart preservation) to the same run:The five suites whose files this PR touches
(
runtime-auth-service.wsl-profile,runtime-auth-service.wsl-9p-guard,pty/claude-config-dir-wsl-env,rate-limits/claude-pty,wsl):npx oxlint,oxlint -c config/oxlint-code-quality-native-plugins.json --deny-warnings,oxlint -c config/oxlint-code-quality-type-aware.json --deny-warnings --type-awareoversrc/main/claude-accounts/, plustypecheck:node/:web/:cli: all clean. CI is green(44 checks). Green CI is not the argument for merging this — see the findings.
Trade-offs
~/.claude/.credentials.jsonand mergesoauthAccountinto~/.claude.json. That is exactly what the host runtime has always done, and a per-distro snapshot is captured before the first managed write — and if that snapshot cannot be confirmed (the 9P share answering ENOENT for a file that exists), nothing is written at all and the launch degrades to the isolated slot — but the old "isolated by CLAUDE_CONFIG_DIR" property is traded for profile fidelity. That is what the issue asks for and whatsrc/shared/types.ts:2922documents as the intended model ("persist only per-account auth (not a CLAUDE_CONFIG_DIR swap)").~/.claudecredentials (snapshot taken first). Session identity is unchanged; only the file behind it moves.envPatch,rememberClaudeAuthSnapshot(src/main/rate-limits/service.ts:1449) recordsconfigDir: nullfor every WSL surface, so two distros each running a different managed account can no longer be told apart atservice.ts:1466— both statuslines post an empty configDir. Single-distro users are unaffected, and the host runtime already has this property. Worth a follow-up issue.service.ts:1055-1069), so the login screen itself still shows first-run onboarding. Unchanged behaviour, deliberately out of scope.materializedAccountIdin that surface's snapshot, and cleared when the profile is handed back. It is only believed again when the login actually sitting in the distro still carries that account's identity, so a/logininside the distro while Orca was closed re-enters as a fresh managed entry and gets snapshotted before it is overwritten. Previously this was inferred from "a snapshot file exists", which stays true forever after the first restore. As written, that identity check cannot pass against a real Claude.credentials.json(outstanding finding 2), so today the re-claim always fails.Manual verification has not been done (macOS-only hardware). On a real Windows + WSL box the required checks are: switch to a second account and confirm the new tab shows statusline/plugins/skills/
CLAUDE.md, thatclaudereports config dir~/.claude, that~/.claude/.credentials.jsoncarries the selected account and the quota bar populates; then deselect and confirm the original login is restored; then repeat once withCLAUDE_CONFIG_DIRset on the Windows side to verify it does not cross into the distro. Also required, given the findings below: the upgrade path with an account already selected, a/logininside the distro while Orca was closed, a root-owned~/.claudeleft behind bysudo claude, and a stopped distro — each followed by a deselect.Two more, specific to the fail-closed guard, that only a real 9P share can exercise:
~/.claudeis large enough to provoke the share's spurious ENOENT, and confirm that when Orca logsCannot read the WSL distro profile through the 9P shareit leaves~/.claude/.credentials.jsonand~/.claude.jsonbyte-identical and the session launches against the isolated slot instead.wsl.exe -d <distro> -- test -f <path>answers promptly on a running distro (the probe is synchronous with a 5 s timeout and now sits on the credential path, not just the config path). It is only reached when Win32 already reports the file absent, andgetWslHomehas already proved the distro is up, but the latency has not been measured on real hardware.Cross-platform
~/.claudepaths, the samesystem-default-auth.jsonsnapshot name (no migration), and the same Keychain calls.resolveWslProfilePathsis only reached forruntime: 'wsl'targets, andgetWslHomeis a no-op off win32. Three mechanical changes do reach them: the hostsystem-default-auth.jsongains amaterializedAccountIdfield (rewritten only when the value changes),~/.claude.jsonis now read withfs/promisesinstead ofreadFileSync, and anoauthAccountmerge that would produce byte-identical contents is now skipped instead of rewritten.managedAuthRuntime === 'wsl'accounts on a reachable distro change behaviour. Windows-side UNC values are built withpath.join; the guest-side$HOME/.claudevalue is built with an explicit POSIX join because it is consumed inside the distro (same as the previous WSL-system branch). Distro names are case-folded for surface keys and snapshot hashes, matchingfoldWslUncPathCaseInsensitiveParts(src/shared/wsl-paths.ts:36-47) — Windows folds the UNC share and distro segment case-insensitively, so two casings must not become two surfaces. (applyWslClaudeConfigDirEnvcompares the launch distro to the UNC distro case-sensitively; untested against real casing.)host/wsl:<distro>) throughruntime-selection.tsand never by workspace kind; nothing in this change reads repo, worktree, or git state.metaKey/CmdOrCtrlconcerns. No git subcommand is added, so the 2.25 baseline andGitCapabilityCacherules do not apply.Performance
The WSL sync now opts into the shared materialization path, which is more work than the old early return. Three mitigations keep it bounded, and
getWslHomeis already cached per distro for the process lifetime (src/main/wsl.ts:202):getOwnedManagedAuthPathspawnsexecFileSync('wsl.exe', …, { timeout: 5000 })for every managed-credential read of a WSL-slot account on win32, including insidefindManagedAccountForRuntimeCredentials's loop over every account. Unmemoized that is roughly 2N+6 synchronous process spawns perprepareForClaudeLaunchand per periodicprepareForRateLimitFetch. The answer is now cached per account for the duration of one serialized mutation and cleared at its start. Thewsl.exefile-existence probe is memoized the same way..claude.jsonis read asynchronously. A heavy user's distro~/.claude.json(full project/MCP history) is routinely tens of MB and lives over 9P.readRuntimeOauthAccount/readRuntimeConfigusefs/promises, so the read no longer blocks the Electron main thread. The write stays atomic-sync but is already skipped when contents are unchanged. Caveat: when that async read returns ENOENT on a distro surface it falls through towslFileConfirmedAbsent, which is a synchronousexecFileSync('wsl.exe', …, { timeout: 5000 }). So an unresponsive distro can still stall the main thread for up to 5 s on this path. Not fixed here..claude.jsonis read and parsed once per sync. The sync consults it up to three times (read-back identity, the "is our identity still there" check, the read-modify-write). A cache scoped to one serialized mutation — dropped at its start and refreshed on write — collapses that to a single read and a singleJSON.parse. TheexistsSyncpre-check is gone too, so the happy path is one syscall rather than a stat plus a read.What this does not claim. A WSL sync costs one full read + parse of the distro's
~/.claude.jsonwhere the pre-PR early return cost zero. Zero is not reachable while the identity check is honest: a/logininside the distro rewritesoauthAccountbehind Orca's back, so Orca's own last-write bookkeeping cannot stand in for reading the file.src/main/claude-accounts/runtime-auth-service.wsl-profile.test.tspins that (rewrites the distro identity after a login inside the distro replaced it), and an attempt to skip the read when.credentials.jsonstill matched Orca's last write was reverted because it failed exactly that test.Deliberately not added: a
wslUncDirectoryExistsprobe insidegetPreparation(a 5s synchronousexecFileSyncinside a synchronous method;getWslHomeis already the reachability proof and the directory is created on write anyway) and awsl.exe -- chmod 600hardening spawn on the write path (a property today's slot writes do not have either).resolveWslProfilePathsis pure path arithmetic over the cached home, sogetPreparationstays synchronous and cheap.Steady-state relaunch cost on a reachable distro is expected to be a handful of small 9P reads — the same class of I/O the host path has always done, and cheaper than the WSL usage probe already spawned per refresh. macOS, Linux, and host-Windows pay nothing new beyond the three mechanical changes listed under Cross-platform. None of these costs have been measured on real hardware.
Security
~/.claude/.credentials.json. That is the same location and posture the host runtime has always used, written throughwriteFileAtomically(..., { mode: 0o600 }). The account slot remains the source of truth; the distro copy is derived. A snapshot of whatever was there first is captured before the first managed write, and ownership is re-proved (contents must still match Orca's last write) before anything is restored or removed.wslUncFileExists(src/main/wsl-unc-path-probe.ts) runsexecFileSync('wsl.exe', ['-d', <distro>, '--', 'test', '-f', <path>], { timeout: 5000 })whenever Win32 reports an auth-surface file absent on a distro surface, because only the distro may be believed about absence. It is memoized per serialized mutation and is only reached when the file already looks gone — a distro that has a login should never pay it, though that has not been confirmed on real hardware. The pre-existingwsl.exeownership probe is unchanged in shape and now runs fewer times per sync. The hidden usage probe emits one fewerexportline; the value it does emit still goes through the existingshellQuote.getWslHome+parseWslUncPath; Windows paths are composed withpath.join, guest paths with an explicit POSIX join. Snapshot filenames aresha256-derived rather than sanitized from user-chosen distro names, so a distro name cannot influence the on-disk path.C:\...-styleCLAUDE_CONFIG_DIR, and a UNCCLAUDE_CONFIG_DIRfor a different distro, are now deleted before the WSLENV export instead of being handed to the guest; a UNC value for the launch distro is rewritten to its Linux path (new behaviour, previously it was forwarded verbatim). A Windows-side value that already looks POSIX is still forwarded unchanged — outstanding finding 3.src/main/ipc/orsrc/renderer/).AI Review Report
Review loop: 3 round(s). Final verdict: OUTSTANDING FINDINGS — DRAFT, NOT READY.
Rounds 1 and 2 produced fixes that hold up: the 9P-blind
existsSyncreads now route through aconfirmed-absence guard (
readSurfaceFile→wslFileConfirmedAbsent), and profile ownership istracked explicitly via a persisted
materializedAccountIdinstead of being inferred from a snapshotfile's existence. Round 3 found four more, none of which are fixed on this branch:
writeRuntimeCredentials/writeRuntimeOauthAccountare called with notry/catch,serializeMutationrethrows to itscaller, and
src/main/ipc/pty.ts:3885/:4999await prepareClaudeAuth(...)outside a guard. Auser who has run
sudo claudeonce inside the distro has a root-owned~/.claude/.credentials.json; selecting a managed account then throwsEACCESand the terminalnever opens, with no UI path out.
seedWslLastSyncedAccountIdproves ownership withruntimeCredentialsBelongToAccount, whichrequires an
email(and a matchingorganizationUuid) parsed out of.credentials.json(
runtime-auth-service.ts:1707-1729,:1080-1097). A real Claude.credentials.jsoncarries onlyclaudeAiOauth.accessToken/refreshToken/expiresAt/scopes. The repo agrees:service.ts:resolveIdentitysourcesorganizationUuidonly fromclaude statusoroauth-account.json, never from credentials. So after a restart the seed always returns null. Thetests pass only because the fixture's synthetic credentials carry an
email.CLAUDE_CONFIG_DIRcan still reach the session.getPreparationreturns anempty
envPatchfor a materialized account,applyClaudeEnvPatchonly assigns, andstripAuthEnv/
hasClaudeAuthEnvConflictdo not coverCLAUDE_CONFIG_DIR, so an inherited value survives intofinalEnv.applyWslClaudeConfigDirEnvcatchesC:\...and foreign-distro UNC values but forwardsanything that already looks POSIX.
captureSystemDefaultSnapshot(
runtime-auth-service.ts:1396-1464) preservescredentialsJsonand both keychain values on there-capture path via
snapshotKeychainCredentials(..., previousSnapshot, ...), but unconditionallyoverwrites
configOauthAccountfrom the live profile — which on re-capture may already be Orca'sown identity.
Non-blocking notes (1):
.claude.jsonread was made async to keep the Electron main thread free, but its ENOENT branchcalls the synchronous 5 s
wslUncFileExistsprobe, so an unresponsive distro can still block themain thread. Low frequency (only when the file already looks absent), and a follow-up rather than a
blocker. Recorded under Performance above.
Why the loop stopped at 3. This code writes into a real user's
~/.claudeinside a WSL distro,and every failure mode above costs them their Claude login, settings, plugins and statusline. All
verification is simulated on macOS (see "What this evidence does not cover"). A change with this
blast radius should not merge on simulated evidence, and each round's fix has surfaced a new finding.
The reproduction, root cause and regression suites are worth keeping as a base for whoever picks this
up with a Windows + WSL host.
Made with Orca 🐋