diff --git a/README.md b/README.md index 0c7ede9..62404b9 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ 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 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. +Each profile gets its own config directory at `~/.claude-accounts/`. Shell aliases/functions launch Claude from a per-session copy of that profile, so already-open sessions keep their original credentials even after a later `/login`. If a session refreshes login credentials, Authmux syncs the changed Claude auth files back to `~/.claude-accounts/` so future `claude-` launches use the new login. `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 fresh 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. If `.claude.json` is older than the active credentials, Authmux marks it as `claudeAccountState=stale` and does not use that stale UUID for duplicate-account detection. diff --git a/openspec/changes/agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49/.openspec.yaml b/openspec/changes/agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49/notes.md b/openspec/changes/agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49/notes.md new file mode 100644 index 0000000..47b6c68 --- /dev/null +++ b/openspec/changes/agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49/notes.md @@ -0,0 +1,22 @@ +# agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49 (minimal / T1) + +Branch: `agent/codex/preserve-claude-login-credentials-for-fu-2026-06-29-22-49` + +Preserve live Claude parallel sessions by launching them from per-session config copies, while syncing changed login credentials back to the canonical profile for future launches. + +## Handoff + +- Handoff: change=`agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49`; branch=`agent/codex/preserve-claude-login-credentials-for-fu-2026-06-29-22-49`; scope=`src/commands/parallel.ts src/tests/json-parity.test.ts README.md`; action=`add session-isolated Claude parallel launcher and finish via PR`. +- Copy prompt: Continue `agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49` on branch `agent/codex/preserve-claude-login-credentials-for-fu-2026-06-29-22-49`. Work inside the existing sandbox, review `openspec/changes/agent-codex-preserve-claude-login-credentials-for-fu-2026-06-29-22-49/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/preserve-claude-login-credentials-for-fu-2026-06-29-22-49 --base main --via-pr --wait-for-merge --cleanup`. + +## Verification + +- `npm run build` passed. +- `node dist/tests/json-parity.test.js` passed; sandboxed run hit `spawnSync node EPERM`, so verification ran with Guardex-approved escalation. +- `npm test` passed: 200 tests. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/preserve-claude-login-credentials-for-fu-2026-06-29-22-49 --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 6ce59f3..a2513fc 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -4,7 +4,7 @@ // with BaseCommand commands. import { Command, Flags } from "@oclif/core"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -16,9 +16,11 @@ import { const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts"); const CLAUDE_PARALLEL_BACKUP_DIR = path.join(os.homedir(), ".claude-accounts-backups"); +const CLAUDE_PARALLEL_SESSION_DIR = path.join(os.homedir(), ".claude-accounts-sessions"); const SKILL_PROFILE_FILE = ".authmux-skill-profile"; const CUE_PROFILE_FILE = ".authmux-cue-profile"; const DEFAULT_CUE_PROFILE = "core"; +const SESSION_SYNC_INTERVAL_MS = 1_000; type ShellName = "bash" | "zsh" | "fish"; type LoginProfileOptions = { fresh: boolean; @@ -30,6 +32,16 @@ type AuthBackup = { files: Array<{ name: string; source: string; backup: string }>; }; +type AuthIdentity = { + credentialsHash?: string; + claudeAccountUuid?: string; +}; + +type LaunchCommand = { + command: string; + args: string[]; +}; + interface ParallelProfileEntry { name: string; configDir: string; @@ -102,12 +114,24 @@ function writeCueProfile(name: string, cueProfile: string): void { fs.writeFileSync(file, `${cueProfile.trim()}\n`); } +function profileDirFor(name: string): string { + return path.join(CLAUDE_PARALLEL_DIR, name); +} + +function credentialsPathForDir(dir: string): string { + return path.join(dir, ".credentials.json"); +} + function credentialsPathForProfile(name: string): string { - return path.join(CLAUDE_PARALLEL_DIR, name, ".credentials.json"); + return credentialsPathForDir(profileDirFor(name)); +} + +function claudeStatePathForDir(dir: string): string { + return path.join(dir, ".claude.json"); } function claudeStatePathForProfile(name: string): string { - return path.join(CLAUDE_PARALLEL_DIR, name, ".claude.json"); + return claudeStatePathForDir(profileDirFor(name)); } function authFilePathsForProfile(name: string): Array<{ name: string; source: string }> { @@ -148,6 +172,40 @@ function restoreProfileAuthBackup(backup: AuthBackup | undefined): void { } } +function copyAuthFiles(fromDir: string, toDir: string): void { + fs.mkdirSync(toDir, { recursive: true }); + for (const file of [".credentials.json", ".claude.json"]) { + const source = path.join(fromDir, file); + const destination = path.join(toDir, file); + if (fs.existsSync(source)) { + fs.copyFileSync(source, destination); + } else { + fs.rmSync(destination, { force: true }); + } + } +} + +function commandExists(command: string): boolean { + const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + const extensions = process.platform === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") + : [""]; + + return pathEntries.some((entry) => + extensions.some((extension) => fs.existsSync(path.join(entry, `${command}${extension}`))), + ); +} + +function createProfileSessionDir(name: string): string { + const sessionDir = path.join(CLAUDE_PARALLEL_SESSION_DIR, name, `${backupTimestamp()}-${process.pid}`); + fs.mkdirSync(path.dirname(sessionDir), { recursive: true }); + return sessionDir; +} + +function copyProfileToSession(profileDir: string, sessionDir: string): void { + fs.cpSync(profileDir, sessionDir, { recursive: true }); +} + function hashFileIfPresent(file: string): string | undefined { if (!fs.existsSync(file)) return undefined; const hash = createHash("sha256"); @@ -159,11 +217,11 @@ function statFileIfPresent(file: string): fs.Stats | undefined { return fs.statSync(file, { throwIfNoEntry: false }) ?? undefined; } -function readClaudeAccountUuid(name: string, credentialsMtimeMs?: number): { +function readClaudeAccountUuidFromDir(dir: string, credentialsMtimeMs?: number): { uuid?: string; stale: boolean; } { - const file = claudeStatePathForProfile(name); + const file = claudeStatePathForDir(dir); const stateStat = statFileIfPresent(file); if (!stateStat) return { stale: false }; @@ -185,6 +243,72 @@ function readClaudeAccountUuid(name: string, credentialsMtimeMs?: number): { } } +function readClaudeAccountUuid(name: string, credentialsMtimeMs?: number): { + uuid?: string; + stale: boolean; +} { + return readClaudeAccountUuidFromDir(profileDirFor(name), credentialsMtimeMs); +} + +function readAuthIdentity(dir: string): AuthIdentity { + const credentialsPath = credentialsPathForDir(dir); + const credentialsStat = statFileIfPresent(credentialsPath); + const claudeAccount = readClaudeAccountUuidFromDir(dir, credentialsStat?.mtimeMs); + return { + credentialsHash: credentialsStat ? hashFileIfPresent(credentialsPath) : undefined, + claudeAccountUuid: claudeAccount.uuid, + }; +} + +function authFileHashes(dir: string): Map { + return new Map( + [".credentials.json", ".claude.json"].map((file) => [ + file, + hashFileIfPresent(path.join(dir, file)), + ]), + ); +} + +function authHashesChanged( + previous: Map, + current: Map, +): boolean { + for (const [file, hash] of current) { + if (previous.get(file) !== hash) return true; + } + return false; +} + +function shouldSyncSessionAuth(profileDir: string, sessionDir: string, initialIdentity: AuthIdentity): boolean { + const sessionIdentity = readAuthIdentity(sessionDir); + const profileIdentity = readAuthIdentity(profileDir); + + if ( + sessionIdentity.claudeAccountUuid && + initialIdentity.claudeAccountUuid && + sessionIdentity.claudeAccountUuid !== initialIdentity.claudeAccountUuid + ) { + return true; + } + + if (!profileIdentity.claudeAccountUuid || !initialIdentity.claudeAccountUuid) { + return true; + } + + return ( + profileIdentity.claudeAccountUuid === initialIdentity.claudeAccountUuid || + profileIdentity.claudeAccountUuid === sessionIdentity.claudeAccountUuid + ); +} + +function syncSessionAuthToProfile(profileDir: string, sessionDir: string, initialIdentity: AuthIdentity): boolean { + if (!shouldSyncSessionAuth(profileDir, sessionDir, initialIdentity)) { + return false; + } + copyAuthFiles(sessionDir, profileDir); + return true; +} + function duplicateOwner( value: T, seen: Map, @@ -222,10 +346,12 @@ function buildProfileEntries(profiles = getProfiles()): ParallelProfileEntry[] { export default class ClaudeParallel extends Command { static description = "Manage parallel Claude Code accounts via CLAUDE_CONFIG_DIR"; + static strict = false; static flags = { add: Flags.string({ description: "Add a new profile name" }), login: Flags.string({ description: "Run Claude Code login inside a parallel profile" }), + run: Flags.string({ description: "Run Claude Code with a session-isolated 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", @@ -253,6 +379,7 @@ export default class ClaudeParallel extends Command { "agent-auth parallel --add personal", "agent-auth parallel --login work", "agent-auth parallel --login work --fresh --require-distinct", + "agent-auth parallel --run work -- --model opus", "agent-auth parallel --list", "agent-auth parallel --aliases", "agent-auth parallel --install", @@ -261,7 +388,7 @@ export default class ClaudeParallel extends Command { private jsonMode = false; async run(): Promise { - const { flags } = await this.parse(ClaudeParallel); + const { flags, argv } = await this.parse(ClaudeParallel); this.jsonMode = Boolean(flags.json); const shell = resolveShellName(flags.shell); @@ -272,6 +399,11 @@ export default class ClaudeParallel extends Command { fresh: Boolean(flags.fresh), requireDistinct: Boolean(flags["require-distinct"]), }); + } else if (flags.run) { + await this.runProfile(flags.run, { + cueProfile: flags["cue-profile"], + args: argv as string[], + }); } else if (flags.remove) { this.removeProfile(flags.remove); } else if (flags.install) { @@ -330,7 +462,7 @@ export default class ClaudeParallel extends Command { this.error("parallel --login is interactive and does not support --json."); } - const dir = path.join(CLAUDE_PARALLEL_DIR, name); + const dir = profileDirFor(name); const existed = fs.existsSync(dir); if (!existed) { fs.mkdirSync(dir, { recursive: true }); @@ -391,7 +523,7 @@ export default class ClaudeParallel extends Command { } private removeProfile(name: string): void { - const dir = path.join(CLAUDE_PARALLEL_DIR, name); + const dir = profileDirFor(name); if (!fs.existsSync(dir)) { this.error(`Profile "${name}" not found.`); } @@ -469,6 +601,89 @@ export default class ClaudeParallel extends Command { } } + private resolveLaunchCommand(cueProfile: string, args: string[]): LaunchCommand { + if (commandExists("cue") && !process.env.AUTHMUX_SKIP_CUE_LAUNCH) { + if (cueProfile === "pick") { + return { + command: "cue", + args: ["launch", "claude", "--cue-pick", ...args], + }; + } + return { + command: "cue", + args: ["launch", "claude", "--cue-profile", cueProfile, ...args], + }; + } + + return { + command: "claude", + args, + }; + } + + private async runProfile( + name: string, + options: { cueProfile?: string; args: string[] }, + ): Promise { + if (this.jsonMode) { + this.error("parallel --run is interactive and does not support --json."); + } + + const profileDir = profileDirFor(name); + if (!fs.existsSync(profileDir)) { + this.error(`Profile "${name}" not found.`); + } + + const sessionDir = createProfileSessionDir(name); + copyProfileToSession(profileDir, sessionDir); + const initialIdentity = readAuthIdentity(sessionDir); + let lastHashes = authFileHashes(sessionDir); + const cueProfile = options.cueProfile ?? readCueProfile(name) ?? DEFAULT_CUE_PROFILE; + const launch = this.resolveLaunchCommand(cueProfile, options.args); + + const syncIfChanged = (): void => { + const currentHashes = authFileHashes(sessionDir); + if (!authHashesChanged(lastHashes, currentHashes)) return; + syncSessionAuthToProfile(profileDir, sessionDir, initialIdentity); + lastHashes = currentHashes; + }; + + let interval: NodeJS.Timeout | undefined; + try { + const child = spawn(launch.command, launch.args, { + stdio: "inherit", + env: { + ...process.env, + CLAUDE_CONFIG_DIR: sessionDir, + }, + }); + + interval = setInterval(syncIfChanged, SESSION_SYNC_INTERVAL_MS); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.on("error", reject); + child.on("close", (code, signal) => resolve({ code, signal })); + }); + + syncIfChanged(); + + if (result.signal) { + this.error(`${launch.command} was terminated by signal ${result.signal}.`); + } + if (result.code && result.code !== 0) { + this.exit(result.code); + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + this.error(`\`${launch.command}\` was not found in PATH.`); + } + throw error; + } finally { + if (interval) clearInterval(interval); + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + } + private generateBashAliases(): string { const profiles = getProfiles(); if (!profiles.length) return ""; @@ -492,17 +707,7 @@ export default class ClaudeParallel extends Command { " 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). - " if [ \"$cue_profile\" = \"pick\" ]; then", - " CLAUDE_CONFIG_DIR=\"$dir\" cue launch claude --cue-pick \"$@\"", - " else", - " CLAUDE_CONFIG_DIR=\"$dir\" cue launch claude --cue-profile \"$cue_profile\" \"$@\"", - " fi", - " else", - " CLAUDE_CONFIG_DIR=\"$dir\" command claude \"$@\"", - " fi", + " command authmux parallel --run \"$name\" --cue-profile \"$cue_profile\" -- \"$@\"", "}", ...profiles.map((p) => { const skillProfile = readSkillProfile(p) ?? "base"; @@ -536,13 +741,7 @@ export default class ClaudeParallel extends Command { " 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" - ? " cue launch claude --cue-pick $argv" - : " cue launch claude --cue-profile \"$cue_profile\" $argv", - " else", - " command claude $argv", - " end", + " command authmux parallel --run \"$name\" --cue-profile \"$cue_profile\" -- $argv", "end", ]; return lines.join("\n"); diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts index 869b486..0d0d4b8 100644 --- a/src/tests/json-parity.test.ts +++ b/src/tests/json-parity.test.ts @@ -345,6 +345,81 @@ test("parallel --login can reject duplicate Claude accounts", async () => { }); }); +test("parallel --run isolates live Claude sessions and syncs changed login credentials", async () => { + await withSandbox(async (env) => { + const added = runCli(["parallel", "--add", "account2", "--json"], env); + assert.equal(added.status, 0, added.stderr); + + const accountsDir = path.join(env.HOME as string, ".claude-accounts"); + const accountDir = path.join(accountsDir, "account2"); + const oldCredentials = JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access-token", + refreshToken: "old-refresh-token", + }, + }); + const oldClaudeState = JSON.stringify({ + oauthAccount: { + accountUuid: "old-claude-account", + }, + }); + const newCredentials = JSON.stringify({ + claudeAiOauth: { + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + }, + }); + const newClaudeState = JSON.stringify({ + oauthAccount: { + accountUuid: "new-claude-account", + }, + }); + + await fsp.writeFile(path.join(accountDir, ".credentials.json"), oldCredentials); + await fsp.writeFile(path.join(accountDir, ".claude.json"), oldClaudeState); + + const fakeBin = path.join(env.HOME as string, "bin"); + await fsp.mkdir(fakeBin, { recursive: true }); + const seenConfigDir = path.join(env.HOME as string, "seen-config-dir.txt"); + const fakeClaude = path.join(fakeBin, "claude"); + await fsp.writeFile( + fakeClaude, + [ + "#!/bin/sh", + "set -eu", + "test \"${1:-}\" = \"--probe-arg\"", + "test \"$CLAUDE_CONFIG_DIR\" != \"$CANONICAL_DIR\"", + "grep -q old-access-token \"$CLAUDE_CONFIG_DIR/.credentials.json\"", + "printf '%s' \"$CLAUDE_CONFIG_DIR\" > \"$SEEN_CONFIG_DIR\"", + `printf '%s' '${newCredentials}' > "$CLAUDE_CONFIG_DIR/.credentials.json"`, + `printf '%s' '${newClaudeState}' > "$CLAUDE_CONFIG_DIR/.claude.json"`, + ].join("\n") + "\n", + ); + await fsp.chmod(fakeClaude, 0o755); + + const run = runCli( + ["parallel", "--run", "account2", "--", "--probe-arg"], + { + ...env, + AUTHMUX_SKIP_CUE_LAUNCH: "1", + CANONICAL_DIR: accountDir, + PATH: `${fakeBin}${path.delimiter}${env.PATH ?? ""}`, + SEEN_CONFIG_DIR: seenConfigDir, + }, + ); + assert.equal(run.status, 0, run.stderr); + + const seen = await fsp.readFile(seenConfigDir, "utf8"); + assert.notEqual(seen, accountDir); + assert.ok( + seen.startsWith(path.join(env.HOME as string, ".claude-accounts-sessions", "account2")), + `expected session config dir, got ${seen}`, + ); + assert.equal(await fsp.readFile(path.join(accountDir, ".credentials.json"), "utf8"), newCredentials); + assert.equal(await fsp.readFile(path.join(accountDir, ".claude.json"), "utf8"), newClaudeState); + }); +}); + for (const tc of CASES) { test(`--json parity: ${tc.name}`, async () => { await withSandbox(async (env) => { @@ -461,7 +536,7 @@ test("parallel aliases pass explicit cue profile per Claude account", async () = ); assert.match( parsedAliases.data.aliases, - /cue launch claude --cue-profile "\$cue_profile"/, + /authmux parallel --run "\$name" --cue-profile "\$cue_profile" -- "\$@"/, ); assert.match( parsedAliases.data.aliases, @@ -470,7 +545,7 @@ test("parallel aliases pass explicit cue profile per Claude account", async () = }); }); -test("parallel install writes Fish functions with direct login refresh path", async () => { +test("parallel install writes Fish functions with isolated run path", async () => { await withSandbox(async (env) => { const addDefault = runCli( ["parallel", "--add", "account1", "--cue-profile", "pick", "--json"], @@ -506,7 +581,7 @@ test("parallel install writes Fish functions with direct login refresh path", as 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, /authmux parallel --run "\$name" --cue-profile "\$cue_profile" -- \$argv/); assert.match(body, /command claude \$argv/); }); });