diff --git a/openspec/changes/agent-codex-fix-account-persistence-2026-06-09-10-05/.openspec.yaml b/openspec/changes/agent-codex-fix-account-persistence-2026-06-09-10-05/.openspec.yaml new file mode 100644 index 0000000..5735446 --- /dev/null +++ b/openspec/changes/agent-codex-fix-account-persistence-2026-06-09-10-05/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-09 diff --git a/openspec/changes/agent-codex-fix-account-persistence-2026-06-09-10-05/notes.md b/openspec/changes/agent-codex-fix-account-persistence-2026-06-09-10-05/notes.md new file mode 100644 index 0000000..d96dc6b --- /dev/null +++ b/openspec/changes/agent-codex-fix-account-persistence-2026-06-09-10-05/notes.md @@ -0,0 +1,20 @@ +# agent-codex-fix-account-persistence-2026-06-09-10-05 (minimal / T1) + +Branch: `agent/codex/fix-account-persistence-2026-06-09-10-05` + +Fix account isolation so per-terminal Codex sessions keep their selected authmux +account and Claude parallel aliases launch with per-account `CLAUDE_CONFIG_DIR` +plus an explicit Cue profile. + +## Handoff + +- Handoff: change=`agent-codex-fix-account-persistence-2026-06-09-10-05`; branch=`agent/codex/fix-account-persistence-2026-06-09-10-05`; scope=`src/commands/parallel.ts, src/lib/config/login-hook.ts, scripts/postinstall-login-hook.cjs, tests`; action=`finish via PR after verification`. +- Verification: `npm run build`; `node dist/tests/login-hook.test.js`; `node dist/tests/json-parity.test.js` (escalated because sandbox blocks child node spawn); live smoke `claude-account1 --version`, `claude-account2 --version`. +- Live config: backed up `/home/deadpool/.bashrc` to `/home/deadpool/.bashrc.authmux-backup-20260609-0814`, refreshed Claude aliases, and patched the active `codex()` shell wrapper to use `authmux` session restore/sync. +- Full suite note: `npm test` had 192/195 passing; only `skills-profile` tests failed because `/home/deadpool/Documents/soul` is absent in this environment. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/fix-account-persistence-2026-06-09-10-05 --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/scripts/postinstall-login-hook.cjs b/scripts/postinstall-login-hook.cjs index 7761608..276ead5 100644 --- a/scripts/postinstall-login-hook.cjs +++ b/scripts/postinstall-login-hook.cjs @@ -31,7 +31,21 @@ function renderHookBlock() { " [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout", " printf '\\033[>4m\\033[' >\"$__tty_target\" 2>/dev/null || true", "}", + "__authmux_ensure_session_key() {", + " if [[ -n \"${CODEX_AUTH_SESSION_KEY:-}\" ]]; then", + " return 0", + " fi", + " local __authmux_tty=\"${TTY:-}\"", + " if [[ -z \"$__authmux_tty\" ]] && command -v tty >/dev/null 2>&1; then", + " __authmux_tty=\"$(tty 2>/dev/null || true)\"", + " fi", + " if [[ -z \"$__authmux_tty\" || \"$__authmux_tty\" == \"not a tty\" ]]; then", + " __authmux_tty=\"ppid:${PPID:-$$}\"", + " fi", + " export CODEX_AUTH_SESSION_KEY=\"terminal:${__authmux_tty}:$$\"", + "}", "codex() {", + " __authmux_ensure_session_key", " if command -v authmux >/dev/null 2>&1; then", " command authmux restore-session >/dev/null 2>&1 || true", " command authmux skills activate-current --agent codex >/dev/null 2>&1 || true", @@ -119,7 +133,7 @@ async function maybeInstallHook() { } if (rc.includes(MARK_START) && rc.includes(MARK_END)) { - const refreshed = normalizeRcContents(rc.replace(hookBlockRegex(), `\n${renderHookBlock()}\n`)); + const refreshed = normalizeRcContents(rc.replace(hookBlockRegex(), () => `\n${renderHookBlock()}\n`)); if (refreshed !== normalizeRcContents(rc)) { await fs.writeFile(rcPath, refreshed, "utf8"); process.stdout.write(`\nUpdated shell hook in ${rcPath}. Restart terminal or run: source ${rcPath}\n`); diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index fc4282d..3746aa9 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -14,6 +14,8 @@ import { const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts"); const SKILL_PROFILE_FILE = ".authmux-skill-profile"; +const CUE_PROFILE_FILE = ".authmux-cue-profile"; +const DEFAULT_CUE_PROFILE = "core"; function getProfiles(): string[] { if (!fs.existsSync(CLAUDE_PARALLEL_DIR)) return []; @@ -45,6 +47,18 @@ function writeSkillProfile(name: string, skillProfile: string): void { fs.writeFileSync(file, `${skillProfile.trim()}\n`); } +function readCueProfile(name: string): string | undefined { + const file = path.join(CLAUDE_PARALLEL_DIR, name, CUE_PROFILE_FILE); + if (!fs.existsSync(file)) return undefined; + const profile = fs.readFileSync(file, "utf8").trim(); + return profile.length > 0 ? profile : undefined; +} + +function writeCueProfile(name: string, cueProfile: string): void { + const file = path.join(CLAUDE_PARALLEL_DIR, name, CUE_PROFILE_FILE); + fs.writeFileSync(file, `${cueProfile.trim()}\n`); +} + export default class ClaudeParallel extends Command { static description = "Manage parallel Claude Code accounts via CLAUDE_CONFIG_DIR"; @@ -55,6 +69,7 @@ export default class ClaudeParallel extends Command { install: Flags.boolean({ description: "Install aliases into shell rc file" }), list: Flags.boolean({ char: "l", description: "List profiles" }), "skill-profile": Flags.string({ description: "Soul skill profile for this Claude profile" }), + "cue-profile": Flags.string({ description: "Cue profile used by generated claude- aliases" }), json: Flags.boolean({ description: "Emit a single JSON envelope to stdout (Theme X4).", default: false, @@ -77,7 +92,7 @@ export default class ClaudeParallel extends Command { this.jsonMode = Boolean(flags.json); if (flags.add) { - this.addProfile(flags.add, flags["skill-profile"]); + this.addProfile(flags.add, flags["skill-profile"], flags["cue-profile"]); } else if (flags.remove) { this.removeProfile(flags.remove); } else if (flags.install) { @@ -89,7 +104,7 @@ export default class ClaudeParallel extends Command { } } - private addProfile(name: string, skillProfile?: string): void { + private addProfile(name: string, skillProfile?: string, cueProfile?: string): void { const dir = path.join(CLAUDE_PARALLEL_DIR, name); const existed = fs.existsSync(dir); if (!existed) { @@ -98,6 +113,10 @@ export default class ClaudeParallel extends Command { if (skillProfile) { writeSkillProfile(name, skillProfile); } + const resolvedCueProfile = cueProfile ?? readCueProfile(name) ?? skillProfile ?? DEFAULT_CUE_PROFILE; + if (cueProfile || !readCueProfile(name)) { + writeCueProfile(name, resolvedCueProfile); + } if (this.jsonMode) { writeJsonEnvelope(jsonSuccess({ @@ -106,6 +125,7 @@ export default class ClaudeParallel extends Command { dir, created: !existed, skillProfile: skillProfile ?? readSkillProfile(name) ?? "base", + cueProfile: resolvedCueProfile, })); return; } @@ -115,11 +135,13 @@ export default class ClaudeParallel extends Command { if (skillProfile) { this.log(` Skill profile: ${skillProfile}`); } + this.log(` Cue profile: ${resolvedCueProfile}`); return; } this.log(`Created profile: ${name}`); this.log(` Config dir: ${dir}`); this.log(` Skill profile: ${skillProfile ?? "base"}`); + this.log(` Cue profile: ${resolvedCueProfile}`); this.log(` Run: CLAUDE_CONFIG_DIR=${dir} claude`); this.log(`\nTo install shell aliases: agent-auth parallel --install`); } @@ -148,6 +170,7 @@ export default class ClaudeParallel extends Command { name: p, configDir: path.join(CLAUDE_PARALLEL_DIR, p), skillProfile: readSkillProfile(p) ?? "base", + cueProfile: readCueProfile(p) ?? readSkillProfile(p) ?? DEFAULT_CUE_PROFILE, })); if (this.jsonMode) { @@ -165,7 +188,7 @@ 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}`); + this.log(` • ${p.name} → ${p.configDir} skillProfile=${p.skillProfile} cueProfile=${p.cueProfile}`); } this.log(`\nRun any profile: claude- (after installing aliases)`); } @@ -175,18 +198,21 @@ export default class ClaudeParallel extends Command { if (!profiles.length) return ""; const lines = [ "# Claude Code parallel accounts (managed by agent-auth)", + "__authmux_claude_account() {", + " local name=\"$1\"", + ` local profile="\${2:-${DEFAULT_CUE_PROFILE}}"`, + " shift 2", + " local dir=\"$HOME/.claude-accounts/$name\"", + " command authmux skills activate \"$profile\" --agent claude --target \"$dir/skills\" >/dev/null 2>&1 || true", + " if command -v cue >/dev/null 2>&1 && [ -z \"${AUTHMUX_SKIP_CUE_LAUNCH:-}\" ]; then", + " CLAUDE_CONFIG_DIR=\"$dir\" cue launch claude --cue-profile \"$profile\" \"$@\"", + " else", + " CLAUDE_CONFIG_DIR=\"$dir\" command claude \"$@\"", + " fi", + "}", ...profiles.map((p) => { - const dir = path.join(CLAUDE_PARALLEL_DIR, p); - const profile = readSkillProfile(p) ?? "base"; - const activate = [ - "command authmux skills activate", - shellQuote(profile), - "--agent claude", - "--target", - shellQuote(path.join(dir, "skills")), - ">/dev/null 2>&1 || true", - ].join(" "); - return `alias claude-${p}="${activate}; CLAUDE_CONFIG_DIR=${shellQuote(dir)} command claude"`; + const profile = readCueProfile(p) ?? readSkillProfile(p) ?? DEFAULT_CUE_PROFILE; + return `alias claude-${p}="__authmux_claude_account ${shellQuote(p)} ${shellQuote(profile)}"`; }), ]; return lines.join("\n"); diff --git a/src/lib/config/login-hook.ts b/src/lib/config/login-hook.ts index 7c11dfa..310b793 100644 --- a/src/lib/config/login-hook.ts +++ b/src/lib/config/login-hook.ts @@ -47,7 +47,21 @@ export function renderLoginHookBlock(): string { " [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout", " printf '\\033[>4m\\033[' >\"$__tty_target\" 2>/dev/null || true", "}", + "__authmux_ensure_session_key() {", + " if [[ -n \"${CODEX_AUTH_SESSION_KEY:-}\" ]]; then", + " return 0", + " fi", + " local __authmux_tty=\"${TTY:-}\"", + " if [[ -z \"$__authmux_tty\" ]] && command -v tty >/dev/null 2>&1; then", + " __authmux_tty=\"$(tty 2>/dev/null || true)\"", + " fi", + " if [[ -z \"$__authmux_tty\" || \"$__authmux_tty\" == \"not a tty\" ]]; then", + " __authmux_tty=\"ppid:${PPID:-$$}\"", + " fi", + " export CODEX_AUTH_SESSION_KEY=\"terminal:${__authmux_tty}:$$\"", + "}", "codex() {", + " __authmux_ensure_session_key", " if command -v authmux >/dev/null 2>&1; then", " command authmux restore-session >/dev/null 2>&1 || true", " command authmux skills activate-current --agent codex >/dev/null 2>&1 || true", @@ -79,7 +93,7 @@ export async function installLoginHook(rcPath = resolveDefaultShellRcPath()): Pr if (existing.includes(LOGIN_HOOK_MARK_START) && existing.includes(LOGIN_HOOK_MARK_END)) { const refreshed = normalizeRcContents( - existing.replace(hookBlockRegex(), `\n${renderLoginHookBlock()}\n`), + existing.replace(hookBlockRegex(), () => `\n${renderLoginHookBlock()}\n`), ); if (refreshed === normalizeRcContents(existing)) { return "already-installed"; diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts index 348b0b5..e428b3c 100644 --- a/src/tests/json-parity.test.ts +++ b/src/tests/json-parity.test.ts @@ -250,3 +250,52 @@ for (const tc of CASES) { }); }); } + +test("parallel aliases pass explicit cue profile per Claude account", async () => { + await withSandbox(async (env) => { + const addDefault = runCli( + ["parallel", "--add", "account1", "--json"], + env, + ); + assert.equal(addDefault.status, 0, addDefault.stderr); + + const parsedDefault = JSON.parse(addDefault.stdout.trim()) as { + ok: true; + data: { profile: string; skillProfile: string; cueProfile: string }; + }; + assert.equal(parsedDefault.data.profile, "account1"); + assert.equal(parsedDefault.data.skillProfile, "base"); + assert.equal(parsedDefault.data.cueProfile, "core"); + + const add = runCli( + ["parallel", "--add", "account2", "--skill-profile", "frontend", "--json"], + env, + ); + assert.equal(add.status, 0, add.stderr); + + const parsedAdd = JSON.parse(add.stdout.trim()) as { + ok: true; + data: { profile: string; skillProfile: string; cueProfile: string }; + }; + assert.equal(parsedAdd.data.profile, "account2"); + assert.equal(parsedAdd.data.skillProfile, "frontend"); + assert.equal(parsedAdd.data.cueProfile, "frontend"); + + const aliases = runCli(["parallel", "--aliases", "--json"], env); + assert.equal(aliases.status, 0, aliases.stderr); + + const parsedAliases = JSON.parse(aliases.stdout.trim()) as { + ok: true; + data: { aliases: string }; + }; + assert.match(parsedAliases.data.aliases, /__authmux_claude_account\(\)/); + assert.match( + parsedAliases.data.aliases, + /cue launch claude --cue-profile "\$profile"/, + ); + assert.match( + parsedAliases.data.aliases, + /alias claude-account2="__authmux_claude_account 'account2' 'frontend'"/, + ); + }); +}); diff --git a/src/tests/login-hook.test.ts b/src/tests/login-hook.test.ts index f8fc840..3bb2148 100644 --- a/src/tests/login-hook.test.ts +++ b/src/tests/login-hook.test.ts @@ -68,6 +68,8 @@ test("installLoginHook refreshes an existing legacy hook block", async (t) => { assert.ok(contents.includes("command authmux restore-session")); assert.ok(contents.includes("command authmux skills activate-current --agent codex")); assert.ok(contents.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command authmux status")); + assert.ok(contents.includes("__authmux_ensure_session_key")); + assert.ok(contents.includes("export CODEX_AUTH_SESSION_KEY=\"terminal:${__authmux_tty}:$$\"")); assert.ok(!contents.includes("# legacy")); const startCount = contents.split(LOGIN_HOOK_MARK_START).length - 1; assert.equal(startCount, 1); @@ -109,7 +111,9 @@ test("getLoginHookStatus reflects installed state", async (t) => { test("renderLoginHookBlock includes terminal-mode restore guard", () => { const hook = renderLoginHookBlock(); assert.ok(hook.includes("__codex_auth_restore_tty")); + assert.ok(hook.includes("__authmux_ensure_session_key")); assert.ok(hook.includes("codex() {")); + assert.ok(hook.includes("__authmux_ensure_session_key")); assert.ok(hook.includes("command authmux restore-session")); assert.ok(hook.includes("command authmux skills activate-current --agent codex")); assert.ok(hook.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command authmux status"));