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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ source ~/.bashrc # or ~/.zshrc
claude-work # logs in, credentials saved to ~/.claude-accounts/work
claude-personal # logs in, credentials saved to ~/.claude-accounts/personal

# Force a fresh OAuth login for one profile
authmux parallel --login work
# Force a fresh OAuth login for one profile and reject duplicate account picks
authmux parallel --login work --fresh --require-distinct
```

### Quick setup (standalone script)
Expand All @@ -241,6 +241,7 @@ source ~/.bashrc
```sh
authmux parallel --add <name> # Create a new profile
authmux parallel --login <name> # Refresh Claude OAuth inside that profile
authmux parallel --login <name> --fresh --require-distinct # Reset stale Claude auth first and fail if it matches another profile
authmux parallel --remove <name> # Remove a profile
authmux parallel --list # List all profiles
authmux parallel --aliases # Print aliases (without installing)
Expand All @@ -249,9 +250,9 @@ authmux parallel --install # Write aliases to shell rc file

### How it works

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.
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 so refreshed OAuth tokens are written back to the right account directory. Generated login wrappers use `authmux parallel --login <name> --fresh --require-distinct`, which backs up stale Claude auth first and rejects a login that resolves to another configured profile. 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.
`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> --fresh --require-distinct` and choose a different Anthropic account for any profile that should have its own subscription/session.

### Notes

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-improve-claude-distinct-relogin-guard-2026-06-29-08-34 (minimal / T1)

Branch: `agent/codex/improve-claude-distinct-relogin-guard-2026-06-29-08-34`

Make Claude parallel relogin safer by backing up stale per-profile auth, forcing a fresh login path from generated wrappers, and rejecting duplicate Claude account results.

## Handoff

- Handoff: change=`agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34`; branch=`agent/codex/improve-claude-distinct-relogin-guard-2026-06-29-08-34`; scope=`src/commands/parallel.ts src/tests/json-parity.test.ts README.md`; action=`finish guarded Claude relogin implementation and verify generated bash/fish wrappers`.
- Copy prompt: Continue `agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34` on branch `agent/codex/improve-claude-distinct-relogin-guard-2026-06-29-08-34`. Work inside the existing sandbox, review `openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/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/improve-claude-distinct-relogin-guard-2026-06-29-08-34 --base main --via-pr --wait-for-merge --cleanup`.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/codex/improve-claude-distinct-relogin-guard-2026-06-29-08-34 --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`).
96 changes: 89 additions & 7 deletions src/commands/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@ import {
} from "../lib/cli/json-envelope";

const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts");
const CLAUDE_PARALLEL_BACKUP_DIR = path.join(os.homedir(), ".claude-accounts-backups");
const SKILL_PROFILE_FILE = ".authmux-skill-profile";
const CUE_PROFILE_FILE = ".authmux-cue-profile";
const DEFAULT_CUE_PROFILE = "core";
type ShellName = "bash" | "zsh" | "fish";
type LoginProfileOptions = {
fresh: boolean;
requireDistinct: boolean;
};

type AuthBackup = {
dir: string;
files: Array<{ name: string; source: string; backup: string }>;
};

interface ParallelProfileEntry {
name: string;
Expand Down Expand Up @@ -99,6 +109,44 @@ function claudeStatePathForProfile(name: string): string {
return path.join(CLAUDE_PARALLEL_DIR, name, ".claude.json");
}

function authFilePathsForProfile(name: string): Array<{ name: string; source: string }> {
return [
{ name: ".credentials.json", source: credentialsPathForProfile(name) },
{ name: ".claude.json", source: claudeStatePathForProfile(name) },
];
}

function backupTimestamp(): string {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.(\d{3})Z$/, "$1Z");
}

function backupProfileAuthFiles(name: string, reason: string): AuthBackup | undefined {
const files = authFilePathsForProfile(name).filter((file) => fs.existsSync(file.source));
if (!files.length) return undefined;

const backupDir = path.join(CLAUDE_PARALLEL_BACKUP_DIR, `${name}-${reason}-${backupTimestamp()}`);
fs.mkdirSync(backupDir, { recursive: true });
const backup: AuthBackup = { dir: backupDir, files: [] };

for (const file of files) {
const backupPath = path.join(backupDir, file.name);
fs.renameSync(file.source, backupPath);
backup.files.push({ ...file, backup: backupPath });
}

return backup;
}

function restoreProfileAuthBackup(backup: AuthBackup | undefined): void {
if (!backup) return;

for (const file of backup.files) {
if (fs.existsSync(file.source)) continue;
fs.mkdirSync(path.dirname(file.source), { recursive: true });
fs.renameSync(file.backup, file.source);
}
}

function hashFileIfPresent(file: string): string | undefined {
if (!fs.existsSync(file)) return undefined;
const hash = createHash("sha256");
Expand Down Expand Up @@ -159,6 +207,10 @@ export default class ClaudeParallel extends Command {
static flags = {
add: Flags.string({ description: "Add a new profile name" }),
login: Flags.string({ description: "Run Claude Code login inside a parallel profile" }),
fresh: Flags.boolean({ description: "Back up and remove existing Claude auth before --login" }),
"require-distinct": Flags.boolean({
description: "Fail --login if the result matches another Claude profile",
}),
remove: Flags.string({ description: "Remove a profile" }),
aliases: Flags.boolean({ description: "Print shell aliases for all profiles" }),
install: Flags.boolean({ description: "Install aliases into shell rc file" }),
Expand All @@ -181,6 +233,7 @@ export default class ClaudeParallel extends Command {
"agent-auth parallel --add frontend --skill-profile frontend",
"agent-auth parallel --add personal",
"agent-auth parallel --login work",
"agent-auth parallel --login work --fresh --require-distinct",
"agent-auth parallel --list",
"agent-auth parallel --aliases",
"agent-auth parallel --install",
Expand All @@ -196,7 +249,10 @@ export default class ClaudeParallel extends Command {
if (flags.add) {
this.addProfile(flags.add, flags["skill-profile"], flags["cue-profile"]);
} else if (flags.login) {
this.loginProfile(flags.login);
this.loginProfile(flags.login, {
fresh: Boolean(flags.fresh),
requireDistinct: Boolean(flags["require-distinct"]),
});
} else if (flags.remove) {
this.removeProfile(flags.remove);
} else if (flags.install) {
Expand Down Expand Up @@ -250,7 +306,7 @@ export default class ClaudeParallel extends Command {
this.log(`\nTo install shell aliases: agent-auth parallel --install`);
}

private loginProfile(name: string): void {
private loginProfile(name: string, options: LoginProfileOptions): void {
if (this.jsonMode) {
this.error("parallel --login is interactive and does not support --json.");
}
Expand All @@ -264,6 +320,11 @@ export default class ClaudeParallel extends Command {
this.log(` Config dir: ${dir}`);
}

const existingBackup = options.fresh ? backupProfileAuthFiles(name, "fresh-login") : undefined;
if (existingBackup) {
this.log(`Backed up existing Claude auth for "${name}" to ${existingBackup.dir}.`);
}

const credentialsPath = credentialsPathForProfile(name);
const before = fs.statSync(credentialsPath, { throwIfNoEntry: false })?.mtimeMs ?? 0;
const result = spawnSync("claude", ["login"], {
Expand All @@ -275,6 +336,7 @@ export default class ClaudeParallel extends Command {
});

if (result.error) {
restoreProfileAuthBackup(existingBackup);
const err = result.error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
this.error("`claude` CLI was not found in PATH. Install Claude Code first, then retry.");
Expand All @@ -283,14 +345,27 @@ export default class ClaudeParallel extends Command {
}

if (result.status !== 0) {
restoreProfileAuthBackup(existingBackup);
this.error(`\`claude login\` failed with exit code ${result.status ?? "unknown"}.`);
}

const after = fs.statSync(credentialsPath, { throwIfNoEntry: false });
if (!after) {
restoreProfileAuthBackup(existingBackup);
this.error(`Claude login completed but did not write ${credentialsPath}.`);
}

const duplicateEntry = buildProfileEntries().find((p) => p.name === name);
const duplicateOwner = duplicateEntry?.credentialsDuplicateOf ?? duplicateEntry?.claudeAccountDuplicateOf;
if (options.requireDistinct && duplicateOwner) {
const duplicateBackup = backupProfileAuthFiles(name, "duplicate-login");
this.error(
`Claude login for "${name}" produced the same account as "${duplicateOwner}". ` +
`The duplicate auth was moved to ${duplicateBackup?.dir ?? "a backup directory"}. ` +
"Run the login again and choose a different Anthropic account.",
);
}

const suffix = after.mtimeMs > before ? "refreshed" : "present";
this.log(`Claude credentials ${suffix} for "${name}" at ${credentialsPath}.`);
this.warnIfProfileDuplicates(name);
Expand Down Expand Up @@ -386,10 +461,17 @@ export default class ClaudeParallel extends Command {
" shift 3",
" local dir=\"$HOME/.claude-accounts/$name\"",
" command authmux skills activate \"$skill_profile\" --agent claude --target \"$dir/skills\" >/dev/null 2>&1 || true",
" if [ \"${1:-}\" = \"login\" ] || [ \"${1:-}\" = \"logout\" ] || [ ! -s \"$dir/.credentials.json\" ]; then",
" if [ \"${1:-}\" = \"login\" ]; then",
" command authmux parallel --login \"$name\" --fresh --require-distinct",
" return $?",
" fi",
" if [ \"${1:-}\" = \"logout\" ]; then",
" CLAUDE_CONFIG_DIR=\"$dir\" command claude \"$@\"",
" return $?",
" fi",
" if [ \"$#\" -eq 0 ] && [ ! -s \"$dir/.credentials.json\" ]; then",
" command authmux parallel --login \"$name\" --fresh --require-distinct || return $?",
" fi",
" if command -v cue >/dev/null 2>&1 && [ -z \"${AUTHMUX_SKIP_CUE_LAUNCH:-}\" ]; then",
// The sentinel `pick` means \"don't force a profile — open cue's selector\".
// Any other value is forced via --cue-profile (which suppresses the picker).
Expand Down Expand Up @@ -423,16 +505,16 @@ export default class ClaudeParallel extends Command {
" command authmux skills activate \"$skill_profile\" --agent claude --target \"$dir/skills\" >/dev/null 2>&1; or true",
" set -lx CLAUDE_CONFIG_DIR \"$dir\"",
" if set -q argv[1]; and test \"$argv[1]\" = login",
" command claude $argv",
" command authmux parallel --login \"$name\" --fresh --require-distinct",
" return $status",
" end",
" if set -q argv[1]; and test \"$argv[1]\" = logout",
" command claude $argv",
" return $status",
" end",
" if not test -s \"$dir/.credentials.json\"",
" command claude $argv",
" return $status",
" if not set -q argv[1]; and not test -s \"$dir/.credentials.json\"",
" command authmux parallel --login \"$name\" --fresh --require-distinct",
" or return $status",
" end",
" if command -q cue; and not set -q AUTHMUX_SKIP_CUE_LAUNCH",
cueProfile === "pick"
Expand Down
59 changes: 58 additions & 1 deletion src/tests/json-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,62 @@ test("parallel --list flags duplicate Claude credentials", async () => {
});
});

test("parallel --login can reject duplicate Claude accounts", 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 duplicateCredentials = JSON.stringify({
claudeAiOauth: {
accessToken: "same-access-token",
refreshToken: "same-refresh-token",
expiresAt: 1782742285132,
},
});
const duplicateClaudeState = JSON.stringify({
oauthAccount: {
accountUuid: "same-claude-account",
},
});
const account1Dir = path.join(accountsDir, "account1");
await fsp.writeFile(path.join(account1Dir, ".credentials.json"), duplicateCredentials);
await fsp.writeFile(path.join(account1Dir, ".claude.json"), duplicateClaudeState);

const fakeBin = path.join(env.HOME as string, "bin");
await fsp.mkdir(fakeBin, { recursive: true });
const fakeClaude = path.join(fakeBin, "claude");
await fsp.writeFile(
fakeClaude,
[
"#!/bin/sh",
"set -eu",
"test \"${1:-}\" = login",
"mkdir -p \"$CLAUDE_CONFIG_DIR\"",
`printf '%s' '${duplicateCredentials}' > "$CLAUDE_CONFIG_DIR/.credentials.json"`,
`printf '%s' '${duplicateClaudeState}' > "$CLAUDE_CONFIG_DIR/.claude.json"`,
].join("\n") + "\n",
);
await fsp.chmod(fakeClaude, 0o755);

const login = runCli(
["parallel", "--login", "account2", "--fresh", "--require-distinct"],
{
...env,
PATH: `${fakeBin}${path.delimiter}${env.PATH ?? ""}`,
},
);
assert.notEqual(login.status, 0);
assert.match(login.stderr, /same account[\s\S]*"account1"/);
await assert.rejects(
fsp.stat(path.join(accountsDir, "account2", ".credentials.json")),
/ENOENT/,
);
});
});

for (const tc of CASES) {
test(`--json parity: ${tc.name}`, async () => {
await withSandbox(async (env) => {
Expand Down Expand Up @@ -352,7 +408,7 @@ test("parallel aliases pass explicit cue profile per Claude account", async () =
);
assert.match(
parsedAliases.data.aliases,
/\[ "\$\{1:-\}" = "login" \]/,
/authmux parallel --login "\$name" --fresh --require-distinct/,
);
assert.match(
parsedAliases.data.aliases,
Expand Down Expand Up @@ -399,6 +455,7 @@ test("parallel install writes Fish functions with direct login refresh path", as
const body = await fsp.readFile(functionPath, "utf8");
assert.match(body, /set -lx CLAUDE_CONFIG_DIR "\$dir"/);
assert.match(body, /test "\$argv\[1\]" = login/);
assert.match(body, /authmux parallel --login "\$name" --fresh --require-distinct/);
assert.match(body, /not test -s "\$dir\/\.credentials\.json"/);
assert.match(body, /cue launch claude --cue-pick \$argv/);
assert.match(body, /command claude \$argv/);
Expand Down
Loading