test/codex-rotation.test.js
Rama main · réplica de lectura del repositorio original.
// 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 <id>` 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 <temp home>/.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 <id>`', 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); }
}
});
});