Skip to content

Keep your WSL Claude profile when switching accounts - #12226

Draft
nwparker wants to merge 5 commits into
mainfrom
nwparker/bugbash-11824
Draft

Keep your WSL Claude profile when switching accounts#12226
nwparker wants to merge 5 commits into
mainfrom
nwparker/bugbash-11824

Conversation

@nwparker

@nwparker nwparker commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Draft — not ready to merge. Three adversarial review rounds left four outstanding findings (two of them blockers), and every WSL behaviour below is verified only on macOS with process.platform faked and ../wsl stubbed. See the conversion comment and AI Review Report at the bottom. The reproduction, root cause, and regression suites are sound and worth keeping; the change itself needs a real Windows + WSL host before it can land.

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-626 before this change) returned the account's auth slot as configDir and exported CLAUDE_CONFIG_DIR=<slot> into the distro, while doSyncForCurrentSelection (: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 — no settings.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 cached getWslHome + parseWslUncPath — the same resolution the WSL-system branch already used — and exposes it as an auth surface.
  • doSyncForCurrentSelection routes a reachable WSL distro through the shared materialization path: snapshot capture on first managed entry, read-back of CLI token refreshes, writeRuntimeCredentials into <distro>:~/.claude/.credentials.json, and an oauthAccount merge into <distro>:~/.claude.json (other keys preserved).
  • getPreparation returns whatever sync actually materialized (src/main/claude-accounts/runtime-auth-service.ts:838-846), so the two branches cannot diverge again. envPatch is empty, so the session reads the distro's real profile.
  • Per-surface state (src/main/claude-accounts/auth-surface.ts) keys lastSynced / lastWritten / snapshot files by host or wsl:<distro>, so host and each distro own their credentials independently. Snapshot file names use sha256(surfaceKey).slice(0,8) because distro names are user-chosen at wsl --import and can contain characters illegal in Windows filenames.
  • applyWslClaudeConfigDirEnv (src/main/pty/claude-config-dir-wsl-env.ts) replaces the bare addWslEnvKeys(['CLAUDE_CONFIG_DIR']) at src/main/providers/local-pty-provider.ts:733 and src/main/daemon/pty-subprocess.ts:704. An empty envPatch does not unset anything (src/main/claude-accounts/environment.ts:27 only assigns, and stripAuthEnv does not cover CLAUDE_CONFIG_DIR), so a user's Windows-side CLAUDE_CONFIG_DIR would otherwise have survived into WSLENV and handed the distro's Claude a C:\... directory. It is now translated when it is a UNC path for the launch distro and deleted when it is a C:\... 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 to addWslEnvKeys and crosses into the distro — see outstanding finding 3.
  • src/main/rate-limits/claude-pty.ts:256,282-284 exports CLAUDE_CONFIG_DIR for the hidden usage probe only when the preparation carries one, so the probe and the interactive session agree on where .claude.json lives.
  • Every read of a distro profile that feeds a snapshot or an ownership decision fails closed. Win32 reports spurious ENOENT over the WSL 9P share (the same blindness wslUncDirectoryExists was added for), and a snapshot that records a real login as null is what lets a later deselect delete it. readSurfaceFile returns unknown unless the distro itself confirms absence, captureSystemDefaultSnapshot returns unconfirmed rather 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.
  • Ownership is persisted, not inferred. materializedAccountId in 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.
  • clearLastWrittenCredentialsJson and forceMaterializeCurrentSelectionForRollback now resolve the account's own surface. WSL selections deliberately never touch settings.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 (getWslHome fails), 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:

  1. The issue's secondary note — that per-distro activeClaudeManagedAccountIdsByRuntime.wsl[<distro>] can disagree with the single global settings.localAccountWslDistro for a workspace on a non-default distro — is untouched here and still needs its own check.
  2. The fix itself is not finished: see the outstanding findings below.

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:

 RUN  v4.1.5 /Users/nwparker/projects/orca/.claude/worktrees/wf_60c051cd-fbd-32

 ❯ src/main/claude-accounts/runtime-auth-service.bug-11824.test.ts (1 test | 1 failed) 61ms
     × keeps the distro profile as the Claude config dir when a WSL managed account is selected 60ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/main/claude-accounts/runtime-auth-service.bug-11824.test.ts > bug-11824: WSL Claude account switch swaps the whole profile > keeps the distro profile as the Claude config dir when a WSL managed account is selected
AssertionError: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ src/main/claude-accounts/runtime-auth-service.bug-11824.test.ts:147:70
    145|
    146|     // The launched session must still see the user's real profile.
    147|     expect(existsSync(join(preparation.configDir, 'settings.json'))).t…
       |                                                                      ^
    148|     expect(existsSync(join(preparation.configDir, 'plugins'))).toBe(tr…
    149|     expect(existsSync(join(preparation.configDir, 'CLAUDE.md'))).toBe(…

 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  21:59:11
   Duration  263ms (transform 127ms, setup 0ms, import 131ms, tests 61ms, environment 0ms)

The permanent coverage is src/main/claude-accounts/runtime-auth-service.wsl-profile.test.ts, with the fail-closed half split out into runtime-auth-service.wsl-9p-guard.test.ts (shared harness in wsl-auth-surface.test-fixtures.ts). The original repro's getWslHome returned a non-UNC temp dir, which lands in the degraded slot fallback; the permanent suites additionally stub parseWslUncPath so the temp dir stands in for a real \\wsl.localhost\ distro home:

 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > keeps the distro profile as the config dir and materializes only the credentials 53ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > restores the distro login when the account is deselected 7ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > keeps the distro login when an already-selected account is first synced after upgrading 8ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > writes the distro .claude.json when the distro confirms it is absent 6ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > rewrites the distro identity after a login inside the distro replaced it 6ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > keeps distros isolated and folds distro-name casing onto one surface 10ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > falls back to the isolated auth slot when the distro cannot be reached 3ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > reads a live refresh out of the distro profile back into managed storage 7ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > suppresses the next WSL read-back after a re-authentication 7ms
 ✓ runtime-auth-service.wsl-profile.test.ts > Claude runtime auth on WSL distro profiles > keeps host and WSL materialization state independent 8ms
 ✓ runtime-auth-service.wsl-9p-guard.test.ts > Claude runtime auth against a blind WSL 9P share > leaves the distro profile alone when the 9P share hides an existing .claude.json 50ms
 ✓ runtime-auth-service.wsl-9p-guard.test.ts > Claude runtime auth against a blind WSL 9P share > never snapshots a hidden distro login as absent, so a deselect cannot delete it 6ms
 ✓ runtime-auth-service.wsl-9p-guard.test.ts > Claude runtime auth against a blind WSL 9P share > re-snapshots the distro login when a login inside the distro replaced it while Orca was closed 10ms
 ✓ runtime-auth-service.wsl-9p-guard.test.ts > Claude runtime auth against a blind WSL 9P share > keeps host materialization state when the WSL distro cannot be reached 8ms

 Test Files  2 passed (2)
      Tests  14 passed (14)

Fail-closed coverage

runtime-auth-service.wsl-9p-guard.test.ts models the share failing stat and read — a guard on
existsSync alone is defeated by the read that follows it. All four fail when the same suites are run
against the previous head of this branch (bac25f1, i.e. new tests over old source):

 × leaves the distro profile alone when the 9P share hides an existing .claude.json
 × never snapshots a hidden distro login as absent, so a deselect cannot delete it
 × re-snapshots the distro login when a login inside the distro replaced it while Orca was closed
 × keeps host materialization state when the WSL distro cannot be reached

 Test Files  1 failed | 1 passed (2)
      Tests  4 failed | 10 passed (14)

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.platform is redefined to 'win32';
  • ../wsl is replaced wholesale (getWslHome, getDefaultWslDistro, wslUncFileExists, and toWindowsWslPath as identity), so no wsl.exe ever runs;
  • ../../shared/wsl-paths is stubbed so a mkdtemp temp directory stands in for \\wsl.localhost\<distro>\home\<user>;
  • node:fs / node:fs/promises are partially stubbed to fake the 9P share's spurious ENOENT;
  • node:os, electron, ./keychain and ./oauth-refresh are mocked.

There is therefore no evidence at all about real 9P timing, UNC case folding on NTFS, wsl.exe exit codes on a booting distro, or file permissions inside a distro. One consequence worth calling out: the fixture's .credentials.json includes an email field, 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:

 Test Files  178 passed | 2 skipped (180)
      Tests  2597 passed | 5 skipped (2602)

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:

 Test Files  351 passed | 3 skipped (354)
      Tests  5351 passed | 17 skipped (5368)

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):

 Test Files  5 passed (5)
      Tests  77 passed (77)

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-aware over
src/main/claude-accounts/, plus typecheck:node / :web / :cli: all clean. CI is green
(44 checks). Green CI is not the argument for merging this — see the findings.

Trade-offs

  • Orca now writes into the distro's ~/.claude/.credentials.json and merges oauthAccount into ~/.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 what src/shared/types.ts:2922 documents as the intended model ("persist only per-account auth (not a CLAUDE_CONFIG_DIR swap)").
  • The first launch after upgrading, for a user currently on a managed WSL account, overwrites the distro's ~/.claude credentials (snapshot taken first). Session identity is unchanged; only the file behind it moves.
  • Live-usage attribution loses one discriminator. With an empty envPatch, rememberClaudeAuthSnapshot (src/main/rate-limits/service.ts:1449) records configDir: null for every WSL surface, so two distros each running a different managed account can no longer be told apart at service.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.
  • The add-account login PTY still runs against the isolated slot (service.ts:1055-1069), so the login screen itself still shows first-run onboarding. Unchanged behaviour, deliberately out of scope.
  • If the distro is unreachable, behaviour silently stays as today (degraded profile, correct identity) rather than failing loudly. Deliberate: a stopped distro must not block a launch. The same degradation now covers "the share cannot confirm what is in the profile".
  • Ownership across restarts. Which account Orca last materialized onto a surface is persisted as materializedAccountId in 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 /login inside 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, that claude reports config dir ~/.claude, that ~/.claude/.credentials.json carries the selected account and the quota bar populates; then deselect and confirm the original login is restored; then repeat once with CLAUDE_CONFIG_DIR set 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 /login inside the distro while Orca was closed, a root-owned ~/.claude left behind by sudo 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:

  • With the distro running, switch accounts on a distro whose ~/.claude is large enough to provoke the share's spurious ENOENT, and confirm that when Orca logs Cannot read the WSL distro profile through the 9P share it leaves ~/.claude/.credentials.json and ~/.claude.json byte-identical and the session launches against the isolated slot instead.
  • Confirm 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, and getWslHome has already proved the distro is up, but the latency has not been measured on real hardware.

Cross-platform

  • macOS / Linux: no user-visible behaviour change. Every path they reach resolves the host surface, which keeps the same ~/.claude paths, the same system-default-auth.json snapshot name (no migration), and the same Keychain calls. resolveWslProfilePaths is only reached for runtime: 'wsl' targets, and getWslHome is a no-op off win32. Three mechanical changes do reach them: the host system-default-auth.json gains a materializedAccountId field (rewritten only when the value changes), ~/.claude.json is now read with fs/promises instead of readFileSync, and an oauthAccount merge that would produce byte-identical contents is now skipped instead of rewritten.
  • Windows: host-runtime accounts are unchanged. Only managedAuthRuntime === 'wsl' accounts on a reachable distro change behaviour. Windows-side UNC values are built with path.join; the guest-side $HOME/.claude value 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, matching foldWslUncPathCaseInsensitiveParts (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. (applyWslClaudeConfigDirEnv compares the launch distro to the UNC distro case-sensitively; untested against real casing.)
  • SSH remotes: untouched. Remote projects go through a different provider (tracked separately in [Bug]: Claude account switching does not work in Remote SSH projects #8795). The surface abstraction added here is in fact the natural seam for an SSH surface later, because it separates "which files are Claude's auth surface" from "which host executes".
  • Folder workspaces (non-git): unaffected. Claude account selection is keyed by local runtime target (host / wsl:<distro>) through runtime-selection.ts and never by workspace kind; nothing in this change reads repo, worktree, or git state.
  • No keyboard shortcut, accelerator, or UI surface is involved, so no metaKey / CmdOrCtrl concerns. No git subcommand is added, so the 2.25 baseline and GitCapabilityCache rules 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 getWslHome is already cached per distro for the process lifetime (src/main/wsl.ts:202):

  1. Ownership probes are memoized per mutation. getOwnedManagedAuthPath spawns execFileSync('wsl.exe', …, { timeout: 5000 }) for every managed-credential read of a WSL-slot account on win32, including inside findManagedAccountForRuntimeCredentials's loop over every account. Unmemoized that is roughly 2N+6 synchronous process spawns per prepareForClaudeLaunch and per periodic prepareForRateLimitFetch. The answer is now cached per account for the duration of one serialized mutation and cleared at its start. The wsl.exe file-existence probe is memoized the same way.
  2. .claude.json is read asynchronously. A heavy user's distro ~/.claude.json (full project/MCP history) is routinely tens of MB and lives over 9P. readRuntimeOauthAccount / readRuntimeConfig use fs/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 to wslFileConfirmedAbsent, which is a synchronous execFileSync('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.
  3. .claude.json is 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 single JSON.parse. The existsSync pre-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.json where the pre-PR early return cost zero. Zero is not reachable while the identity check is honest: a /login inside the distro rewrites oauthAccount behind 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.ts pins that (rewrites the distro identity after a login inside the distro replaced it), and an attempt to skip the read when .credentials.json still matched Orca's last write was reverted because it failed exactly that test.

Deliberately not added: a wslUncDirectoryExists probe inside getPreparation (a 5s synchronous execFileSync inside a synchronous method; getWslHome is already the reachability proof and the directory is created on write anyway) and a wsl.exe -- chmod 600 hardening spawn on the write path (a property today's slot writes do not have either). resolveWslProfilePaths is pure path arithmetic over the cached home, so getPreparation stays 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

  • Auth surface: credentials move from a private per-account slot to the distro's ~/.claude/.credentials.json. That is the same location and posture the host runtime has always used, written through writeFileAtomically(..., { 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.
  • Command execution: this change adds a new process spawn on the credential path. wslUncFileExists (src/main/wsl-unc-path-probe.ts) runs execFileSync('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-existing wsl.exe ownership probe is unchanged in shape and now runs fewer times per sync. The hidden usage probe emits one fewer export line; the value it does emit still goes through the existing shellQuote.
  • Path handling: distro homes come from the existing cached getWslHome + parseWslUncPath; Windows paths are composed with path.join, guest paths with an explicit POSIX join. Snapshot filenames are sha256-derived rather than sanitized from user-chosen distro names, so a distro name cannot influence the on-disk path.
  • Environment: net tightening, but not strictly so. A C:\...-style CLAUDE_CONFIG_DIR, and a UNC CLAUDE_CONFIG_DIR for 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.
  • IPC: no change. No IPC shapes, channels, or renderer code are touched (the diff touches no file under src/main/ipc/ or src/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 existsSync reads now route through a
confirmed-absence guard (readSurfaceFilewslFileConfirmedAbsent), and profile ownership is
tracked explicitly via a persisted materializedAccountId instead of being inferred from a snapshot
file's existence. Round 3 found four more, none of which are fixed on this branch:

  1. (blocker) Any distro I/O error aborts terminal creation. writeRuntimeCredentials /
    writeRuntimeOauthAccount are called with no try/catch, serializeMutation rethrows to its
    caller, and src/main/ipc/pty.ts:3885 / :4999 await prepareClaudeAuth(...) outside a guard. A
    user who has run sudo claude once inside the distro has a root-owned
    ~/.claude/.credentials.json; selecting a managed account then throws EACCES and the terminal
    never opens, with no UI path out
    .
  2. (blocker) The restart ownership proof can never succeed against real credentials.
    seedWslLastSyncedAccountId proves ownership with runtimeCredentialsBelongToAccount, which
    requires an email (and a matching organizationUuid) parsed out of .credentials.json
    (runtime-auth-service.ts:1707-1729, :1080-1097). A real Claude .credentials.json carries only
    claudeAiOauth.accessToken / refreshToken / expiresAt / scopes. The repo agrees:
    service.ts:resolveIdentity sources organizationUuid only from claude status or
    oauth-account.json, never from credentials. So after a restart the seed always returns null. The
    tests pass only because the fixture's synthetic credentials carry an email.
  3. (major) Ambient CLAUDE_CONFIG_DIR can still reach the session. getPreparation returns an
    empty envPatch for a materialized account, applyClaudeEnvPatch only assigns, and stripAuthEnv
    / hasClaudeAuthEnvConflict do not cover CLAUDE_CONFIG_DIR, so an inherited value survives into
    finalEnv. applyWslClaudeConfigDirEnv catches C:\... and foreign-distro UNC values but forwards
    anything that already looks POSIX.
  4. (major) Snapshot re-capture is asymmetric. captureSystemDefaultSnapshot
    (runtime-auth-service.ts:1396-1464) preserves credentialsJson and both keychain values on the
    re-capture path via snapshotKeychainCredentials(..., previousSnapshot, ...), but unconditionally
    overwrites configOauthAccount from the live profile — which on re-capture may already be Orca's
    own identity.

Non-blocking notes (1):

  • The .claude.json read was made async to keep the Electron main thread free, but its ENOENT branch
    calls the synchronous 5 s wslUncFileExists probe, so an unresponsive distro can still block the
    main 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 ~/.claude inside 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 🐋

…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
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Claude 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 CLAUDE_CONFIG_DIR for the selected distro. Regression tests cover profile restoration, credential synchronization, and host/WSL separation.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary user-visible change: preserving the WSL Claude profile during account switching.
Description check ✅ Passed The description thoroughly covers the change, testing, risks, AI review, security, cross-platform behavior, and known limitations.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/main/wsl-unc-path-probe.ts (1)

30-53: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider an async probe variant for the async config path.

wslUncPathExists blocks the main thread for up to 5 seconds per call. readRuntimeConfigJson in src/main/claude-accounts/runtime-auth-service.ts was deliberately converted to readFile to avoid blocking on 9P, then calls wslUncFileExists synchronously in the same path. The probe only runs when existsSync reports absence, so the frequency is low, but an unresponsive distro still freezes the UI for the full timeout.

An execFile-based async twin used by readRuntimeConfigJson would 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 value

Reuse getClaudeSelectionTargetForAccount instead of duplicating it.

src/main/claude-accounts/runtime-selection.ts already exports getClaudeSelectionTargetForAccount with identical logic, and src/main/claude-accounts/service.ts calls it. Two copies of the host/WSL target mapping can drift.

getSelectionTargetForAccount here also accepts null, 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 win

Add host-state coverage to the unreachable-distro test.

This test confirms the isolated-slot fallback and that the host .credentials.json is 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.ts lines 415-420: the unreachable-distro branch calls clearLastWrittenRuntimeState() while this.surface is 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.json returns 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8b311ae and bac25f1.

📒 Files selected for processing (14)
  • src/main/claude-accounts/auth-surface.ts
  • src/main/claude-accounts/runtime-auth-service.ts
  • src/main/claude-accounts/runtime-auth-service.wsl-profile.test.ts
  • src/main/claude-accounts/runtime-paths.ts
  • src/main/claude-accounts/service.ts
  • src/main/daemon/pty-subprocess.ts
  • src/main/providers/local-pty-provider.ts
  • src/main/pty/claude-config-dir-wsl-env.test.ts
  • src/main/pty/claude-config-dir-wsl-env.ts
  • src/main/rate-limits/claude-pty.test.ts
  • src/main/rate-limits/claude-pty.ts
  • src/main/wsl-unc-path-probe.ts
  • src/main/wsl.test.ts
  • src/main/wsl.ts

Comment thread src/main/claude-accounts/runtime-auth-service.ts Outdated
Comment on lines +4 to +18
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)
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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
}
})

Comment thread src/main/wsl.ts
nwparker and others added 2 commits August 2, 2026 23:54
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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 value

Add an explicit return type to createStore.

createStore is exported and its return type is inferred. Both suites cast the result with as never at 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

📥 Commits

Reviewing files that changed from the base of the PR and between bac25f1 and 2e53d4c.

📒 Files selected for processing (5)
  • config/tsconfig.tc.web.json
  • src/main/claude-accounts/runtime-auth-service.ts
  • src/main/claude-accounts/runtime-auth-service.wsl-9p-guard.test.ts
  • src/main/claude-accounts/runtime-auth-service.wsl-profile.test.ts
  • src/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

Comment on lines +1671 to +1681
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' }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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 existsSync and readFileSync (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.

Suggested change
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' }
}

Comment on lines +21 to +24
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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=ts

Repository: 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=ts

Repository: 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.

Suggested change
// 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.

@nwparker

nwparker commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Converting to draft — not ready to merge

This 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 stands

Round 1 and 2 blockers were genuinely fixed — the 9P-blind existsSync reads now route through a confirmed-absence guard, and profile ownership is tracked explicitly via a persisted materializedAccountId instead of being inferred from a snapshot file's existence. Round 3 then found four more:

  1. (blocker) Any distro I/O error now aborts terminal creation. writeRuntimeCredentials / writeRuntimeOauthAccount are called with no try/catch, serializeMutation rethrows, and src/main/ipc/pty.ts:3885/:4999 await prepareClaudeAuth outside a guard. A user who has run sudo claude once inside the distro has a root-owned ~/.claude/.credentials.json; selecting a managed account then throws EACCES and the terminal never opens, with no UI path out.
  2. (blocker) The new ownership proof can never succeed. seedWslLastSyncedAccountId proves ownership with runtimeCredentialsBelongToAccount, which needs an email and a matching organizationUuid from .credentials.json. A real Claude .credentials.json carries only accessToken/refreshToken/expiresAt/scopes. The repo agrees: service.ts:resolveIdentity sources organizationUuid only from claude status or oauth-account.json, never from credentials. So after a restart the seed always fails.
  3. (major) Ambient CLAUDE_CONFIG_DIR leaks into the session. getPreparation returns an empty envPatch for a materialized account, applyClaudeEnvPatch only assigns, and stripAuthEnv / hasClaudeAuthEnvConflict don't cover CLAUDE_CONFIG_DIR — so a user's inherited value survives into finalEnv.
  4. (major) Snapshot re-capture is asymmetric. captureSystemDefaultSnapshot preserves credentialsJson and both keychain values on the re-capture path but unconditionally overwrites configOauthAccount from the live profile.

Why I'm stopping here rather than doing a fourth round

This code writes to a real user's ~/.claude inside a WSL distro. Every failure mode is their Claude login, settings, plugins, and statusline. All verification so far is on macOS with process.platform faked, ../wsl stubbed, and temp dirs standing in for \\wsl.localhost\... — real 9P timing, UNC casing, and wsl.exe exit codes on a booting distro are untested.

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 /login inside the distro while Orca is closed, a root-owned ~/.claude from sudo claude, a stopped distro, and deselect-after-each.

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.

@nwparker
nwparker marked this pull request as draft August 3, 2026 09:00
@nwparker

nwparker commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Verified on real Windows + WSL — needs a redesign before it can land

Verified 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 file

top level:           ["claudeAiOauth", "mcpOAuth"]
claudeAiOauth keys:  accessToken, expiresAt, rateLimitTier, refreshToken,
                     refreshTokenExpiresAt, scopes, subscriptionType

No email, no organizationUuid, no accountUuid. readIdentityFromCredentials (runtime-auth-service.ts:1080-1097) reads all three from parsed.claudeAiOauth, so identity.email is always null and runtimeCredentialsBelongToAccount returns false at its first guard (:1716-1721). seedWslLastSyncedAccountId can never return non-null on real hardware.

The suite passes only because the fixture invents the field — wsl-auth-surface.test-fixtures.ts:createCredentialsJson writes an email into claudeAiOauth that Claude does not write.

(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 happen

Running the real ClaudeRuntimeAuthService on real Windows against the real distro over real 9P, with credentials in the real no-email format, in the exact upgrade state (selection already persisted, no snapshot file) — it passed:

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 blocker

Because ownership can never be proven, every restart re-enters as a fresh managed entry, so captureSystemDefaultSnapshotForManagedEntry (:1365-1394) re-runs each time. Its first branch, if (runtimeCredentials.contents !== managedCredentialsJson), force-overwrites the snapshot with whatever is on disk — and after Claude's routine hourly token refresh, that is Orca's own refreshed managed token, not the user's login. The keychain fields carry a previousSnapshot/managedCredentialsJson guard against exactly this; the plain-file credentialsJson has none.

Reproduced end-to-end on the host:

RUN1 snapshot holds user own login?            true
(CLI refreshes the token inside the distro)
RUN2 snapshot holds user own login?            false
RUN2 snapshot holds REFRESHED MANAGED token?   true
AFTER DESELECT: user own login restored?       false
AFTER DESELECT: accessToken = MANAGED-TOKEN-V2-REFRESHED

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

getWslHome caps wsl.exe -d Ubuntu -- bash -c 'echo $HOME' at 5000 ms. Measured here:

first call after idle    5028ms  ETIMEDOUT -> returns null
subsequent (warm)        145-292ms
cold after --terminate   4362ms  (just under the cap)

When it returns null, resolveWslProfilePaths → null → no surface → getPreparation falls back to configDir: activeAccount.managedAuthPath with CLAUDE_CONFIG_DIRprecisely the empty-profile session #11824 is about. Deliberate by comment, but the trigger is a 5 s timeout that a cold or idle WSL routinely blows.

The 9P premise the guard is built on did not reproduce, and the guard can fail open

  • steady state: Win32 existsSync agreed with wsl.exe test on every path — 0/400 spurious misses
  • immediately after wsl --terminate Ubuntu: existsSync/statSync/readFile all succeeded — 0/12 failures (first access transparently restarts the distro)

And the guard is wrong in both directions on real hardware:

  • wslUncFileExists returned null (ETIMEDOUT) right after a terminate — its 5 s cap is shorter than a cold start.
  • Its comment claims "wsl.exe missing, distro not running, timeout → inconclusive → null", but an unreachable distro exits 4294967295, which is a number, so typeof status === 'number' reports false = definitively absent. wsl.exe signals its own failures numerically, so the fail-closed guard can fail open.

Confirmed as predicted

A distro I/O error still aborts terminal creation: writeRuntimeCredentials at :569 is bare, serializeMutation rethrows, and pty.ts:4312/:5596 await prepareClaudeAuth unguarded. One correction — the code is ENOTSUP, not EACCES (9P maps Linux EPERM that way), so isPermissionError is false and the icacls retry never fires.

What a redesign needs

  1. An Orca-owned ownership marker — compare the actual refresh token, or write a marker next to the profile — rather than reading identity fields Claude does not produce. This one assumption causes both the always-fresh-entry behaviour and the data loss above.
  2. A credentialsJson guard in captureSystemDefaultSnapshot mirroring the existing keychain one.
  3. try/catch around the distro write path so an I/O error degrades instead of killing terminal creation.
  4. A longer or adaptive timeout for getWslHome and wslUncFileExists — 5 s is shorter than a real cold start.
  5. wslUncPathExists must distinguish wsl.exe's own 4294967295 from a real test exit of 1.

Also worth recording: the earlier major finding about oauthAccountAlreadyWritten trusting in-memory bookkeeping is fixed — it now calls runtimeOauthAccountMatches and compares on-disk state.

Suites on Windows: 5 files, 77 tests, all pass.

Not verified

No 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 (windows-native-registry fails electron-rebuild), so the "terminal never opens" conclusion is code structure plus a real-hardware proof that the write throws, not an observed failed launch. There is no second distro, so per-distro snapshot isolation is untested on hardware.

Safety: the distro profile was backed up before any write (415 files, md5-verified) and re-verified clean after both destructive runs.

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