Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ authmux parallel --install # Write aliases to shell rc file

Each profile gets its own config directory at `~/.claude-accounts/<name>`. 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 <name>` 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-29
Original file line number Diff line number Diff line change
@@ -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`).
121 changes: 113 additions & 8 deletions src/commands/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 })
Expand Down Expand Up @@ -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<T extends string | undefined>(
value: T,
seen: Map<string, string>,
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<string, string>();
const seenClaudeAccountUuids = new Map<string, string>();

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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -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({
Expand All @@ -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-<name> (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 "";
Expand Down
51 changes: 51 additions & 0 deletions src/tests/json-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading