From 17020a57ff5898238661b434e185e25c38ae037d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 29 Jun 2026 08:50:42 +0200 Subject: [PATCH] Guard Claude parallel relogins Constraint: keep auth backups outside ~/.claude-accounts and never print token data. Tested: npm test (198/198, unsandboxed because json-parity spawns Node children). --- README.md | 9 +- .../.openspec.yaml | 2 + .../notes.md | 16 ++++ src/commands/parallel.ts | 96 +++++++++++++++++-- src/tests/json-parity.test.ts | 59 +++++++++++- 5 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/.openspec.yaml create mode 100644 openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/notes.md diff --git a/README.md b/README.md index 61e83f0..4243609 100644 --- a/README.md +++ b/README.md @@ -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) @@ -241,6 +241,7 @@ source ~/.bashrc ```sh authmux parallel --add # Create a new profile authmux parallel --login # Refresh Claude OAuth inside that profile +authmux parallel --login --fresh --require-distinct # Reset stale Claude auth first and fail if it matches another profile authmux parallel --remove # Remove a profile authmux parallel --list # List all profiles authmux parallel --aliases # Print aliases (without installing) @@ -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/`. 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/`. 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 --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 ` 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 --fresh --require-distinct` and choose a different Anthropic account for any profile that should have its own subscription/session. ### Notes diff --git a/openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/.openspec.yaml b/openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/notes.md b/openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/notes.md new file mode 100644 index 0000000..8abf59e --- /dev/null +++ b/openspec/changes/agent-codex-improve-claude-distinct-relogin-guard-2026-06-29-08-34/notes.md @@ -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`). diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index c960f68..16ff2ac 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -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; @@ -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"); @@ -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" }), @@ -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", @@ -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) { @@ -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."); } @@ -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"], { @@ -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."); @@ -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); @@ -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). @@ -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" diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts index 82b2493..1f7dc8c 100644 --- a/src/tests/json-parity.test.ts +++ b/src/tests/json-parity.test.ts @@ -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) => { @@ -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, @@ -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/);