Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b8c4beb
Run a Claude account on System default until it has its own login
Jinwoo-H Oct 8, 2026
26dae32
Refresh each Claude account folder from ~/.claude before every launch
Jinwoo-H Oct 8, 2026
9e2355a
Route chat auth and unselected-account usage through the Claude router
Jinwoo-H Oct 8, 2026
85aff42
Show Claude account sign-in prompts only when an account cannot run
Jinwoo-H Oct 8, 2026
8f216ac
Keep the user's shell Anthropic auth on Claude account launches
Jinwoo-H Oct 9, 2026
f359109
Share from ~/.claude only what Claude itself would seed into a new fo…
Jinwoo-H Oct 9, 2026
56f374e
Merge trusted folders and MCP servers into an account instead of repl…
Jinwoo-H Oct 9, 2026
40cc9cd
Require the same organization before System default stands in for an …
Jinwoo-H Oct 9, 2026
3a858d2
Treat v42 terminals as having the claude account function
Jinwoo-H Oct 9, 2026
d17034c
Fail the routing test when a new module resolves the claude binary
Jinwoo-H Oct 9, 2026
60cafb3
Skip the account refresh on launches that run on System default
Jinwoo-H Oct 9, 2026
d82f7f2
Give a terminal opened before the login shell's env only the account …
Jinwoo-H Oct 9, 2026
678e719
Drop the chat-only Anthropic auth stripping and override refusal
Jinwoo-H Oct 9, 2026
dcce31c
Publish a new account state file whole
Jinwoo-H Oct 9, 2026
64c87fc
Read an unselected WSL account's usage where the WSL router would run it
Jinwoo-H Oct 9, 2026
3ecd16b
Keep the shell's Anthropic auth when the claude function runs an account
Jinwoo-H Oct 9, 2026
ff11abc
Never drop the user's Anthropic auth from a Claude launch
Jinwoo-H Oct 9, 2026
fcc2316
Leave out of an account's state everything Claude resets when it sign…
Jinwoo-H Oct 9, 2026
4e8ff77
Judge an old terminal's Claude banner by the pane's own runtime
Jinwoo-H Oct 9, 2026
6cbf056
Leave an account's settings file alone when only Orca's hooks differ …
Jinwoo-H Oct 9, 2026
bc3fbc2
Reread a WSL account's Claude state only when the file changes
Jinwoo-H Oct 9, 2026
586c1b7
Merge branch 'claude-accounts-reland' into claude-accounts-reland-fixes
Jinwoo-H Oct 9, 2026
37414b0
Move tests merged from main onto the account-only Claude auth policy
Jinwoo-H Oct 9, 2026
973b94f
Merge branch 'claude-accounts-reland' into claude-accounts-reland-fixes
Jinwoo-H Oct 9, 2026
70ec03d
Merge branch 'claude-accounts-reland' into claude-accounts-reland-fixes
Jinwoo-H Oct 9, 2026
3711824
Merge branch 'claude-accounts-reland' into claude-accounts-reland-fixes
Jinwoo-H Oct 9, 2026
8c02840
Merge branch 'claude-accounts-reland' into claude-accounts-reland-fixes
Jinwoo-H Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion src/main/agent-workspace-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ describe('applyAgentWorkspaceTrust on this machine', () => {
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/u/.claude',
envPatch: {},
stripAuthEnv: false,
provenance: 'test'
} as const
const wsl = { ...local, claudeAuth: wslAuth, wslDistro: 'Ubuntu' }
Expand Down
35 changes: 33 additions & 2 deletions src/main/claude-accounts/claude-account-folder.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
Expand All @@ -21,7 +29,11 @@ vi.mock('../macos-keychain/generic-password', () => ({
}
}))

import { readClaudeFolderLogin, removeClaudeAccountFolder } from './claude-account-folder'
import {
readClaudeFolderLogin,
readClaudeFolderLoginAsync,
removeClaudeAccountFolder
} from './claude-account-folder'
import { claudeKeychainService } from './keychain'

const roots: string[] = []
Expand Down Expand Up @@ -65,6 +77,25 @@ describe('claude account folder', () => {
expect(readClaudeFolderLogin(stateFile)?.email).toBe('b@example.test!')
})

it('rereads a state file over a share only when its mtime or size changes', async () => {
const { home } = fixture()
const stateFile = join(home, '.claude.json')
await expect(readClaudeFolderLoginAsync(stateFile)).resolves.toBeNull()
writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'a@example.test' } }))
// Whole seconds, which every filesystem stores exactly.
const mtime = new Date(Math.floor(Date.now() / 1000) * 1000)
utimesSync(stateFile, mtime, mtime)
expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('a@example.test')
// Same size and mtime: the cached answer, so the file was not read again.
writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.test' } }))
utimesSync(stateFile, mtime, mtime)
expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('a@example.test')
utimesSync(stateFile, mtime, new Date(mtime.getTime() + 2_000))
expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('b@example.test')
rmSync(stateFile)
await expect(readClaudeFolderLoginAsync(stateFile)).resolves.toBeNull()
})

// A real symlink needs Developer Mode on Windows (EPERM otherwise).
it.skipIf(process.platform === 'win32')(
'unlinks shared history before deleting, and leaves the older layout alone',
Expand Down
46 changes: 46 additions & 0 deletions src/main/claude-accounts/claude-account-folder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
statSync,
unlinkSync
} from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { z } from 'zod'
Expand Down Expand Up @@ -48,6 +49,20 @@ const logins = new Map<
{ mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null }
>()

/** Whether two logins name one account: the same email, and the same organization when both
* name one, so one email in two organizations stays two accounts. */
export function isSameClaudeLogin(
left: { email: string; organizationUuid?: string | null },
right: { email: string; organizationUuid?: string | null }
): boolean {
return (
left.email.trim().toLowerCase() === right.email.trim().toLowerCase() &&
(!left.organizationUuid ||
!right.organizationUuid ||
left.organizationUuid === right.organizationUuid)
)
}

/** The login parsed Claude state names; null before Claude finishes a sign-in. */
export function claudeStateLogin(state: Record<string, unknown>): ClaudeFolderLogin | null {
const parsed = oauthAccount.safeParse(state.oauthAccount)
Expand Down Expand Up @@ -87,6 +102,37 @@ export function readClaudeFolderLogin(stateFile: string, maxAgeMs = 0): ClaudeFo
return login
}

/** readClaudeFolderLogin without blocking, for a state file across a WSL share. */
export async function readClaudeFolderLoginAsync(
stateFile: string
): Promise<ClaudeFolderLogin | null> {
const current = await stat(stateFile).catch(() => null)
if (!current) {
logins.delete(stateFile)
return null
}
const recent = logins.get(stateFile)
if (recent?.mtimeMs === current.mtimeMs && recent.size === current.size) {
return recent.login
}
let login: ClaudeFolderLogin | null = null
try {
const state: unknown = JSON.parse(await readFile(stateFile, 'utf8'))
if (state && typeof state === 'object' && !Array.isArray(state)) {
login = claudeStateLogin(Object.fromEntries(Object.entries(state)))
}
} catch {
// Unreadable reads as signed out, as on the host.
}
logins.set(stateFile, {
mtimeMs: current.mtimeMs,
size: current.size,
readAt: Date.now(),
login
})
return login
}

/**
* Deletes a host account's folder and, on macOS, the Keychain items Claude keyed to it
* (superset profile-remove.ts). Only `claude-profiles/<id>` is reachable from an id, so an
Expand Down
20 changes: 12 additions & 8 deletions src/main/claude-accounts/claude-account-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type ClaudeAccountRuntime = Pick<
> & {
router: Pick<
ClaudeRuntimeAuthService['router'],
'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault'
'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' | 'coveredBySystemDefault'
>
}

Expand Down Expand Up @@ -107,13 +107,17 @@ export class ClaudeAccountSelection {
const login = readClaudeFolderLogin(
claudeStateFile(this.runtimeAuth.router.accountHome(account.id))
)
return login
? {
...summary,
email: login.email,
organizationUuid: login.organizationUuid,
organizationName: login.organizationName
}
if (login) {
return {
...summary,
email: login.email,
organizationUuid: login.organizationUuid,
organizationName: login.organizationName
}
}
// Why: an account System default is signed in to runs there until it signs in on its own.
return this.runtimeAuth.router.coveredBySystemDefault(account.id)
? summary
: { ...summary, needsSignIn: true }
}

Expand Down
15 changes: 13 additions & 2 deletions src/main/claude-accounts/claude-account-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]
JSON.stringify({ oauthAccount: { emailAddress: email } })
)
}
const covered = new Set<string>()
let settings: GlobalSettings = {
...getDefaultSettings('/tmp'),
claudeManagedAccounts: accounts,
Expand All @@ -54,7 +55,8 @@ function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]
router: {
accountHome: home,
userConfigDir: () => join(root, 'personal'),
copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth'))
copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')),
coveredBySystemDefault: (id: string) => covered.has(id)
},
syncForCurrentSelection: vi.fn(async (_target?: ClaudeAccountSelectionTarget) => {}),
publishAll: vi.fn(async () => {}),
Expand Down Expand Up @@ -88,7 +90,7 @@ function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]
runtimeAuth,
runLogin
)
return { root, home, service, runtimeAuth, runLogin, signIn, settings: () => settings }
return { root, home, service, runtimeAuth, runLogin, signIn, covered, settings: () => settings }
}

// The id the last sign-in prepared a folder for.
Expand Down Expand Up @@ -121,6 +123,15 @@ describe('ClaudeAccountService', () => {
expect(byId.get('new-wsl')?.needsSignIn).toBeUndefined()
})

it('asks no sign-in of an account System default runs, and lets it be selected', async () => {
const f = fixture()
f.covered.add('b')
expect(f.service.listAccounts().accounts.find((row) => row.id === 'b')?.needsSignIn).toBe(
undefined
)
await expect(f.service.selectAccount('b')).resolves.toMatchObject({ activeAccountId: 'b' })
})

it("reports System default's login from the user's own folder", () => {
const f = fixture()
mkdirSync(join(f.root, 'personal'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const fsAllowances: Record<string, readonly string[]> = {
'mkdirSync',
'renameSync',
'rmdirSync',
'symlinkSync'
'symlinkSync',
'unlinkSync'
],
'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'],
// The which-account file.
Expand All @@ -51,7 +52,11 @@ const fsAllowances: Record<string, readonly string[]> = {
'writeFileSync',
'writeFileAtomically'
],
'claude-accounts/claude-profile-provisioning.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'],
'claude-accounts/claude-profile-provisioning.ts': [
'publishFileWithoutOverwrite',
'rmSync',
'writeFileSync'
],
'claude-accounts/claude-profile-sharing.ts': [
'mkdirSync',
'rmdirSync',
Expand Down
Loading
Loading