// Codex account rotation: the auth.json adapter (server/codex-login.js), the // shared registry with cli 'codex' (server/account-rotation.js), Codex reset // times, `codex resume ` restarts and the outside-process check. Temp homes // with fake auth.json files only; `codex` is a stub, never the real CLI. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync, symlinkSync, lstatSync, readdirSync, existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { captureCodexLogin, activateCodexLogin, codexIdentity, stopCodexDaemon } from '../server/codex-login.js'; import { addRotationAccount, configureRotation, rotateAccount, recoverRotation, publicRotationState, rotationState, retryAt, RETRY_MS, rotationOn } from '../server/account-rotation.js'; import { resetInFlight } from '../server/account-migration.js'; import { resumeCodexCommand, withClaudeSessionsStopped } from '../server/claude-rotation-sessions.js'; import { externalClaudePids, assertClaudeProcessesManaged, isCodexDaemon } from '../server/claude-processes.js'; import { parseCommand } from '../lib/helpers.js'; import { removeTempDir } from './temp-dir.js'; const jwt = claims => `h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`; const auth = (name, refresh = `refresh-${name}`) => JSON.stringify({ OPENAI_API_KEY: null, tokens: { id_token: jwt({ email: `${name}@example.com`, 'https://api.openai.com/auth': { chatgpt_account_id: `acct-${name}`, chatgpt_user_id: `user-${name}` } }), access_token: `access-${name}`, refresh_token: refresh, account_id: `acct-${name}` }, last_refresh: '2026-10-01T00:00:00Z', }); const POSIX = process.platform !== 'win32'; let home, dir, env, run, calls; const write = (folder, text) => { mkdirSync(join(home, folder), { recursive: true }); writeFileSync(join(home, folder, 'auth.json'), text, { mode: 0o600 }); }; const live = () => readFileSync(join(home, '.codex', 'auth.json'), 'utf8'); const deps = over => ({ cli: 'codex', dir, home, env, run, platform: process.platform, now: () => 1_000_000, ...over }); beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'codex-rotation-')); dir = join(home, '.agent-007'); env = {}; // no CODEX_HOME: the default is /.codex calls = []; // `codex login status`: logged in when the folder's auth.json holds a refresh token. run = vi.fn(async (file, args, { env: e }) => { calls.push([file, ...args]); try { return { code: JSON.parse(readFileSync(join(e.CODEX_HOME, 'auth.json'), 'utf8')).tokens.refresh_token ? 0 : 1, stdout: '', stderr: '' }; } catch { return { code: 1, stdout: '', stderr: '' }; } }); write('.codex', auth('a')); write('.codex-b', auth('b')); write('.codex-c', auth('c')); resetInFlight(); }); afterEach(() => removeTempDir(home)); describe('Codex login snapshots', () => { it('reads the account id and email from the token, keeping Team members of one workspace apart', () => { expect(codexIdentity(auth('a'))).toEqual({ accountId: 'acct-a:user-a', email: 'a@example.com' }); expect(() => codexIdentity(JSON.stringify({ OPENAI_API_KEY: 'sk-x', tokens: null }))).toThrow(/Not a ChatGPT login/); }); it('captures the default home for null and a folder by absolute path, and refuses a logged-out one', async () => { expect(await captureCodexLogin(null, deps())).toMatchObject({ email: 'a@example.com', folder: join(home, '.codex'), secret: auth('a') }); expect((await captureCodexLogin(join(home, '.codex-b'), deps())).email).toBe('b@example.com'); await expect(captureCodexLogin('relative/.codex-b', deps())).rejects.toThrow(/absolute/); write('.codex-x', JSON.stringify({ tokens: { id_token: jwt({ email: 'x@example.com' }), account_id: 'x', refresh_token: '' } })); await expect(captureCodexLogin(join(home, '.codex-x'), deps())).rejects.toThrow(); // The secret never reaches a command line. expect(JSON.stringify(calls)).not.toContain('refresh-'); }); it('activates atomically, 0600, through a symlinked auth.json, and verifies the result', async () => { const real = join(home, 'elsewhere', 'auth.json'); mkdirSync(join(home, 'elsewhere')); writeFileSync(real, auth('a')); const link = join(home, '.codex', 'auth.json'); removeTempDir(link); symlinkSync(real, link); await activateCodexLogin(await captureCodexLogin(join(home, '.codex-b'), deps()), deps()); expect(lstatSync(link).isSymbolicLink()).toBe(true); expect(readFileSync(real, 'utf8')).toBe(auth('b')); if (POSIX) expect(statSync(real).mode & 0o777).toBe(0o600); expect(readdirSync(join(home, 'elsewhere'))).toEqual(['auth.json']); // no tmp left behind }); it('refuses to activate an incomplete snapshot or without a default login', async () => { await expect(activateCodexLogin({ email: 'b@example.com' }, deps())).rejects.toThrow(/incomplete/); removeTempDir(join(home, '.codex', 'auth.json')); await expect(activateCodexLogin({ secret: auth('b'), accountId: 'acct-b:user-b', email: 'b@example.com' }, deps())).rejects.toThrow(/missing/); }); it('stops the background server with `codex app-server daemon stop` and reports a failure', async () => { const stub = vi.fn(async () => ({ code: 0 })); await stopCodexDaemon({ run: stub }); expect(stub).toHaveBeenCalledWith('codex', ['app-server', 'daemon', 'stop'], expect.anything()); await expect(stopCodexDaemon({ run: async () => ({ code: 1 }) })).rejects.toThrow(/No account was switched/); }); }); describe('Codex account rotation', () => { const addAll = async () => { await addRotationAccount(join(home, '.codex-b'), deps()); await addRotationAccount(join(home, '.codex-c'), deps()); await configureRotation({ enabled: true, accounts: publicRotationState(dir, undefined, 'codex').accounts.map(a => ({ id: a.id, enabled: true })) }, deps()); }; it('keeps its own registry next to Claude\'s, auto-on at two accounts, with 0600 snapshots and no secrets public', async () => { await addRotationAccount(join(home, '.codex-b'), deps()); expect(existsSync(join(dir, 'codex-account-rotation.json'))).toBe(true); expect(existsSync(join(dir, 'account-rotation.json'))).toBe(false); expect(rotationState(dir).accounts).toEqual([]); const s = rotationState(dir, 'codex'); expect(s.enabled).toBe(true); expect(s.accounts.map(a => a.email)).toEqual(['a@example.com', 'b@example.com']); for (const f of readdirSync(join(dir, 'account-logins'))) if (POSIX) expect(statSync(join(dir, 'account-logins', f)).mode & 0o777).toBe(0o600); if (POSIX) expect(statSync(join(dir, 'account-logins')).mode & 0o777).toBe(0o700); const pub = JSON.stringify(publicRotationState(dir, undefined, 'codex')); expect(pub).not.toMatch(/refresh-|access-|secret|id_token/); }); it('rotates A -> B -> C -> A, saving each account\'s refreshed auth.json back first, never a stale source copy', async () => { await addAll(); write('.codex', auth('a', 'refresh-a-2')); // Codex refreshed A while it was active expect(await rotateAccount({}, deps())).toMatchObject({ ok: true, oldEmail: 'a@example.com', newEmail: 'b@example.com' }); expect(live()).toBe(auth('b')); write('.codex', auth('b', 'refresh-b-2')); expect(await rotateAccount({}, deps())).toMatchObject({ ok: true, newEmail: 'c@example.com' }); await addRotationAccount(join(home, '.codex-b'), deps()); // rediscovery must not restore B's stale token expect(await rotateAccount({}, deps())).toMatchObject({ ok: true, newEmail: 'a@example.com' }); expect(live()).toBe(auth('a', 'refresh-a-2')); expect(await rotateAccount({}, deps())).toMatchObject({ ok: true, newEmail: 'b@example.com' }); expect(live()).toBe(auth('b', 'refresh-b-2')); // The workspace stays put: only auth.json changed. expect(readdirSync(join(home, '.codex'))).toEqual(['auth.json']); }); it('refuses when the default login changed outside the app, and leaves it alone', async () => { await addAll(); write('.codex', auth('z')); expect(await rotateAccount({}, deps())).toMatchObject({ blocked: true, error: expect.stringContaining('default Codex login changed') }); expect(live()).toBe(auth('z')); }); it('marks the limited account until the reset Codex printed, then is exhausted until then', async () => { await addRotationAccount(join(home, '.codex-b'), deps()); const now = new Date(2026, 9, 8, 12, 0).getTime(); const at = deps({ now: () => now }); expect(await rotateAccount({ limited: true, line: "You've hit your usage limit. … or try again at Oct 25th, 2026 3:15 PM." }, at)).toMatchObject({ ok: true }); const a = rotationState(dir, 'codex').accounts.find(x => x.email === 'a@example.com'); expect(a.limitedUntil).toBe(new Date(2026, 9, 25, 15, 15).getTime()); // B gives no reset: the 30-minute delay, the earliest the pool is retried. expect(await rotateAccount({ limited: true, line: "You've hit your usage limit." }, at)).toMatchObject({ exhausted: true, retryAt: now + RETRY_MS }); expect(rotationState(dir, 'codex').accounts.find(x => x.email === 'b@example.com').limitedUntil).toBe(now + RETRY_MS); }); // Value: protects=the real adapter's own verify failure (login status rejects the written auth.json) rolls back to the previous login; // fails_when=activateCodexLogin stops checking `codex login status` after the write, or rollback rewrites the wrong login; // why_new=the rollback test below throws from a replaced activate, never from the real adapter; seam=none it('rolls back auth.json when codex login status rejects the newly written login', async () => { await addAll(); const rejectB = vi.fn(async (_f, _a, { env: e }) => ({ code: readFileSync(join(e.CODEX_HOME, 'auth.json'), 'utf8').includes('refresh-b') ? 1 : 0, stdout: '', stderr: '' })); expect(await rotateAccount({}, deps({ run: rejectB }))).toMatchObject({ retry: true, error: expect.stringContaining('previous login was restored') }); expect(live()).toBe(auth('a')); expect(rotationState(dir, 'codex').accounts.find(a => a.email === 'b@example.com').error).toMatch(/Sign in again/); expect(readdirSync(join(home, '.codex')).filter(f => f.endsWith('.tmp'))).toEqual([]); }); it('rolls back to the previous login when the new one does not verify, and recovers an interrupted switch', async () => { await addAll(); const failing = deps({ activate: async s => { if (s.email === 'b@example.com') throw Error('token details'); return activateCodexLogin(s, deps()); } }); expect(await rotateAccount({ id: rotationState(dir, 'codex').accounts[1].id }, failing)).toMatchObject({ retry: true, error: expect.stringContaining('previous login was restored') }); expect(live()).toBe(auth('a')); const s = rotationState(dir, 'codex'); writeFileSync(join(dir, 'codex-account-rotation.json'), JSON.stringify({ ...s, pending: { from: s.active, to: s.accounts[1].id } })); write('.codex', auth('b')); expect(await recoverRotation(undefined, deps())).toMatchObject({ ok: true }); expect(live()).toBe(auth('a')); expect(rotationState(dir, 'codex')).toMatchObject({ pending: null, enabled: false }); }); }); // Settings shows one list: one switch and one order over both registries. describe('one account list for Claude and Codex', () => { // Claude logins are in-memory fixtures here; no real ~/.claude* is read. const claudeLogin = name => ({ email: `${name}@example.com`, fields: { oauthAccount: { accountUuid: name, emailAddress: `${name}@example.com` } }, secret: `secret-${name}`, folder: `/claude-${name}` }); const claudeDeps = () => ({ dir, now: () => 1_000_000, capture: async folder => claudeLogin(folder === null ? 'x' : folder.slice(1)), activate: async () => {} }); const ids = cli => publicRotationState(dir, undefined, cli).accounts; it('turns on by default at two selected accounts counted across both CLIs', async () => { await addRotationAccount('/x', claudeDeps()); // one Claude login: the default x expect(rotationOn(dir)).toBe(false); await addRotationAccount(join(home, '.codex'), deps()); // one Codex login: a expect(rotationOn(dir)).toBe(true); // Each CLI's pool reads its own flag, so both registries say on. expect(rotationState(dir, 'claude').enabled).toBe(true); }); // Value: protects=an off saved through the one list stays off when the other CLI's logins are discovered; // fails_when=default-on looks only at the registry being added to; why_new=default-on now counts both CLIs; seam=none it('keeps a saved off when the other CLI\'s logins are found later', async () => { await addRotationAccount('/y', claudeDeps()); await configureRotation({ enabled: false, accounts: ids('claude').map(({ id }) => ({ id, enabled: true })) }, claudeDeps()); await addRotationAccount(join(home, '.codex-b'), deps()); expect(rotationOn(dir)).toBe(false); }); it('saves one mixed order into both registries, ranked, and refuses a list missing either CLI', async () => { await addRotationAccount('/y', claudeDeps()); await addRotationAccount(join(home, '.codex-b'), deps()); const [x, y] = ids('claude'), [a, b] = ids('codex'); expect(await configureRotation({ enabled: true, accounts: [x, y].map(({ id }) => ({ id, enabled: true })) }, deps())).toMatchObject({ error: 'Invalid rotation settings.' }); expect(await configureRotation({ enabled: true, accounts: [null, x, b, y] }, deps())).toMatchObject({ error: 'Invalid rotation settings.' }); const order = [a, x, b, y].map(({ id }, i) => ({ id, enabled: i !== 3 })); expect(await configureRotation({ enabled: true, accounts: order }, deps())).toEqual({ ok: true }); expect(ids('claude').map(r => [r.email, r.rank, r.enabled])).toEqual([['x@example.com', 1, true], ['y@example.com', 3, false]]); expect(ids('codex').map(r => [r.email, r.rank])).toEqual([['a@example.com', 0], ['b@example.com', 2]]); for (const cli of ['claude', 'codex']) expect(rotationState(dir, cli)).toMatchObject({ enabled: true, defaultSettings: false }); expect(rotationState(dir, 'codex')).not.toHaveProperty('fallback'); // Two selected across both CLIs are enough; one is not. expect((await configureRotation({ enabled: true, accounts: order.map((o, i) => ({ ...o, enabled: i === 0 })) }, deps())).error).toMatch(/at least two/); }); it('refuses to save while either CLI has an interrupted switch, and recovery turns switching off for both', async () => { await addRotationAccount('/y', claudeDeps()); await addRotationAccount(join(home, '.codex-b'), deps()); const all = [...ids('claude'), ...ids('codex')].map(({ id }) => ({ id, enabled: true })); await configureRotation({ enabled: true, accounts: all }, deps()); const s = rotationState(dir, 'codex'); writeFileSync(join(dir, 'codex-account-rotation.json'), JSON.stringify({ ...s, pending: { from: s.active, to: s.accounts[1].id } })); expect((await configureRotation({ enabled: false, accounts: all }, claudeDeps())).error).toMatch(/interrupted/); expect(await recoverRotation(undefined, deps())).toMatchObject({ ok: true }); expect(rotationOn(dir)).toBe(false); expect(rotationState(dir, 'claude').enabled).toBe(false); }); // Value: protects=a login added while switching is on through the other CLI joins the list unselected, not silently into rotation; // fails_when=addRotationAccount defaults a new account's enabled from its own registry only (!s.enabled); // why_new=the default-on test above starts with both registries off, so it never sees the other CLI's switch; seam=none it('adds new accounts unselected while switching is on through the other CLI', async () => { await addRotationAccount('/y', claudeDeps()); // Claude x and y: on by default expect(rotationState(dir, 'claude').enabled).toBe(true); await addRotationAccount(join(home, '.codex-b'), deps()); expect(ids('codex').map(r => r.enabled)).toEqual([false, false]); }); // Value: protects=recovering one CLI's interrupted switch never overwrites the other CLI's unreadable registry; // fails_when=recoverRotation saves the other registry without its damaged check, replacing the file with an empty state; // why_new=the recovery test above has a readable Claude registry; seam=none it('leaves an unreadable other registry alone when recovering', async () => { await addRotationAccount(join(home, '.codex-b'), deps()); const s = rotationState(dir, 'codex'); writeFileSync(join(dir, 'codex-account-rotation.json'), JSON.stringify({ ...s, pending: { from: s.active, to: s.accounts[1].id } })); writeFileSync(join(dir, 'account-rotation.json'), '{not json'); expect(await recoverRotation(undefined, deps())).toMatchObject({ ok: true }); expect(readFileSync(join(dir, 'account-rotation.json'), 'utf8')).toBe('{not json'); }); }); describe('Codex reset times', () => { const now = new Date(2026, 9, 8, 12, 0).getTime(); const at = (...a) => new Date(...a).getTime(); it('reads the local reset Codex prints, with or without a date, year or time', () => { expect(retryAt('or try again at Oct 25th, 2026 3:15 PM.', now)).toBe(at(2026, 9, 25, 15, 15)); expect(retryAt('or try again at Oct 25', now)).toBe(at(2026, 9, 25)); expect(retryAt('or try again at 3:15 PM.', now)).toBe(at(2026, 9, 8, 15, 15)); expect(retryAt('or try again at 9:15 AM.', now)).toBe(at(2026, 9, 9, 9, 15)); // already past today expect(retryAt('tryagainat Jan 3rd, 9:00 AM', now)).toBe(at(2027, 0, 3, 9)); // spaces lost, next year expect(retryAt('try again in 2 days 3 hours', now)).toBe(now + (51 * 60) * 60_000); expect(retryAt('try again at 16:05', now)).toBe(at(2026, 9, 8, 16, 5)); // 24-hour clock expect(retryAt('try again at Oct 25 12:30 AM', now)).toBe(at(2026, 9, 25, 0, 30)); // just after midnight }); it('falls back to the 30-minute retry delay when there is no usable reset', () => { expect(retryAt("You've hit your usage limit.", now)).toBe(now + RETRY_MS); expect(retryAt('try again at Oct 7, 2026 1:00 AM', now)).toBe(now + RETRY_MS); }); // Value: protects=a reset under a minute away (Claude or Codex wording) still waits at least 60s before the account is retried; // fails_when=the Math.max(…, now + 60_000) floor in retryAt is dropped for either the relative or the absolute branch; // why_new=the rows above use resets hours or days away; seam=none it('waits at least a minute for a reset that is less than a minute away', () => { const soon = now - 30_000; // 11:59:30, so 12:00 PM is 30s away expect(retryAt('or try again at 12:00 PM.', soon)).toBe(soon + 60_000); expect(retryAt('Limit reached; resets in 0m', now)).toBe(now + 60_000); expect(retryAt(`resets ${new Date(now + 10_000).toISOString().slice(0, 19)}Z`, now)).toBe(now + 60_000); }); }); describe('Codex sessions across a switch', () => { const id = '019a0000-0000-7000-8000-00000000c0de'; it('resumes the exact conversation with the session\'s own options and without its old prompt', () => { const cmd = resumeCodexCommand('codex --sandbox read-only --ask-for-approval on-request -m gpt-5.5 -c model_reasoning_effort=high "Ship card 9"', id); expect(parseCommand(cmd).args).toEqual(['resume', id, '--sandbox', 'read-only', '--ask-for-approval', 'on-request', '-m', 'gpt-5.5', '-c', 'model_reasoning_effort=high', expect.stringContaining('interrupted conversation')]); expect(cmd).not.toContain('Ship card 9'); // Every spelling lib/jobs.js accepts as a Codex permission flag survives. expect(parseCommand(resumeCodexCommand('codex --yolo -sread-only -a=never "task"', id)).args.slice(2, 5)).toEqual(['--yolo', '-sread-only', '-a=never']); // Billion's own form, already resuming another id. const billion = resumeCodexCommand(`codex resume 019a0000-0000-7000-8000-000000000001 --dangerously-bypass-approvals-and-sandbox 'You were restarted.'`, id); expect(parseCommand(billion).args.slice(0, 3)).toEqual(['resume', id, '--dangerously-bypass-approvals-and-sandbox']); }); it('refuses an unknown conversation or an unsupported option before stopping anything', async () => { expect(() => resumeCodexCommand('codex', null)).toThrow(/exact Codex conversation/); // Value: protects=a Codex restart drops --last and anything after --, keeps --opt=value, and refuses a dangling value or a second positional; // fails_when=resumeCodexCommand's --last, --, = or refusal branches change; why_new=the rows above cover only spaced values and one prompt; seam=none expect(parseCommand(resumeCodexCommand('codex resume --last --model=gpt-5.5 -- "old"', id)).args.slice(0, 3)).toEqual(['resume', id, '--model=gpt-5.5']); expect(() => resumeCodexCommand('codex -m', id)).toThrow(/Missing -m value/); expect(() => resumeCodexCommand('codex "one" "two"', id)).toThrow(/separate the Codex prompt/); const stop = vi.fn(), fn = vi.fn(); await expect(withClaudeSessionsStopped(fn, { agent: 'codex', list: () => [{ agent: 'codex', command: 'codex --worktree' }], idFor: () => id, stop, start: vi.fn() })).rejects.toThrow(/does not support --worktree/); await expect(withClaudeSessionsStopped(fn, { agent: 'codex', list: () => [{ agent: 'codex', command: 'codex' }], idFor: () => null, stop, start: vi.fn() })).rejects.toThrow(/exact Codex conversation/); expect(stop).not.toHaveBeenCalled(); expect(fn).not.toHaveBeenCalled(); }); it('stops only Codex sessions, swaps, then resumes each on `codex resume `', async () => { const events = []; const sessions = [{ id: 'w', agent: 'codex', command: 'codex -m gpt-5.5 "card"' }, { id: 'c', agent: 'claude', command: 'claude' }]; await withClaudeSessionsStopped(async () => { events.push('swap'); return { ok: true }; }, { agent: 'codex', list: () => sessions, idFor: () => id, stop: async s => { events.push(`stop-${s.id}`); return []; }, start: async r => { events.push(`start-${r.session.id}`); expect(parseCommand(r.command).args.slice(0, 2)).toEqual(['resume', id]); }, }); expect(events).toEqual(['stop-w', 'swap', 'start-w']); }); it('counts an outside codex as blocking but lets the app\'s own sessions and the background server through', async () => { const DAEMON = '/Users/x/.codex/packages/app-server-daemon/releases/0.162.0/bin/codex app-server --listen unix:// --managed-daemon'; const LOOP = '/Users/x/.codex/packages/app-server-daemon/releases/0.162.0/bin/codex app-server daemon pid-update-loop'; expect(isCodexDaemon(DAEMON)).toBe(true); expect(isCodexDaemon(LOOP)).toBe(true); expect(isCodexDaemon('codex resume 1')).toBe(false); // Only as the subcommand: a prompt that names it is an ordinary outside codex. expect(isCodexDaemon("codex exec 'run codex app-server --managed-daemon for me'")).toBe(false); expect(isCodexDaemon('node /n/@openai/codex/bin/codex.js app-server daemon pid-update-loop')).toBe(true); const rows = [{ pid: 10, ppid: 1, command: 'node /n/@openai/codex/bin/codex.js' }, { pid: 11, ppid: 10, command: '/n/vendor/codex' }, { pid: 20, ppid: 1, command: DAEMON }, { pid: 21, ppid: 1, command: LOOP }, { pid: 30, ppid: 1, command: '/opt/bin/codex exec hi' }, { pid: 40, ppid: 1, command: 'claude' }]; expect(externalClaudePids(rows, new Set([10]), 'codex')).toEqual([30]); const ps = out => (_f, _a, _o, cb) => cb(null, out); const opts = { agent: 'codex', platform: 'darwin', getuid: () => 1 }; await expect(assertClaudeProcessesManaged([], { ...opts, run: ps(`20 1 ${DAEMON}\n30 1 /opt/bin/codex exec hi\n`) })).rejects.toThrow(/Another Codex process/); await expect(assertClaudeProcessesManaged([], { ...opts, run: ps(`20 1 ${DAEMON}\n40 1 claude\n`) })).resolves.toEqual({ daemon: true }); await expect(assertClaudeProcessesManaged([], { ...opts, run: ps('40 1 claude\n') })).resolves.toEqual({ daemon: false }); }); }); // Value: protects=a Codex switch or an interrupted Codex switch refuses only Codex spawns, and Claude's gate refuses only Claude; // fails_when=pty.js's spawn gate or pending check ignores which CLI is switching, blocking or letting through the wrong agent; // why_new=account-rotation-server.test.js covers the Claude pending block only, never per-CLI isolation; seam=none describe.skipIf(!POSIX)('the per-CLI spawn gate', () => { it('refuses Codex while Codex switches or is interrupted, and leaves Claude alone (and back)', async () => { const { createSessionFromConfig, blockSpawns } = await import('../server/pty.js'); const { CONFIG_DIR } = await import('../server/state.js'); const bin = join(home, 'bin'); mkdirSync(bin); for (const name of ['codex', 'claude']) writeFileSync(join(bin, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); const made = []; const spawn = name => { const r = createSessionFromConfig({ sessionId: `gate-${Math.random()}`, name: 'G', color: '#000', command: join(bin, name), cwd: home }, () => {}); if (r.session) made.push(r.session); return r; }; const pendingFile = join(CONFIG_DIR, 'codex-account-rotation.json'); try { blockSpawns(true, 'codex'); expect(spawn('codex').error).toMatch(/Codex accounts are switching/); expect(spawn('claude').error).toBeUndefined(); blockSpawns(true, 'claude'); expect(spawn('claude').error).toMatch(/Claude accounts are switching/); expect(spawn('codex').error).toBeUndefined(); blockSpawns(false); mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(pendingFile, JSON.stringify({ enabled: true, accounts: [], pending: { from: 'a'.repeat(64), to: 'b'.repeat(64) } })); expect(spawn('codex').error).toMatch(/Restore the interrupted Codex login/); expect(spawn('claude').error).toBeUndefined(); } finally { blockSpawns(false); removeTempDir(pendingFile); for (const s of made) { try { s.pty.kill(); } catch {} clearInterval(s.stateCheckInterval); clearTimeout(s.scanTimer); } } }); });