From d22ad8ea1a5b84773cbc8a0b748f3200847db904 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 29 Jun 2026 08:24:01 +0200 Subject: [PATCH] Surface duplicate Claude parallel logins Constraint: keep authmux parallel state read-only and avoid printing credential hashes or token values. Tested: npm test (unsandboxed; sandboxed Node child spawn hit EPERM). --- README.md | 2 + .../.openspec.yaml | 2 + .../notes.md | 16 +++ src/commands/parallel.ts | 121 ++++++++++++++++-- src/tests/json-parity.test.ts | 51 ++++++++ 5 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/.openspec.yaml create mode 100644 openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/notes.md diff --git a/README.md b/README.md index bcd2df8..61e83f0 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,8 @@ authmux parallel --install # Write aliases to shell rc file Each profile gets its own config directory at `~/.claude-accounts/`. Shell aliases/functions set `CLAUDE_CONFIG_DIR` before launching `claude`, so each instance uses isolated credentials, settings, and history. `authmux parallel --install` writes Bash/Zsh aliases or Fish autoload functions based on `$SHELL`. Login and logout commands bypass Cue and run raw Claude against the selected profile so refreshed OAuth tokens are written back to the right account directory. Run profiles in separate terminal tabs or tmux panes for true parallel usage. +`authmux parallel --list` warns when two profiles contain the same Claude credentials or the same Claude OAuth account UUID. Those profiles are not independent: logging in or out of one can invalidate the other because Claude sees them as the same account session. Re-run `authmux parallel --login ` and choose a different Anthropic account for any profile that should have its own subscription/session. + ### Notes - Requires a separate Anthropic subscription (different email) per profile. diff --git a/openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/.openspec.yaml b/openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/notes.md b/openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/notes.md new file mode 100644 index 0000000..59f1046 --- /dev/null +++ b/openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/notes.md @@ -0,0 +1,16 @@ +# agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16 (minimal / T1) + +Branch: `agent/codex/fix-claude-account-relogin-isolation-2026-06-29-08-16` + +Diagnose Claude parallel-account relogin bleed and surface duplicate Claude credentials/OAuth account state in `authmux parallel`. + +## Handoff + +- Handoff: change=`agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16`; branch=`agent/codex/fix-claude-account-relogin-isolation-2026-06-29-08-16`; scope=`src/commands/parallel.ts src/tests/json-parity.test.ts README.md`; action=`detect duplicate Claude credentials/account UUIDs and warn that shared profiles are not independent`. +- Copy prompt: Continue `agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16` on branch `agent/codex/fix-claude-account-relogin-isolation-2026-06-29-08-16`. Work inside the existing sandbox, review `openspec/changes/agent-codex-fix-claude-account-relogin-isolation-2026-06-29-08-16/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/fix-claude-account-relogin-isolation-2026-06-29-08-16 --base main --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/fix-claude-account-relogin-isolation-2026-06-29-08-16 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index dbec7dd..c960f68 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -5,6 +5,7 @@ import { Command, Flags } from "@oclif/core"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; @@ -19,6 +20,16 @@ const CUE_PROFILE_FILE = ".authmux-cue-profile"; const DEFAULT_CUE_PROFILE = "core"; type ShellName = "bash" | "zsh" | "fish"; +interface ParallelProfileEntry { + name: string; + configDir: string; + skillProfile: string; + cueProfile: string; + credentialsPresent: boolean; + credentialsDuplicateOf?: string; + claudeAccountDuplicateOf?: string; +} + function getProfiles(): string[] { if (!fs.existsSync(CLAUDE_PARALLEL_DIR)) return []; return fs.readdirSync(CLAUDE_PARALLEL_DIR, { withFileTypes: true }) @@ -80,6 +91,68 @@ function writeCueProfile(name: string, cueProfile: string): void { fs.writeFileSync(file, `${cueProfile.trim()}\n`); } +function credentialsPathForProfile(name: string): string { + return path.join(CLAUDE_PARALLEL_DIR, name, ".credentials.json"); +} + +function claudeStatePathForProfile(name: string): string { + return path.join(CLAUDE_PARALLEL_DIR, name, ".claude.json"); +} + +function hashFileIfPresent(file: string): string | undefined { + if (!fs.existsSync(file)) return undefined; + const hash = createHash("sha256"); + hash.update(fs.readFileSync(file)); + return hash.digest("hex"); +} + +function readClaudeAccountUuid(name: string): string | undefined { + const file = claudeStatePathForProfile(name); + if (!fs.existsSync(file)) return undefined; + + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { + oauthAccount?: { accountUuid?: unknown }; + }; + const uuid = parsed.oauthAccount?.accountUuid; + return typeof uuid === "string" && uuid.length > 0 ? uuid : undefined; + } catch { + return undefined; + } +} + +function duplicateOwner( + value: T, + seen: Map, + profile: string, +): string | undefined { + if (!value) return undefined; + const existing = seen.get(value); + if (existing) return existing; + seen.set(value, profile); + return undefined; +} + +function buildProfileEntries(profiles = getProfiles()): ParallelProfileEntry[] { + const seenCredentialHashes = new Map(); + const seenClaudeAccountUuids = new Map(); + + return profiles.map((p) => { + const credentialHash = hashFileIfPresent(credentialsPathForProfile(p)); + const claudeAccountUuid = readClaudeAccountUuid(p); + + return { + name: p, + configDir: path.join(CLAUDE_PARALLEL_DIR, p), + skillProfile: readSkillProfile(p) ?? "base", + cueProfile: readCueProfile(p) ?? readSkillProfile(p) ?? DEFAULT_CUE_PROFILE, + credentialsPresent: Boolean(credentialHash), + credentialsDuplicateOf: duplicateOwner(credentialHash, seenCredentialHashes, p), + claudeAccountDuplicateOf: duplicateOwner(claudeAccountUuid, seenClaudeAccountUuids, p), + }; + }); +} + export default class ClaudeParallel extends Command { static description = "Manage parallel Claude Code accounts via CLAUDE_CONFIG_DIR"; @@ -191,7 +264,7 @@ export default class ClaudeParallel extends Command { this.log(` Config dir: ${dir}`); } - const credentialsPath = path.join(dir, ".credentials.json"); + const credentialsPath = credentialsPathForProfile(name); const before = fs.statSync(credentialsPath, { throwIfNoEntry: false })?.mtimeMs ?? 0; const result = spawnSync("claude", ["login"], { stdio: "inherit", @@ -220,6 +293,7 @@ export default class ClaudeParallel extends Command { const suffix = after.mtimeMs > before ? "refreshed" : "present"; this.log(`Claude credentials ${suffix} for "${name}" at ${credentialsPath}.`); + this.warnIfProfileDuplicates(name); } private removeProfile(name: string): void { @@ -242,12 +316,7 @@ export default class ClaudeParallel extends Command { private listProfiles(): void { const profiles = getProfiles(); - const entries = profiles.map((p) => ({ - name: p, - configDir: path.join(CLAUDE_PARALLEL_DIR, p), - skillProfile: readSkillProfile(p) ?? "base", - cueProfile: readCueProfile(p) ?? readSkillProfile(p) ?? DEFAULT_CUE_PROFILE, - })); + const entries = buildProfileEntries(profiles); if (this.jsonMode) { writeJsonEnvelope(jsonSuccess({ @@ -264,11 +333,47 @@ export default class ClaudeParallel extends Command { } this.log("Claude Code parallel profiles:\n"); for (const p of entries) { - this.log(` • ${p.name} → ${p.configDir} skillProfile=${p.skillProfile} cueProfile=${p.cueProfile}`); + const duplicateBits = [ + p.credentialsDuplicateOf ? `credentialsDuplicateOf=${p.credentialsDuplicateOf}` : undefined, + p.claudeAccountDuplicateOf ? `claudeAccountDuplicateOf=${p.claudeAccountDuplicateOf}` : undefined, + ].filter(Boolean); + const duplicateSuffix = duplicateBits.length ? ` ${duplicateBits.join(" ")}` : ""; + this.log( + ` • ${p.name} → ${p.configDir} skillProfile=${p.skillProfile} cueProfile=${p.cueProfile}` + + ` credentials=${p.credentialsPresent ? "present" : "missing"}${duplicateSuffix}`, + ); } + this.warnForDuplicateEntries(entries); this.log(`\nRun any profile: claude- (after installing aliases)`); } + private warnIfProfileDuplicates(name: string): void { + const entry = buildProfileEntries().find((p) => p.name === name); + if (!entry) return; + + if (entry.credentialsDuplicateOf || entry.claudeAccountDuplicateOf) { + const other = entry.credentialsDuplicateOf ?? entry.claudeAccountDuplicateOf; + this.warn( + `Profile "${name}" uses the same Claude login as "${other}". ` + + "Those profiles are not independent; logging in or out of one can invalidate the other.", + ); + this.warn("Log this profile in with a different Anthropic account to run true parallel sessions."); + } + } + + private warnForDuplicateEntries(entries: ParallelProfileEntry[]): void { + const duplicates = entries.filter((p) => p.credentialsDuplicateOf || p.claudeAccountDuplicateOf); + if (!duplicates.length) return; + + for (const entry of duplicates) { + const other = entry.credentialsDuplicateOf ?? entry.claudeAccountDuplicateOf; + this.warn( + `Profile "${entry.name}" shares a Claude login with "${other}". ` + + "Use a different Anthropic account if these should run independently.", + ); + } + } + private generateBashAliases(): string { const profiles = getProfiles(); if (!profiles.length) return ""; diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts index 08c17db..82b2493 100644 --- a/src/tests/json-parity.test.ts +++ b/src/tests/json-parity.test.ts @@ -189,6 +189,57 @@ function runCli(argv: string[], env: NodeJS.ProcessEnv): { stdout: string; stder }; } +test("parallel --list flags duplicate Claude credentials", async () => { + await withSandbox(async (env) => { + for (const profile of ["account1", "account2"]) { + const added = runCli(["parallel", "--add", profile, "--json"], env); + assert.equal(added.status, 0, added.stderr); + } + + const accountsDir = path.join(env.HOME as string, ".claude-accounts"); + const credentials = JSON.stringify({ + claudeAiOauth: { + accessToken: "same-access-token", + refreshToken: "same-refresh-token", + expiresAt: 1782742285132, + }, + }); + const claudeState = JSON.stringify({ + oauthAccount: { + accountUuid: "same-claude-account", + }, + }); + + for (const profile of ["account1", "account2"]) { + const profileDir = path.join(accountsDir, profile); + await fsp.writeFile(path.join(profileDir, ".credentials.json"), credentials); + await fsp.writeFile(path.join(profileDir, ".claude.json"), claudeState); + } + + const listed = runCli(["parallel", "--list", "--json"], env); + assert.equal(listed.status, 0, listed.stderr); + + const parsed = JSON.parse(listed.stdout.trim()) as { + ok: true; + data: { + profiles: Array<{ + name: string; + credentialsPresent: boolean; + credentialsDuplicateOf?: string; + claudeAccountDuplicateOf?: string; + }>; + }; + }; + const account1 = parsed.data.profiles.find((p) => p.name === "account1"); + const account2 = parsed.data.profiles.find((p) => p.name === "account2"); + assert.equal(account1?.credentialsPresent, true); + assert.equal(account1?.credentialsDuplicateOf, undefined); + assert.equal(account2?.credentialsPresent, true); + assert.equal(account2?.credentialsDuplicateOf, "account1"); + assert.equal(account2?.claudeAccountDuplicateOf, "account1"); + }); +}); + for (const tc of CASES) { test(`--json parity: ${tc.name}`, async () => { await withSandbox(async (env) => {