feat: per-worktree Claude account binding (concurrent multi-account)#6963
feat: per-worktree Claude account binding (concurrent multi-account)#6963srs-adamr wants to merge 14 commits into
Conversation
|
Ready to review this PR? Stage has broken it down into 6 individual chapters for you: Chapters generated by Stage for commit 30b3b50 on Jul 2, 2026 2:10am UTC. |
Wave 1 plumbing for per-worktree Claude account binding. Adds the optional claudeAccountId field to Worktree and WorktreeMeta (following the workspaceStatus precedent), surfaces it through the meta->Worktree merges (git + folder paths), and threads it through createWorktree args. Persistence (blind spread merge) and the renderer store (generic Partial<WorktreeMeta> spread) carry the field with no further change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the worktree's claudeAccountId fresh at each Claude PTY spawn
(pty.ts) and pass it as an overrideAccountId on the selection target. In
getPreparation, a valid owned host override returns the WSL-shaped injection
({configDir, CLAUDE_CONFIG_DIR envPatch, stripAuthEnv}) instead of
materializing into shared ~/.claude, and doSyncForCurrentSelection
early-returns for injected accounts (mirroring the WSL path). A missing,
foreign, or WSL override is ignored, so unassigned worktrees keep today's
global behavior — purely additive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code 2.1+ reads macOS Keychain credentials from a config-dir-scoped service rather than the legacy unscoped one. An injected per-worktree host account points CLAUDE_CONFIG_DIR at its own managedAuthPath, so before launch we now seed ONLY that scoped service (reusing the existing scoped-only writeActiveClaudeKeychainCredentials helper) from the account's managed credentials. The legacy unscoped service is never touched here — that's the shared global-selection singleton, and writing it from multiple worktrees would race. No-op on Linux/Windows (keychain ops are darwin-only there) and for unassigned worktrees, which keep today's behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Threads the per-worktree claudeAccountId (Wave 1 data model) from the quick-composer's submitQuick(agent, claudeAccountId) through WorktreeCreationRequest and into the createWorktree store action's trailing options bag (alongside automationProvenanceRequest, avoiding yet another positional parameter on an already 24-deep signature). Purely additive: existing callers omit the new param/field and see no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Claude account picker beside the Agent picker in the quick-create modal, mirroring the quickAgent/onQuickAgentChange controlled-prop pattern. Populated from claudeAccounts:list, default "Inherit global", filtered to accounts compatible with the target repo's runtime (host vs WSL — keyed off the repo's on-disk path via the new claude-account-runtime-filter helper, shared with the upcoming context-menu surface). Hidden for folder-workspace and SSH-remote targets, where the field either isn't forwarded yet or has no local launch path to apply to. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clones the "Move to Status" DropdownMenuSub into an "Assign Account" submenu
(plus "Inherit global") calling updateWorktreeMeta(id, { claudeAccountId }).
Fetches claudeAccounts:list only while the menu is open, mirroring the
existing selectMenuScopedMap gating so many closed sidebar rows don't each
fire an IPC call. Filtered by runtime (host vs WSL) via the
claude-account-runtime-filter helper added alongside the create-modal
selector; multi-select uses the first selected worktree's path as the
representative runtime, matching contextWorkspaceStatus's "use first" mixed-
value convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n mismatch resolveInjectedHostAccount only resolved HOST override accounts, so a worktree pinned to a WSL account had no effect on WSL launches: the WSL prep branch in getPreparation kept reading the global active account's wslLinuxAuthPath instead of the worktree's override. Generalize the resolver (now resolveInjectedAccount) to also resolve a WSL override whose runtime/distro matches the launch, inject that account's wslLinuxAuthPath (+ configDir/wslLinuxConfigDir/wslDistro, stripAuthEnv) from getPreparation, and bypass materialization in doSyncForCurrentSelection the same way the host path already does. On a runtime/distro mismatch (e.g. a host-pinned account on a WSL launch, or a WSL-pinned account for the wrong distro), fall back to the global selection and emit a single console.warn instead of silently ignoring the pin. seedInjectedHostAccountKeychain now explicitly skips WSL accounts (they're isolated by their own Linux config dir and need no Keychain seed), since the generalized resolver can return either runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wave 1 added a store.getWorktreeMeta(worktreeId) call in pty.ts to resolve the per-worktree claudeAccountId override at spawn time, but the store mocks in pty.test.ts were never given that method, causing "store.getWorktreeMeta is not a function" in 13 tests whenever a spawn path passed a string worktreeId. Add getWorktreeMeta: vi.fn() (returning undefined, i.e. no pinned account) to each affected store mock.
… global switch-block doSyncForCurrentSelection already early-returns for injected (per-worktree- pinned) accounts, so Orca does no proactive refresh/materialization for them — the Claude CLI owns each injected config dir's own token refresh. The only remaining gap was that a Claude launch in a pinned worktree could still be blocked by isClaudeAuthSwitchInProgress() (live-pty-gate.ts), even though it never reads/writes the shared ~/.claude runtime that gate protects. Add ClaudeRuntimeAuthService.hasInjectedAccountOverride(), a synchronous wrapper around the existing resolveInjectedAccount() resolution (the same one getPreparation() uses), so the PTY spawn gates in pty.ts can tell whether an in-flight launch is injected *before* paying for the async prepare/keychain work. Both gates in pty.ts (runtime.setPtyController's spawn and the pty:spawn IPC handler) now skip the switch-block throw only when the launch resolves a valid per-worktree override; the global- selection (non-injected) path is unchanged. This is explicitly NOT the per-config-dir refresh lock described in the spec's Correctness model — that remains future work. It is the minimal coordination fix needed so a global account switch cannot stall a worktree that isn't using the shared runtime at all.
…tor shares its row
d6420c2 to
30f82ca
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
👮 Files not reviewed due to content moderation or server errors (1)
📝 Walkthrough🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
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 |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/renderer/src/components/NewWorkspaceComposerCard.test.tsx (1)
119-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding coverage for the new Account selector.
Props are wired correctly for the default render, but no test exercises rendering the Select when
claudeAccountsis non-empty, or verifiesonClaudeAccountIdChangereceivesnullwhenINHERIT_GLOBAL_CLAUDE_ACCOUNT_VALUEis selected vs. the account id otherwise.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 296a1bb5-1312-4bd5-b8fc-f7526e879ff1
📒 Files selected for processing (26)
src/main/claude-accounts/runtime-auth-service.test.tssrc/main/claude-accounts/runtime-auth-service.tssrc/main/claude-accounts/runtime-selection.tssrc/main/index.tssrc/main/ipc/pty.test.tssrc/main/ipc/pty.tssrc/main/ipc/worktree-metadata-merge.tssrc/main/ipc/worktree-remote.tssrc/main/ipc/worktrees.tssrc/main/window/attach-main-window-services.tssrc/renderer/src/components/NewWorkspaceComposerCard.test.tsxsrc/renderer/src/components/NewWorkspaceComposerCard.tsxsrc/renderer/src/components/NewWorkspaceComposerModal.tsxsrc/renderer/src/components/sidebar/WorktreeContextMenu.tsxsrc/renderer/src/hooks/useComposerState.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/renderer/src/lib/claude-account-runtime-filter.tssrc/renderer/src/lib/pending-worktree-creation.tssrc/renderer/src/lib/worktree-creation-flow.tssrc/renderer/src/store/slices/worktree-helpers.tssrc/renderer/src/store/slices/worktrees.tssrc/shared/types.ts
- runtime-auth-service: memoize the Windows WSL owned-auth-path resolution
(execFileSync('wsl.exe')) so hasInjectedAccountOverride() no longer blocks
the main process on a synchronous subprocess in the PTY spawn hot path.
Only successful resolutions are cached; failures re-attempt.
- NewWorkspaceComposerModal: add .catch() to the claudeAccounts.list() fetch
so an IPC rejection degrades to an empty list instead of an unhandled
promise rejection.
- NewWorkspaceComposerCard.test: cover the Account selector — conditional
render, controlled value display, and that onClaudeAccountIdChange emits the
account id on select and null for "Inherit global".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the automated review feedback in 02de27d:
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/main/claude-accounts/runtime-auth-service.ts (2)
789-797: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winPreserve default-WSL account matching when the target has no distro.
All callers pass the target through
resolveWslDefaultTarget()first, so a launch with no distro becomes the concrete default distro. That makes a pinned WSL account stored withwslDistro: null/__default__fail this comparison and fall back to global selection even though it represents the default WSL account.🐛 Proposed fix
- const runtimeMismatch = + const targetWslKey = getClaudeWslSelectionKey(normalizedTarget.wslDistro) + const defaultWslKey = getClaudeWslSelectionKey(getDefaultWslDistro()) + const accountWslKey = getClaudeWslSelectionKey(account.wslDistro) + const wslDistroMatches = + accountWslKey === targetWslKey || + (normalizedTarget.runtime === 'wsl' && + targetWslKey === defaultWslKey && + accountWslKey === getClaudeWslSelectionKey(null)) + const runtimeMismatch = (normalizedTarget.runtime === 'host' && accountIsWsl) || - (normalizedTarget.runtime === 'wsl' && - (!accountIsWsl || - getClaudeWslSelectionKey(account.wslDistro) !== - getClaudeWslSelectionKey(normalizedTarget.wslDistro))) + (normalizedTarget.runtime === 'wsl' && (!accountIsWsl || !wslDistroMatches))
845-850: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftDo not overwrite refreshed injected Keychain credentials with stale managed credentials.
For macOS injected host launches, Claude can refresh the scoped Keychain service for
account.managedAuthPath. The next launch always seeds that scoped service from managed storage, but this path never reads the scoped service back first; a rotated refresh token can be replaced with the stale managed copy and break subsequent auth.Before writing, read the scoped service for
account.managedAuthPathand persist/keep it when it matches this account and is fresher or has a non-older rotated refresh token, mirroring the existing read-back logic used for global runtime auth.
🧹 Nitpick comments (1)
src/renderer/src/components/NewWorkspaceComposerCard.test.tsx (1)
180-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
as nevererases type safety for the fixture.Casting the fixture
as neverbypasses structural checks againstClaudeManagedAccountSummary[]entirely, so future required-field additions to that type won't be caught here.♻️ Suggested safer typing
-const claudeAccounts = [ - { id: 'acct-alice', email: 'alice@example.com' }, - { id: 'acct-bob', email: 'bob@example.com' } -] as never +const claudeAccounts = [ + { id: 'acct-alice', email: 'alice@example.com' }, + { id: 'acct-bob', email: 'bob@example.com' } +] as unknown as ClaudeManagedAccountSummary[]
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 71715fcb-9f85-447a-b009-3eaac928fafb
📒 Files selected for processing (3)
src/main/claude-accounts/runtime-auth-service.tssrc/renderer/src/components/NewWorkspaceComposerCard.test.tsxsrc/renderer/src/components/NewWorkspaceComposerModal.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- src/renderer/src/components/NewWorkspaceComposerModal.tsx
…e-seed Addresses the second CodeRabbit review pass: - resolveInjectedAccount: canonicalize the WSL distro key so a default-WSL account (wslDistro: null -> '__default__') matches a launch whose distro resolved to the concrete default. Also fixes resolveWslDefaultTarget to preserve overrideAccountId when it fills in the default distro, so a distro-less WSL launch no longer drops its per-worktree pin. (functional correctness) + regression test. - seedInjectedHostAccountKeychain: only bootstrap the config-dir-scoped Keychain on first launch. Once Claude owns that scoped service it may hold a rotated refresh token (injected accounts are CLI-owned, no read-back), so re-seeding the static managed copy would clobber it. Skip when valid creds already exist there. (macOS data integrity) + regression test. - NewWorkspaceComposerCard.test: restore the HTMLElement.prototype pointer/ scroll polyfills in a finally so they don't leak across tests; type the account fixture as ClaudeManagedAccountSummary[] instead of `as never`. typecheck + full claude-accounts/pty/composer suites (364) green; lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the second review pass in 30b3b50. 🟠 Default-WSL account matching (functional correctness) — verified real and fixed, plus a related bug found while tracing it:
🟠 Injected Keychain re-seed clobber (macOS data integrity) — verified real and fixed. On macOS the seed reads the managed keychain (keyed by 🟡/🔵 Test hardening — the
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/main/claude-accounts/runtime-auth-service.test.ts (1)
3829-3854: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winForced non-null assertion on
originalReadImplrisks a silent mock-restore no-op.
originalReadImplis typed as possiblyundefined(fromgetMockImplementation()), but thefinallyblock restores it withreadStrict.mockImplementation(originalReadImpl!). If no prior implementation was set forreadActiveClaudeKeychainCredentialsStrictin this suite, this silently swaps the mock's implementation toundefinedrather than genuinely restoring prior state — masking the intent and risking subtle isolation drift for later tests, similar to the mock-restore issues already hardened elsewhere in this PR (HTMLElement.prototypepolyfills restored infinally).♻️ Safer restore
} finally { - readStrict.mockImplementation(originalReadImpl!) + if (originalReadImpl) { + readStrict.mockImplementation(originalReadImpl) + } else { + readStrict.mockReset() + } }
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 3405120c-aae3-464c-9f36-1b64874798c4
📒 Files selected for processing (3)
src/main/claude-accounts/runtime-auth-service.test.tssrc/main/claude-accounts/runtime-auth-service.tssrc/renderer/src/components/NewWorkspaceComposerCard.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
- src/renderer/src/components/NewWorkspaceComposerCard.test.tsx
- src/main/claude-accounts/runtime-auth-service.ts
…null Third CodeRabbit pass (trivial): guard the finally-block restore of readActiveClaudeKeychainCredentialsStrict — restore the captured implementation when present, else mockReset() — instead of asserting it non-null, mirroring the polyfill-restore hardening elsewhere in this PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Lets each worktree bind to a specific Claude managed account, so multiple worktrees can run different Claude accounts concurrently, and a worktree's agents stay deterministically pinned to its account across spawn/despawn churn. Closes the per-project ask in #5203 / #5635 (concurrency is the superset). Tracking design discussion: #6921.
Today account selection is global per host (
activeClaudeManagedAccountId), and the host launch path materializes the active account's creds into shared~/.claude, which is structurally single-tenant. This PR adds an optionalclaudeAccountIdon the worktree and, when set, has the launch reuse Orca's existing WSL-styleCLAUDE_CONFIG_DIRinjection on the host path instead of materializing — so assigned worktrees are isolated and concurrent. Purely additive: unassigned worktrees are byte-for-byte unchanged.What's included:
claudeAccountId?: string | nullonWorktree+WorktreeMeta(mirrors theworkspaceStatusprecedent end-to-end through persistence/IPC/store).pty.tsresolves the worktree's pinned account fresh at every spawn and passes it as an override;getPreparationreturns the injection shape ({ configDir: managedAuthPath, envPatch: { CLAUDE_CONFIG_DIR }, stripAuthEnv: true }) for host and WSL pinned accounts;doSyncForCurrentSelectionearly-returns for injected accounts (no materialization/read-back).Claude Code-credentials-<sha256(configDir)[:8]>) once per launch; never the shared legacy service.Screenshots
Create-worktree modal — new Account selector beside the Agent picker (hidden when no accounts exist; runtime-filtered host/WSL):
Worktree context menu — new Assign Account submenu ("Inherit global" + managed accounts):
Testing
pnpm typecheck— cleanpnpm build— cleanpnpm lint— the only failure is pre-existingverify:localization-catalogdebt inEphemeralVmsPane.tsx(a file this PR does not touch); this PR's own new i18n keys pass.pnpm test— 16 failures, all pre-existing onmain(PR-comments, Linear-skill toast, sidebar-toolbar suites — all outside this PR's diff). Verified by running the full suite at the base commit: identical 16 failures there (16 failed / 23035 passed) vs this branch (16 failed / 23039 passed) → this PR adds zero new failures. Branch is rebased onto currentupstream/main(v1.4.111-rc.2); our targeted suites are green (347 passed acrossclaude-accounts,pty.test.ts, and the composer card).pty.test.tsmock (getWorktreeMeta) is fixed.AI Review Report
Reviewed with Claude (Opus) across the full diff. Main risks checked:
!isInjectedClaudeLaunch. The override is threaded as a trailing optional param, leaving ~130 existing call sites untouched.process.platform === 'darwin'-guarded (no-ops elsewhere); host vs WSL paths are cleanly separated viamanagedAuthRuntime/wslLinuxAuthPath; runtime filtering reuses the existinggetWslDistroFromPathhelper (no hardcoded path separators); SSH-remote spawns are correctly out of scope (no-op fallback, never a wrong-account launch). No Electron menu accelerators or shortcuts touched.doSyncForCurrentSelectionearly-returns), so token rotation ([Feature]: Make Orca's managed Claude (cloud subscription) auth survive token rotation — stop daily re-auth #6234) is preserved; multi-tenant (N worktrees → 1 account) refresh is CLI-owned per config dir, as it already is for the shared runtime today.Flagged and addressed: an initial version left WSL bindings inert (host-only resolution) — fixed so WSL worktrees inject their account's
wslLinuxAuthPath, with a runtime/distro-mismatch fallback + warn.Security Audit
CLAUDE_CONFIG_DIR) in the child env and setsstripAuthEnv: true; no token/credential is ever placed in the environment or logged (the mismatch/seed warnings log account ids, not secrets).getOwnedManagedAuthPath(account)(ownership-marker validated), so a pinned account can't pointCLAUDE_CONFIG_DIRoutside Orca's managed root.claudeAccountIdstring; no credential material crosses IPC.Claude Code-credentialsservice is never written from an injected launch (avoids cross-worktree clobber), and the seed is best-effort (degrades to Claude's own login prompt, never throws).Follow-up needed: none blocking. See Notes for deferred scope.
Notes
CLAUDE_CONFIG_DIRrefresh lock — not needed for this PR since Orca doesn't proactively refresh injected accounts (CLI-owned); (2) per-worktree usage/rate-limit display in the Accounts pane.WorktreeMetafollowing theworkspaceStatusprecedent — happy to relocate if maintainers prefer a different home (raised in [Feature]: Per-worktree Claude account binding (concurrent multi-account + per-project defaults) #6921).