test/account-migration.test.js
Rama main · réplica de lectura del repositorio original.
// The owner's permanent Claude account switch (server/account-migration.js).
// Everything runs against a scratch home and a fake `security` / `claude`:
// the real Keychain, ~/.claude and ~/.claude.json are never touched.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, readdirSync, realpathSync, rmSync, utimesSync } from 'fs';
import { spawnSync } from 'child_process';
import { tmpdir, userInfo } from 'os';
import { join } from 'path';
import {
preflight, migrate, rollback, retire, setup, setArmed, isArmed, loadState, publicState, canMigrate, runCommand, BUSY_ERROR, resetInFlight, checkSwitch, recheck, authStatus,
keychainService, keychainAccount, loginOf, writeAccountFields, DEFAULT_SERVICE, ACCOUNT_FIELDS,
} from '../server/account-migration.js';
import { limitTick, resetLimitWatch, SETTLE_MS } from '../server/billion-limit.js';
import { withBillionStopped } from '../server/billion.js';
const OLD = 'old@example.com', NEW = 'new@example.com';
const OLD_TOKEN = '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-OLD","refreshToken":"sk-ant-ort01-OLD"}}';
const NEW_TOKEN = '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-NEW","refreshToken":"sk-ant-ort01-NEW"}}';
let home, dir, newDir, keychain, calls, env, deps;
// A fake `security` over an in-memory Keychain and a fake `claude auth status
// --json` that answers as the real one does: logged in when the folder's
// .claude.json has an oauthAccount and its token exists, email from that block.
function fakeRun(over = {}) {
// `security -i` takes its command on stdin: parse the one line the module writes.
const parseLine = (line) => {
const m = /^add-generic-password -U -a "([^"]*)" -s "([^"]*)" -X ([0-9a-f]+)\n$/.exec(line);
if (!m) throw new Error(`fake security -i: unexpected command ${JSON.stringify(line)}`);
return { acct: m[1], svc: m[2], secret: Buffer.from(m[3], 'hex').toString('utf8') };
};
return vi.fn(async (file, args, opts = {}) => {
calls.push([file, ...args, ...(opts.input ? ['<stdin>'] : [])]);
if (file === '/usr/bin/security') {
if (args[0] === '-i') {
if (over.writeFails) return { code: 1, stdout: '', stderr: 'boom' };
const { acct, svc, secret } = parseLine(opts.input);
const item = keychain.get(svc);
// -U updates the item with this account and service; another account would be a second item.
if (item && item.acct !== acct) keychain.set(`${svc}@${acct}`, { acct, secret });
else keychain.set(svc, { acct, secret });
return { code: 0, stdout: '', stderr: '' };
}
const svc = args[args.indexOf('-s') + 1];
const acct = args.includes('-a') ? args[args.indexOf('-a') + 1] : null;
const item = keychain.get(svc);
if (args[0] === 'find-generic-password') {
if (!item || (acct && item.acct !== acct)) return { code: 44, stdout: '', stderr: 'The specified item could not be found in the keychain.' };
return args.includes('-w')
? { code: 0, stdout: `${item.secret}\n`, stderr: '' }
: { code: 0, stdout: `keychain: "login"\nclass: "genp"\nattributes:\n "acct"<blob>="${item.acct}"\n "svce"<blob>="${svc}"\n`, stderr: '' };
}
throw new Error(`fake security: unexpected ${args.join(' ')}`);
}
if (file === 'claude') {
const folder = opts.env?.CLAUDE_CONFIG_DIR;
const login = loginOf(folder || null, { home, env: opts.env || env, platform: 'darwin' });
let account = null;
try { account = JSON.parse(readFileSync(login.configJson, 'utf8')).oauthAccount; } catch {}
const item = keychain.get(login.service);
const loggedIn = !!(account && item) && !over.loggedOut?.(folder);
const email = over.reportEmail ? over.reportEmail(folder, account) : account?.emailAddress ?? null;
const s = { loggedIn, authMethod: loggedIn ? 'claude.ai' : 'none', configDirectory: login.folder, ...(loggedIn ? { email } : {}) };
return { code: loggedIn ? 0 : 1, stdout: JSON.stringify(s, null, 2), stderr: '' };
}
return { code: 127, stdout: '', stderr: `no ${file}` };
});
}
const json = (f) => JSON.parse(readFileSync(f, 'utf8'));
const defaultJson = () => join(home, '.claude.json');
beforeEach(() => {
home = realpathSync(mkdtempSync(join(tmpdir(), 'acct-home-')));
dir = join(home, '.agent-007');
newDir = join(home, '.claude-new');
mkdirSync(join(home, '.claude'));
mkdirSync(newDir);
writeFileSync(defaultJson(), JSON.stringify({
numStartups: 40, theme: 'dark', oauthAccount: { accountUuid: 'a-old', emailAddress: OLD, organizationName: 'Old org' },
hasAvailableSubscription: true, projects: { '/x': { hasTrustDialogAccepted: true } }, userID: 'u1',
}, null, 2), { mode: 0o600 });
writeFileSync(join(newDir, '.claude.json'), JSON.stringify({
numStartups: 1, oauthAccount: { accountUuid: 'a-new', emailAddress: NEW, organizationName: 'New org' },
}, null, 2), { mode: 0o600 });
keychain = new Map([
[DEFAULT_SERVICE, { acct: 'bill', secret: OLD_TOKEN }],
[keychainService(newDir), { acct: 'bill', secret: NEW_TOKEN }],
]);
calls = [];
env = { USER: 'bill', PATH: '/usr/bin' };
deps = { home, env, platform: 'darwin', dir, run: fakeRun(), log: () => {}, wait: async () => {}, now: () => new Date('2026-09-27T10:00:00Z') };
resetLimitWatch();
resetInFlight();
});
describe('where a login lives', () => {
it('names the Keychain item as claude 2.1.283 does', () => {
expect(keychainService(null)).toBe('Claude Code-credentials');
expect(keychainService('/Users/x/.claude-new')).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/);
expect(keychainService('/Users/x/.claude-new')).not.toBe(keychainService('/Users/x/.claude-new/'));
expect(keychainAccount({ USER: 'bill' })).toBe('bill');
expect(keychainAccount({ USER: 'bad user!' })).toBe('claude-code-user');
// NFC: a folder typed with a combining accent hashes like the composed one.
expect(keychainService('/Users/x/café')).toBe(keychainService('/Users/x/café'));
// No USER in the env: the process's own username, as claude does.
expect(keychainAccount({})).toBe(/^[a-zA-Z0-9._-]+$/.test(userInfo().username) ? userInfo().username : 'claude-code-user');
});
it('the default folder follows CLAUDE_CONFIG_DIR when the server has it, and Linux uses a file', () => {
expect(loginOf(null, { home, env: {}, platform: 'darwin' })).toMatchObject({ folder: join(home, '.claude'), configJson: defaultJson(), service: DEFAULT_SERVICE, credentialsFile: null });
const moved = loginOf(null, { home, env: { CLAUDE_CONFIG_DIR: '/cfg' }, platform: 'darwin' });
expect(moved).toMatchObject({ folder: '/cfg', configJson: join('/cfg', '.claude.json'), service: keychainService('/cfg') }); // join: a backslash on Windows
expect(moved.env.CLAUDE_CONFIG_DIR).toBe('/cfg');
const linux = loginOf(newDir, { home, env: {}, platform: 'linux' });
expect(linux).toMatchObject({ service: null, credentialsFile: join(newDir, '.credentials.json'), configJson: join(newDir, '.claude.json') });
expect(linux.env.CLAUDE_CONFIG_DIR).toBe(newDir);
});
});
describe('preflight', () => {
it('passes with the emails of both accounts and reads no secret', async () => {
const pre = await preflight('~/.claude-new', deps);
expect(pre).toMatchObject({ folder: newDir, newEmail: NEW, oldEmail: OLD });
expect(calls.some(c => c.includes('-w'))).toBe(false);
expect(calls).toContainEqual(['claude', 'auth', 'status', '--json']);
expect(deps.run.mock.calls.find(([f, , o]) => f === 'claude' && o.env.CLAUDE_CONFIG_DIR === newDir)).toBeTruthy();
});
it('refuses a relative path, a missing folder, the default folder, no login, no token, logged out, and the same account', async () => {
expect((await preflight('claude-new', deps)).error).toMatch(/absolute path/);
expect((await preflight(join(home, 'nope'), deps)).error).toMatch(/does not exist/);
expect((await preflight(join(home, '.claude'), deps)).error).toMatch(/default Claude Code folder itself/);
writeFileSync(join(newDir, '.claude.json'), '{"numStartups":1}');
expect((await preflight(newDir, deps)).error).toMatch(/no oauthAccount/);
writeFileSync(join(newDir, '.claude.json'), JSON.stringify({ oauthAccount: { emailAddress: NEW } }));
keychain.delete(keychainService(newDir));
expect((await preflight(newDir, deps)).error).toMatch(/No token for/);
keychain.set(keychainService(newDir), { acct: 'bill', secret: NEW_TOKEN });
expect((await preflight(newDir, { ...deps, run: fakeRun({ loggedOut: (f) => f === newDir }) })).error).toMatch(/not logged in/);
writeFileSync(join(newDir, '.claude.json'), JSON.stringify({ oauthAccount: { emailAddress: OLD.toUpperCase() } }));
expect((await preflight(newDir, deps)).error).toMatch(/Both folders are logged in as old@example.com/);
});
it('needs a current login to move away from', async () => {
keychain.delete(DEFAULT_SERVICE);
expect((await preflight(newDir, deps)).error).toMatch(/default Claude Code folder is not logged in/);
});
it('names the new folder\'s .claude.json when it is missing or not JSON', async () => {
rmSync(join(newDir, '.claude.json'));
expect((await preflight(newDir, deps)).error).toMatch(new RegExp(`^${join(newDir, '.claude.json').replace(/[.\\/]/g, '\\$&')}: `));
writeFileSync(join(newDir, '.claude.json'), 'not json');
expect((await preflight(newDir, deps)).error).toMatch(/\.claude\.json: /);
writeFileSync(join(newDir, '.claude.json'), '[1]');
expect((await preflight(newDir, deps)).error).toMatch(/not a JSON object/);
});
it('treats a claude that prints no JSON as logged out', async () => {
const base = fakeRun();
const run = vi.fn(async (f, a, o) => (f === 'claude' && o.env?.CLAUDE_CONFIG_DIR === newDir) ? { code: 0, stdout: 'Logged in as new@example.com\n', stderr: '' } : base(f, a, o));
expect(await authStatus(loginOf(newDir, { home, env, platform: 'darwin' }), { run, platform: 'darwin' })).toEqual({ loggedIn: false, email: null, error: 'claude auth status printed no JSON' });
expect((await preflight(newDir, { ...deps, run })).error).toMatch(/not logged in/);
// loggedIn: true with a non-zero exit is not a login either.
const flaky = vi.fn(async () => ({ code: 1, stdout: '{"loggedIn":true,"email":"x@y"}', stderr: '' }));
expect((await authStatus(loginOf(null, { home, env, platform: 'darwin' }), { run: flaky, platform: 'darwin' })).loggedIn).toBe(false);
});
});
describe('migrate', () => {
it('backs up at 0600, swaps the token and account in place, verifies, and records the switch', async () => {
const result = await migrate(newDir, deps);
expect(result).toMatchObject({ ok: true, oldEmail: OLD, newEmail: NEW });
// The default item now holds the new token, same account attribute, and nothing else was added.
expect(keychain.get(DEFAULT_SERVICE)).toEqual({ acct: 'bill', secret: NEW_TOKEN });
expect([...keychain.keys()]).toEqual([DEFAULT_SERVICE, keychainService(newDir)]);
expect(keychain.get(keychainService(newDir)).secret).toBe(NEW_TOKEN); // the new folder is left alone
// The write went to `security -i` on stdin: no token on any command line.
expect(calls.find(c => c[1] === '-i')).toEqual(['/usr/bin/security', '-i', '<stdin>']);
expect(calls.flat().join(' ')).not.toMatch(/add-generic-password|sk-ant|[0-9a-f]{40}/);
expect(deps.run.mock.calls.find(([, a]) => a[0] === '-i')[2].input).toBe(`add-generic-password -U -a "bill" -s "${DEFAULT_SERVICE}" -X ${Buffer.from(NEW_TOKEN).toString('hex')}\n`);
// Read back after the write, with the account claude itself uses.
expect(calls.filter(c => c.includes('-w')).every(c => c.includes('-a') && c.includes('bill'))).toBe(true);
// Only the account fields changed in ~/.claude.json.
const after = json(defaultJson());
expect(after.oauthAccount).toEqual({ accountUuid: 'a-new', emailAddress: NEW, organizationName: 'New org' });
expect(after.hasAvailableSubscription).toBeUndefined(); // not in the new folder: dropped
expect(after).toMatchObject({ numStartups: 40, theme: 'dark', projects: { '/x': { hasTrustDialogAccepted: true } }, userID: 'u1' });
expect(readdirSync(home).filter(f => f.includes('.tmp'))).toEqual([]);
if (process.platform !== 'win32') expect(statSync(defaultJson()).mode & 0o777).toBe(0o600);
// The backup: the old token and account fields, readable by the owner alone.
expect(result.backupDir.startsWith(join(dir, 'account-backup'))).toBe(true);
expect(readFileSync(join(result.backupDir, 'credentials'), 'utf8')).toBe(OLD_TOKEN);
const meta = json(join(result.backupDir, 'account.json'));
expect(meta).toMatchObject({ email: OLD, account: 'bill', where: `Keychain item "${DEFAULT_SERVICE}"`, fields: { oauthAccount: { emailAddress: OLD }, hasAvailableSubscription: true } });
if (process.platform !== 'win32') {
expect(statSync(join(result.backupDir, 'credentials')).mode & 0o777).toBe(0o600);
expect(statSync(join(result.backupDir, 'account.json')).mode & 0o777).toBe(0o600);
expect(statSync(result.backupDir).mode & 0o777).toBe(0o700);
}
expect(loadState(dir)).toMatchObject({ status: 'migrated', folder: newDir, newEmail: NEW, oldEmail: OLD, backupDir: result.backupDir });
expect(JSON.stringify(publicState(dir))).not.toMatch(/sk-ant/);
});
it('never logs a token', async () => {
const lines = [];
await migrate(newDir, { ...deps, log: (l) => lines.push(l) });
expect(lines.join('\n')).toMatch(/switched the default login from <email> to <email>/);
expect(lines.join('\n')).not.toMatch(/sk-ant|OLD|NEW|@example\.com/);
// Every other line the module logs is redacted the same way.
const more = [];
await rollback({ ...deps, log: (l) => more.push(l) });
await migrate(join(home, 'nope'), { ...deps, log: (l) => more.push(l) });
expect(more.length).toBeGreaterThan(1);
expect(more.join('\n')).not.toMatch(/@example\.com/);
});
it('rolls back by itself when the verify does not show the new email', async () => {
// claude still reports the old account after the swap.
const run = fakeRun({ reportEmail: (folder) => (folder ? NEW : OLD) });
const result = await migrate(newDir, { ...deps, run });
expect(result.error).toMatch(/reports old@example.com after the swap, not new@example.com\. Rolled back to old@example.com\./);
expect(result.rolledBack).toBe(true);
expect(keychain.get(DEFAULT_SERVICE)).toEqual({ acct: 'bill', secret: OLD_TOKEN });
const after = json(defaultJson());
expect(after.oauthAccount.emailAddress).toBe(OLD);
expect(after.hasAvailableSubscription).toBe(true);
expect(after.numStartups).toBe(40);
expect(loadState(dir)).toMatchObject({ status: 'rolled back', oldEmail: OLD, newEmail: NEW });
expect(isArmed(dir)).toBe(false);
});
it('a Keychain write that fails before anything changed needs no rollback and leaves the panel usable', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
const run = fakeRun({ writeFails: true });
const result = await migrate(newDir, { ...deps, run });
expect(result.error).toMatch(/add-generic-password failed \(1\): boom\. Nothing changed\./);
expect(result.rolledBack).toBeUndefined();
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN);
expect(json(defaultJson()).oauthAccount.emailAddress).toBe(OLD);
// Disarmed, ready, the error shown; the backup stays for the record.
expect(loadState(dir)).toMatchObject({ status: 'ready', error: expect.stringMatching(/boom/), backupDir: result.backupDir });
expect(canMigrate(dir).ok).toBe(true);
expect((await migrate(newDir, deps)).ok).toBe(true);
});
it('stops before the backup when the current token cannot be read, and rolls back when the new one cannot', async () => {
// The item is there (preflight only checks that) but its secret will not come out.
const noRead = (svc) => { const base = fakeRun(); return vi.fn(async (f, a, o) => (f === '/usr/bin/security' && a.includes('-w') && a.includes(svc)) ? { code: 36, stdout: '', stderr: 'denied' } : base(f, a, o)); };
const result = await migrate(newDir, { ...deps, run: noRead(DEFAULT_SERVICE) });
expect(result.error).toMatch(/Could not read the current token from Keychain item "Claude Code-credentials"; nothing changed/);
expect(result.rolledBack).toBeUndefined();
expect(existsSync(join(dir, 'account-backup'))).toBe(false);
expect(loadState(dir)).toEqual({ status: 'not set up' });
expect(json(defaultJson()).oauthAccount.emailAddress).toBe(OLD);
// The new token unreadable: found out before anything moved, so nothing to undo.
const second = await migrate(newDir, { ...deps, run: noRead(keychainService(newDir)) });
expect(second.error).toMatch(/could not read the new token from Keychain item .*; nothing changed\./);
expect(second.rolledBack).toBeUndefined();
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN);
expect(json(defaultJson())).toMatchObject({ oauthAccount: { emailAddress: OLD }, hasAvailableSubscription: true });
expect(loadState(dir)).toEqual({ status: 'not set up' }); // not armed: nothing to record
});
it('on Linux copies .credentials.json at 0600 instead of using the Keychain', async () => {
const linux = { ...deps, platform: 'linux', env: { ...env, HOME: home } };
writeFileSync(join(home, '.claude', '.credentials.json'), OLD_TOKEN, { mode: 0o600 });
writeFileSync(join(newDir, '.credentials.json'), NEW_TOKEN, { mode: 0o600 });
// The fake claude reads the Keychain map for loggedIn; give it the file view instead.
linux.run = vi.fn(async (file, args, opts = {}) => {
if (file === 'security') throw new Error('security must not run on Linux');
const login = loginOf(opts.env?.CLAUDE_CONFIG_DIR || null, { home, env, platform: 'linux' });
const account = json(login.configJson).oauthAccount;
const loggedIn = !!account && existsSync(login.credentialsFile);
return { code: loggedIn ? 0 : 1, stdout: JSON.stringify({ loggedIn, email: loggedIn ? account.emailAddress : null }), stderr: '' };
});
const result = await migrate(newDir, linux);
expect(result).toMatchObject({ ok: true, newEmail: NEW });
const file = join(home, '.claude', '.credentials.json');
expect(readFileSync(file, 'utf8')).toBe(NEW_TOKEN);
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600);
expect(readFileSync(join(newDir, '.credentials.json'), 'utf8')).toBe(NEW_TOKEN);
expect(json(join(result.backupDir, 'account.json')).where).toBe(file);
expect(readFileSync(join(result.backupDir, 'credentials'), 'utf8')).toBe(OLD_TOKEN);
expect((await rollback(linux)).ok).toBe(true);
expect(readFileSync(file, 'utf8')).toBe(OLD_TOKEN);
});
});
describe('writeAccountFields', () => {
it('tries again when Claude Code wrote the file meanwhile, and gives up after a few', async () => {
const file = defaultJson();
const waits = [];
// Claude Code writes between our read and our rename, once.
const afterRead = (i) => { if (i === 0) writeFileSync(file, JSON.stringify({ ...json(file), numStartups: 41 })); };
expect(await writeAccountFields(file, { oauthAccount: { emailAddress: NEW } }, { wait: async (ms) => waits.push(ms), afterRead })).toBe(true);
expect(waits).toEqual([100]);
expect(json(file)).toMatchObject({ oauthAccount: { emailAddress: NEW }, numStartups: 41, theme: 'dark' });
expect(readdirSync(home).filter(f => f.includes('.tmp'))).toEqual([]);
// A file that keeps changing is an error, and the temp file is gone.
const always = () => writeFileSync(file, JSON.stringify({ ...json(file), numStartups: Math.random() }));
await expect(writeAccountFields(file, {}, { tries: 2, wait: async () => {}, afterRead: always })).rejects.toThrow(/kept changing under us \(2 tries\)/);
expect(json(file).oauthAccount.emailAddress).toBe(NEW);
expect(readdirSync(home).filter(f => f.includes('.tmp'))).toEqual([]);
});
it('leaves a file that is not a JSON object alone', async () => {
const file = join(home, 'list.json');
writeFileSync(file, '[1,2]');
await expect(writeAccountFields(file, {}, { tries: 1 })).rejects.toThrow(/not a JSON object/);
expect(readFileSync(file, 'utf8')).toBe('[1,2]');
});
it('drops account fields the new folder lacks and keeps every other key', async () => {
const file = defaultJson();
await writeAccountFields(file, { oauthAccount: { emailAddress: NEW } });
const after = json(file);
for (const k of ACCOUNT_FIELDS.filter(k => k !== 'oauthAccount')) expect(after[k]).toBeUndefined();
expect(Object.keys(after)).toEqual(['numStartups', 'theme', 'oauthAccount', 'projects', 'userID']);
});
});
describe('rollback and retire', () => {
it('rollback restores the backup and verifies the old email again', async () => {
await migrate(newDir, deps);
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(NEW_TOKEN);
const result = await rollback(deps);
expect(result).toMatchObject({ ok: true, email: OLD });
expect(keychain.get(DEFAULT_SERVICE)).toEqual({ acct: 'bill', secret: OLD_TOKEN });
expect(json(defaultJson())).toMatchObject({ oauthAccount: { emailAddress: OLD }, hasAvailableSubscription: true, numStartups: 40 });
expect(loadState(dir).status).toBe('rolled back');
expect((await rollback({ ...deps, dir: join(home, 'empty') })).error).toMatch(/Nothing to roll back/);
});
it('rollback reports a restore that does not verify', async () => {
await migrate(newDir, deps);
const result = await rollback({ ...deps, run: fakeRun({ reportEmail: () => NEW }) });
expect(result.error).toMatch(/^Rollback failed: claude auth status reports new@example.com after the restore, not old@example.com/);
expect(loadState(dir).status).toBe('rollback failed');
expect(publicState(dir).error).toMatch(/after the restore/);
});
it('rollback needs the backup its state names, never whatever is newest on disk, and backs up what it replaces', async () => {
const first = await migrate(newDir, deps);
// Nothing to roll back from a fresh or a rolled-back state; a state that lost its pointer is refused too.
expect((await rollback({ ...deps, dir: join(home, 'other') })).error).toMatch(/Nothing to roll back/);
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ status: 'migrated', folder: newDir, newEmail: NEW, oldEmail: OLD }));
mkdirSync(join(dir, 'account-backup', '2020-01-01T00-00-00-000Z'));
writeFileSync(join(dir, 'account-backup', '2020-01-01T00-00-00-000Z', 'account.json'), '{"email":"stale@example.com"}');
writeFileSync(join(dir, 'account-backup', '2020-01-01T00-00-00-000Z', 'credentials'), 'stale');
expect((await rollback(deps)).error).toMatch(/names no backup/);
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(NEW_TOKEN);
// With the pointer back: the login being replaced is saved first, then the old one restored.
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ status: 'migrated', folder: newDir, newEmail: NEW, oldEmail: OLD, backupDir: first.backupDir }));
const result = await rollback(deps);
expect(result).toMatchObject({ ok: true, email: OLD });
expect(loadState(dir)).toMatchObject({ status: 'rolled back', backupDir: first.backupDir, replacedBackupDir: result.replacedBackupDir });
expect(result.replacedBackupDir).not.toBe(first.backupDir);
expect(readFileSync(join(result.replacedBackupDir, 'credentials'), 'utf8')).toBe(NEW_TOKEN);
expect(json(join(result.replacedBackupDir, 'account.json'))).toMatchObject({ email: NEW, fields: { oauthAccount: { emailAddress: NEW } } });
expect((await rollback(deps)).error).toMatch(/Already rolled back/);
// A backup with its credentials gone is an error, not a half restore.
await migrate(newDir, deps);
const state = loadState(dir);
rmSync(join(state.backupDir, 'credentials'));
const broken = await rollback(deps);
expect(broken.error).toMatch(/incomplete: credentials missing/);
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(NEW_TOKEN);
expect(loadState(dir).status).toBe('migrated');
});
it('retire renames the migrated folder with the date and never deletes it', async () => {
expect((await retire(deps)).error).toMatch(/Only a folder whose account has been switched to/);
await migrate(newDir, deps);
const result = await retire(deps);
expect(result).toEqual({ ok: true, retiredTo: `${newDir}.retired-2026-09-27` });
expect(existsSync(newDir)).toBe(false);
expect(json(join(result.retiredTo, '.claude.json')).oauthAccount.emailAddress).toBe(NEW);
expect(loadState(dir)).toMatchObject({ status: 'migrated', retiredTo: result.retiredTo });
expect(publicState(dir).retiredTo).toBe(result.retiredTo);
// A second folder retired the same day gets a number, not an overwrite.
mkdirSync(newDir);
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ ...loadState(dir), retiredTo: undefined }));
expect((await retire(deps)).retiredTo).toBe(`${newDir}.retired-2026-09-27-2`);
expect(existsSync(result.retiredTo)).toBe(true);
// Retired already: the button does nothing twice.
const rename = vi.fn();
expect((await retire({ ...deps, rename })).error).toMatch(/already retired as/);
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ ...loadState(dir), retiredTo: undefined }));
expect((await retire({ ...deps, rename })).error).toMatch(/not there any more/);
expect(rename).not.toHaveBeenCalled();
// A rename that fails is an error, not a throw, and nothing is recorded.
mkdirSync(newDir);
const eperm = vi.fn(() => { throw new Error('EPERM: operation not permitted'); });
expect((await retire({ ...deps, rename: eperm })).error).toMatch(/Could not rename .*EPERM/);
expect(loadState(dir).retiredTo).toBeUndefined();
});
});
describe('setup and arming', () => {
it('is off by default: no state file, nothing armed, nothing to disarm', () => {
expect(loadState(dir)).toEqual({ status: 'not set up' });
expect(isArmed(dir)).toBe(false);
expect(setArmed(true, { dir }).error).toMatch(/Set the new account's folder up first/);
expect(setArmed(false, { dir }).error).toMatch(/Nothing is armed/);
expect(existsSync(join(dir, 'account-migration.json'))).toBe(false);
});
it('setup records the folder as ready, then arm and disarm flip it', async () => {
expect((await setup(join(home, 'nope'), deps)).error).toMatch(/does not exist/);
expect(loadState(dir).status).toBe('not set up');
const result = await setup('~/.claude-new', deps);
expect(result.state).toMatchObject({ status: 'ready', folder: newDir, newEmail: NEW, oldEmail: OLD });
if (process.platform !== 'win32') expect(statSync(join(dir, 'account-migration.json')).mode & 0o777).toBe(0o600);
expect(setArmed(true, { dir }).state.status).toBe('armed');
expect(isArmed(dir)).toBe(true);
expect(setArmed(false, { dir }).state.status).toBe('ready');
expect(isArmed(dir)).toBe(false);
// A corrupt state file reads as not set up rather than throwing.
writeFileSync(join(dir, 'account-migration.json'), '{"status":"weird"}');
expect(loadState(dir)).toEqual({ status: 'not set up' });
writeFileSync(join(dir, 'account-migration.json'), '{not json');
expect(loadState(dir)).toEqual({ status: 'not set up' });
expect(isArmed(dir)).toBe(false);
expect(publicState(dir)).toMatchObject({ status: 'not set up', folder: undefined, newEmail: undefined });
});
it('arming again while armed keeps it armed, and a rolled-back state must be checked again first', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
expect(setArmed(true, { dir }).state.status).toBe('armed');
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ ...loadState(dir), status: 'rolled back' }));
expect(setArmed(true, { dir }).error).toMatch(/Set the new account's folder up first/);
expect(setArmed(false, { dir }).error).toMatch(/Nothing is armed/);
// Check again from there brings it back to ready with the state file rewritten.
expect((await setup(newDir, deps)).state.status).toBe('ready');
});
});
describe('what the reviews asked for', () => {
it('one action at a time: a second switch while one runs is refused, and one backup is written', async () => {
// Slow the first switch down at its first `security -w` read.
const base = fakeRun();
let release;
const gate = new Promise(r => { release = r; });
const run = vi.fn(async (f, a, o) => { if (a.includes('-w') && !run.held) { run.held = true; await gate; } return base(f, a, o); });
const first = migrate(newDir, { ...deps, run });
try {
await new Promise(r => setTimeout(r, 20));
expect(await migrate(newDir, deps)).toEqual({ error: BUSY_ERROR });
expect(await rollback(deps)).toEqual({ error: BUSY_ERROR });
expect(await retire(deps)).toEqual({ error: BUSY_ERROR });
expect(await setup(newDir, deps)).toEqual({ error: BUSY_ERROR });
expect(await checkSwitch(newDir, deps)).toEqual({ error: BUSY_ERROR });
// Another server process holds the same lock on disk.
expect(existsSync(join(dir, 'account-migration.lock'))).toBe(true);
} finally { release(); }
expect((await first).ok).toBe(true);
expect(existsSync(join(dir, 'account-migration.lock'))).toBe(false);
expect(readdirSync(join(dir, 'account-backup'))).toHaveLength(1);
// Free again afterwards.
expect((await rollback(deps)).ok).toBe(true);
});
it('a failure before the swap disarms, so the next limit tick can take the ordinary road', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
rmSync(newDir, { recursive: true });
const result = await migrate(newDir, deps);
expect(result.error).toMatch(/does not exist/);
expect(loadState(dir)).toMatchObject({ status: 'ready', error: expect.stringMatching(/does not exist/) });
expect(isArmed(dir)).toBe(false);
expect(publicState(dir).error).toMatch(/does not exist/);
// Not armed: the same failure leaves the state alone.
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ status: 'ready', folder: newDir, newEmail: NEW, oldEmail: OLD }));
await migrate(newDir, deps);
expect(loadState(dir).error).toBeUndefined();
});
it('a folder typed with a trailing slash finds the item claude made for it either way', async () => {
// Logged in as CLAUDE_CONFIG_DIR=~/.claude-new/ : the hash covers the slash.
keychain.delete(keychainService(newDir));
keychain.set(keychainService(`${newDir}/`), { acct: 'bill', secret: NEW_TOKEN });
const pre = await preflight(`${newDir}/`, deps);
expect(pre.folder).toBe(newDir); // stored without the slash
expect(pre.next.folder).toBe(`${newDir}/`); // used as claude hashed it
expect(pre.next.env.CLAUDE_CONFIG_DIR).toBe(`${newDir}/`);
const result = await migrate(`${newDir}///`, deps);
expect(result).toMatchObject({ ok: true, newEmail: NEW });
expect(loadState(dir).folder).toBe(newDir);
expect((await retire(deps)).retiredTo).toBe(`${newDir}.retired-2026-09-27`);
});
it('a token that does not read back as written is a failed swap, rolled back', async () => {
const base = fakeRun();
const run = vi.fn(async (f, a, o) => {
const r = await base(f, a, o);
// The write "succeeds" but the item keeps the old secret.
if (a[0] === '-i' && keychain.get(DEFAULT_SERVICE).secret === NEW_TOKEN) keychain.set(DEFAULT_SERVICE, { acct: 'bill', secret: OLD_TOKEN });
return r;
});
const result = await migrate(newDir, { ...deps, run });
// The item still holds the old token, so there is nothing to undo.
expect(result.error).toMatch(/does not hold the token just written\. Nothing changed\./);
expect(json(defaultJson()).oauthAccount.emailAddress).toBe(OLD);
expect(loadState(dir)).toMatchObject({ status: 'ready', error: expect.stringMatching(/does not hold/) });
});
it("records 'switching' while the swap is in the air, and 'rollback failed' when the restore fails too", async () => {
const seen = [];
const base = fakeRun();
const run = vi.fn(async (f, a, o) => { if (f === 'claude') seen.push(loadState(dir).status); return base(f, a, o); });
await migrate(newDir, { ...deps, run });
expect(seen).toEqual(['not set up', 'not set up', 'switching']); // two preflight checks, then the verify; the backup reuses preflight's email
expect(loadState(dir).status).toBe('migrated');
// Back on the old account, then a config file that breaks once the token has moved: the swap fails
// after its first write, the restore hits the same broken file, and the state says so.
expect(await rollback(deps)).toMatchObject({ ok: true });
const base2 = fakeRun();
const breaking = vi.fn(async (f, a, o) => { const r = await base2(f, a, o); if (a[0] === '-i') writeFileSync(defaultJson(), '[1]'); return r; });
const failed = await migrate(newDir, { ...deps, run: breaking });
expect(failed.rolledBack).toBe(false);
expect(failed.error).toMatch(/not a JSON object.*Rollback failed too/);
expect(loadState(dir)).toMatchObject({ status: 'rollback failed', error: expect.stringMatching(/rollback failed: /) });
expect(canMigrate(dir).error).toMatch(/did not finish cleanly/);
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN); // the restore did put the token back
// Roll back by hand once the file is whole again.
writeFileSync(defaultJson(), JSON.stringify({ numStartups: 40, oauthAccount: { emailAddress: NEW } }));
expect(await rollback(deps)).toMatchObject({ ok: true, email: OLD });
expect(loadState(dir)).toMatchObject({ status: 'rolled back' });
expect(loadState(dir).error).toBeUndefined();
});
it('finds and keeps an item claude wrote under another account name', async () => {
keychain.set(DEFAULT_SERVICE, { acct: 'legacy', secret: OLD_TOKEN });
const result = await migrate(newDir, deps);
expect(result.ok).toBe(true);
expect(deps.run.mock.calls.find(([, a]) => a[0] === '-i')[2].input).toMatch(/-a "legacy"/);
expect(json(join(result.backupDir, 'account.json')).account).toBe('legacy');
expect(keychain.get(DEFAULT_SERVICE)).toEqual({ acct: 'legacy', secret: NEW_TOKEN });
expect([...keychain.keys()]).toEqual([DEFAULT_SERVICE, keychainService(newDir)]);
expect((await rollback(deps)).ok).toBe(true);
expect(keychain.get(DEFAULT_SERVICE)).toEqual({ acct: 'legacy', secret: OLD_TOKEN });
// An account attribute that is not a plain name is not used at all.
keychain.set(DEFAULT_SERVICE, { acct: 'bad name!', secret: OLD_TOKEN });
expect((await migrate(newDir, deps)).error).toMatch(/Could not find the current token in Keychain item "Claude Code-credentials"; nothing changed/);
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN);
});
it('writes its state beside and renames over, and refuses setup once a switch is recorded', async () => {
await migrate(newDir, deps);
expect(readdirSync(dir).filter(f => f.endsWith('.tmp'))).toEqual([]);
expect((await setup(newDir, deps)).error).toMatch(/A switch has been made; roll it back/);
expect(loadState(dir).status).toBe('migrated');
await rollback(deps);
expect((await setup(newDir, deps)).ok).toBe(true);
});
it('checkSwitch is the preflight the server runs before stopping Billion, disarming on failure', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
expect(await checkSwitch(newDir, deps)).toEqual({ ok: true, newEmail: NEW, oldEmail: OLD });
expect(isArmed(dir)).toBe(true);
rmSync(newDir, { recursive: true });
expect((await checkSwitch(newDir, deps)).error).toMatch(/does not exist/);
expect(loadState(dir)).toMatchObject({ status: 'ready', error: expect.stringMatching(/does not exist/) });
});
it('recheck notices a login that went back to the old account after the switch', async () => {
expect(await recheck(deps)).toEqual({ ok: true, skipped: true });
await migrate(newDir, deps);
expect(await recheck({ ...deps, now: () => new Date('2026-09-27T10:00:40Z') })).toEqual({ ok: true, email: NEW });
// A worker wrote the old account back.
writeFileSync(defaultJson(), JSON.stringify({ ...json(defaultJson()), oauthAccount: { emailAddress: OLD } }));
const result = await recheck({ ...deps, now: () => new Date('2026-09-27T10:00:40Z') });
expect(result.error).toMatch(/reports old@example.com, not new@example.com, 40s after the switch; a running Claude Code/);
expect(loadState(dir)).toMatchObject({ status: 'migrated', error: expect.stringMatching(/40s after/) });
expect(publicState(dir).error).toMatch(/written its token back/);
});
it('a lock another server left on disk blocks, unless it is stale or its holder is dead', async () => {
const lock = join(dir, 'account-migration.lock');
mkdirSync(lock, { recursive: true });
writeFileSync(join(lock, 'pid'), '4242');
// Its holder is alive: wait.
expect(await migrate(newDir, { ...deps, alive: () => true })).toEqual({ error: BUSY_ERROR });
expect(await rollback({ ...deps, alive: () => true })).toEqual({ error: BUSY_ERROR });
expect(existsSync(lock)).toBe(true);
// Its holder died mid-action: the owner is not locked out of Roll back for a quarter of an hour.
expect((await migrate(newDir, { ...deps, alive: () => false })).ok).toBe(true);
expect(existsSync(lock)).toBe(false);
// Very old, no pid file: stale by age.
mkdirSync(lock);
const old = new Date(Date.now() - 16 * 60_000);
utimesSync(lock, old, old);
expect((await rollback(deps)).ok).toBe(true);
expect(existsSync(lock)).toBe(false);
// Our own lock carries our pid while held.
let pidSeen;
const run = vi.fn(async (f, a, o) => { if (a[0] === '-i') pidSeen = readFileSync(join(lock, 'pid'), 'utf8'); return fakeRun()(f, a, o); });
await migrate(newDir, { ...deps, run });
expect(pidSeen).toBe(String(process.pid));
});
it('recheck never writes over a state that moved on, and rollback stops when it cannot save what it replaces', async () => {
await migrate(newDir, deps);
// The owner rolls back while the recheck is in the air: its result is dropped.
const racing = vi.fn(async (f, a, o) => {
if (f === 'claude') writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ ...loadState(dir), status: 'rolled back', at: '2026-09-27T10:00:30Z' }));
return fakeRun({ reportEmail: () => OLD })(f, a, o);
});
const result = await recheck({ ...deps, run: racing, now: () => new Date('2026-09-27T10:00:40Z') });
expect(result.error).toMatch(/reports old@example.com/);
expect(loadState(dir)).toMatchObject({ status: 'rolled back', at: '2026-09-27T10:00:30Z' });
expect(loadState(dir).error).toBeUndefined();
// A current login whose config cannot be read is not overwritten by a rollback.
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ ...loadState(dir), status: 'migrated' }));
const good = readFileSync(defaultJson(), 'utf8');
writeFileSync(defaultJson(), 'not json');
const blocked = await rollback(deps);
expect(blocked.error).toMatch(/Rollback not started: the current login could not be backed up first/);
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(NEW_TOKEN);
expect(loadState(dir).status).toBe('migrated');
writeFileSync(defaultJson(), good);
// A missing current token is nothing to preserve: the rollback goes on and puts one there.
keychain.delete(DEFAULT_SERVICE);
expect((await rollback(deps)).ok).toBe(true);
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN);
});
it('withBillionStopped stops before, restarts after with the carried mail, even when the work throws', async () => {
const order = [];
const stop = vi.fn(async () => { order.push('stop'); return ['mail']; });
const start = vi.fn(({ carried }) => { order.push(`start:${carried}`); return { session: { id: 'b2' } }; });
const announce = vi.fn();
expect(await withBillionStopped(async () => { order.push('fn'); return 'ok'; }, { live: () => ({ id: 'b1' }), stop, start, announce })).toBe('ok');
expect(order).toEqual(['stop', 'fn', 'start:mail']);
expect(announce).toHaveBeenCalledWith({ id: 'b2' });
order.length = 0;
await expect(withBillionStopped(() => { order.push('fn'); throw new Error('x'); }, { live: () => ({ id: 'b1' }), stop, start, announce })).rejects.toThrow('x');
expect(order).toEqual(['stop', 'fn', 'start:mail']);
// No Billion: nothing stopped, nothing started.
stop.mockClear(); start.mockClear();
expect(await withBillionStopped(async () => 1, { live: () => null, stop, start, announce })).toBe(1);
expect(stop).not.toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
// A start that reports an existing session is not announced twice; one that fails is reported.
announce.mockClear();
await withBillionStopped(async () => 1, { live: () => ({}), stop, start: () => ({ existing: true, session: {} }), announce });
expect(announce).not.toHaveBeenCalled();
const failed = vi.fn();
expect(await withBillionStopped(async () => 'done', { live: () => ({}), stop, start: () => ({ error: 'no claude' }), announce, failed })).toBe('done');
expect(failed).toHaveBeenCalledWith('no claude');
expect(announce).not.toHaveBeenCalled();
});
it('rolls back from a switch the server died in the middle of, and recheck under another action is busy, not an alarm', async () => {
await migrate(newDir, deps);
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ ...loadState(dir), status: 'switching' }));
expect(await rollback(deps)).toMatchObject({ ok: true, email: OLD });
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN);
expect(loadState(dir).status).toBe('rolled back');
// The recheck timer fires while the owner rolls back: busy, and nothing recorded.
await migrate(newDir, deps);
let release; const gate = new Promise(r => { release = r; });
const base = fakeRun();
const run = vi.fn(async (f, a, o) => { if (a.includes('-w') && !run.held) { run.held = true; await gate; } return base(f, a, o); });
const rb = rollback({ ...deps, run });
try {
await new Promise(r => setTimeout(r, 20));
expect(await recheck(deps)).toEqual({ error: BUSY_ERROR });
expect(loadState(dir).error).toBeUndefined();
} finally { release(); }
expect((await rb).ok).toBe(true);
});
it('the default liveness check tells a dead holder from a live one', async () => {
const lock = join(dir, 'account-migration.lock');
mkdirSync(lock, { recursive: true });
writeFileSync(join(lock, 'pid'), String(spawnSync(process.execPath, ['-e', '0']).pid));
expect((await migrate(newDir, deps)).ok).toBe(true); // dead holder: taken over
expect((await rollback(deps)).ok).toBe(true);
mkdirSync(lock);
writeFileSync(join(lock, 'pid'), String(process.ppid));
expect(await migrate(newDir, deps)).toEqual({ error: BUSY_ERROR }); // live holder: wait
});
it('a folder check while armed stays armed', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
expect((await setup(newDir, deps)).state.status).toBe('armed');
expect(isArmed(dir)).toBe(true);
rmSync(newDir, { recursive: true });
expect((await setup(newDir, deps)).error).toMatch(/does not exist/);
expect(isArmed(dir)).toBe(true); // a failed check changes nothing; Disarm is the owner's call
});
it('canMigrate gates on the state', async () => {
expect(canMigrate(dir).error).toMatch(/Set the new account/);
await setup(newDir, deps);
expect(canMigrate(dir)).toEqual({ ok: true, folder: newDir });
await migrate(newDir, deps);
expect(canMigrate(dir).error).toMatch(/already the new account; roll back first/);
writeFileSync(join(dir, 'account-migration.json'), JSON.stringify({ ...loadState(dir), status: 'switching' }));
expect(canMigrate(dir).error).toMatch(/did not finish cleanly/);
});
it("takes Claude Code's own lock on .claude.json, waits out a fresh one and clears a stale one", async () => {
const file = defaultJson();
const lock = `${file}.lock`;
// A fresh lock: someone is writing; we wait and then give up.
mkdirSync(lock);
const waits = [];
await expect(writeAccountFields(file, { oauthAccount: { emailAddress: NEW } }, { lockTries: 3, wait: async (ms) => waits.push(ms) }))
.rejects.toThrow(/locked by Claude Code \(\.claude\.json\.lock\)/);
expect(waits).toEqual([400, 400, 400]); // 30 of these by default: longer than a fresh lock stays fresh
expect(json(file).oauthAccount.emailAddress).toBe(OLD);
expect(existsSync(lock)).toBe(true); // not ours: left alone
// A stale lock (older than 10 s): claude left it behind, so it is removed and the write goes through.
expect(await writeAccountFields(file, { oauthAccount: { emailAddress: NEW } }, { lockTries: 3, wait: async () => {}, now: () => Date.now() + 60_000 })).toBe(true);
expect(json(file).oauthAccount.emailAddress).toBe(NEW);
expect(existsSync(lock)).toBe(false); // ours, released
expect(readdirSync(home).filter(f => f.includes('.tmp'))).toEqual([]);
});
it('runCommand maps exit codes, never throws, feeds stdin, and hands the child the agent env only', async () => {
const env = { PATH: process.env.PATH, HOME: process.env.HOME, AGENT007_CONFIG_DIR: '/secret', TELEGRAM_BOT_TOKEN: 't', KEEP: 'yes' };
expect((await runCommand(process.execPath, ['-e', 'process.exit(3)'], { env })).code).toBe(3);
// No quotes inside the one-liner: on Windows the argument goes through cmd.exe.
expect((await runCommand(process.execPath, ['-e', 'process.stdout.write(String.fromCharCode(104,105))'], { env })).stdout).toBe('hi');
expect((await runCommand(process.execPath, ['-e', 'process.stdin.pipe(process.stdout)'], { env, input: 'from stdin' })).stdout).toBe('from stdin');
expect((await runCommand('definitely-not-a-binary-a007', [], { env })).code).toBe(1); // ENOENT has a string code
const { stdout } = await runCommand(process.execPath, ['-e', 'process.stdout.write(JSON.stringify(process.env))'], { env });
const child = JSON.parse(stdout);
expect(child.KEEP).toBe('yes');
expect(child.AGENT007_CONFIG_DIR).toBeUndefined();
expect(child.TELEGRAM_BOT_TOKEN).toBeUndefined();
});
});
// The armed trigger, through the same tick that switches Billion to Codex.
describe('the armed switch at a usage limit', () => {
const T0 = 1_000_000_000;
const OUT = "You're out of usage credits. Run /usage-credits to keep using Fable 5.1 or /model to switch models.";
let ids = 0;
const billion = (screen = OUT, over = {}) => ({
id: `acct-${++ids}`, isBillion: true, exited: false, agent: 'claude',
state: 'WAITING', lastOutputAt: T0 - SETTLE_MS, ringBuffer: { getAll: () => [screen] }, ...over,
});
const tick = (session, over = {}) => limitTick(session, {
now: T0, env: {}, log: () => {}, send: vi.fn(), ready: vi.fn(async () => true),
switchTo: vi.fn(async () => ({ session: {} })), notify: vi.fn(async () => ({})), tell: vi.fn(async () => ({})), ...over,
});
const migration = (run) => ({ armed: () => isArmed(dir), run: vi.fn(run || (async () => { await setup(newDir, deps); return migrate(newDir, deps); })) });
it('fires once at the first hard limit, disarms, and the Codex switch never happens', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
const m = migration();
const switchTo = vi.fn(async () => ({ session: {} }));
expect(await tick(billion(), { migration: m, switchTo })).toBe('migrated');
expect(m.run).toHaveBeenCalledTimes(1);
expect(m.run.mock.calls[0][0]).toMatchObject({ kind: 'hard' });
expect(switchTo).not.toHaveBeenCalled();
expect(loadState(dir).status).toBe('migrated');
expect(isArmed(dir)).toBe(false);
// The notice still on screen (say Billion did not restart yet): the ordinary switch now, not a second migration.
expect(await tick(billion(), { migration: m, switchTo })).toBe('switched');
expect(m.run).toHaveBeenCalledTimes(1);
});
it('waits for Billion to go quiet, and only a Claude Billion arms it', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
const m = migration();
expect(await tick(billion(OUT, { state: 'WORKING' }), { migration: m })).toBeNull();
expect(await tick(billion(OUT, { lastOutputAt: T0 - 1000 }), { migration: m })).toBeNull();
const switchTo = vi.fn(async () => ({ session: {} }));
expect(await tick(billion("■ You've hit your usage limit. Try again at 4:05 PM.", { agent: 'codex' }), { migration: m, switchTo })).toBe('switched');
expect(m.run).not.toHaveBeenCalled();
expect(isArmed(dir)).toBe(true);
});
it('runs even with BILLION_AUTO_SWITCH=0, which keeps the Codex switch off', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
const m = migration();
const switchTo = vi.fn(async () => ({ session: {} }));
expect(await tick(billion(), { migration: m, switchTo, env: { BILLION_AUTO_SWITCH: '0' } })).toBe('migrated');
expect(await tick(billion(), { migration: m, switchTo, env: { BILLION_AUTO_SWITCH: '0' } })).toBeNull();
expect(switchTo).not.toHaveBeenCalled();
});
it('a failed migration is rolled back and disarmed, and the next tick switches to Codex', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
const bad = { ...deps, run: fakeRun({ reportEmail: (folder) => (folder ? NEW : OLD) }) };
const m = migration(async () => migrate(newDir, bad));
const switchTo = vi.fn(async () => ({ session: {} }));
expect(await tick(billion(), { migration: m, switchTo })).toBe('migration-failed');
expect(switchTo).not.toHaveBeenCalled();
expect(loadState(dir).status).toBe('rolled back');
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN);
expect(await tick(billion(), { migration: m, switchTo })).toBe('switched');
expect(m.run).toHaveBeenCalledTimes(1);
});
it('a warning still needs BILLION_AUTO_SWITCH: armed alone never nudges, and never migrates', async () => {
const WARN = "You've used 92% of your weekly limit · resets 10am (America/Los_Angeles)";
await setup(newDir, deps);
setArmed(true, { dir });
const m = migration();
const send = vi.fn();
// Auto-switch off: the armed gate lets the tick in, the warning branch sends it back out.
expect(await tick(billion(WARN), { migration: m, send, env: { BILLION_AUTO_SWITCH: '0' } })).toBeNull();
expect(send).not.toHaveBeenCalled();
// Auto-switch on: the nudge as before, and no migration for a mere warning.
expect(await tick(billion(WARN), { migration: m, send })).toBe('warned');
expect(send).toHaveBeenCalledTimes(1);
expect(m.run).not.toHaveBeenCalled();
expect(isArmed(dir)).toBe(true);
});
it('stays out of a paused Billion, and a Codex Billion with auto-switch off is left alone even when armed', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
const m = migration();
const s = billion();
resetLimitWatch({ pausedFor: s.id });
expect(await tick(s, { migration: m })).toBeNull();
resetLimitWatch();
expect(await tick(billion("■ You've hit your usage limit. Try again at 4:05 PM.", { agent: 'codex' }), { migration: m, env: { BILLION_AUTO_SWITCH: '0' } })).toBeNull();
expect(m.run).not.toHaveBeenCalled();
// migration given without armed() (or an exited Billion) is as good as none.
expect(await tick(billion(), { migration: {}, env: { BILLION_AUTO_SWITCH: '0' } })).toBeNull();
expect(await tick(billion(OUT, { exited: true }), { migration: m })).toBeNull();
expect(isArmed(dir)).toBe(true);
});
it('a migration that throws lets go of the tick, so the next one runs', async () => {
await setup(newDir, deps);
setArmed(true, { dir });
const m = { armed: () => isArmed(dir), run: vi.fn(async () => { throw new Error('keychain locked'); }) };
await expect(tick(billion(), { migration: m })).rejects.toThrow(/keychain locked/);
expect(isArmed(dir)).toBe(true); // nothing disarmed it: the switch never got as far as the state file
// watch.running was cleared: the next tick reaches run() again.
await expect(tick(billion(), { migration: m })).rejects.toThrow(/keychain locked/);
expect(m.run).toHaveBeenCalledTimes(2);
});
it('off by default: nothing set up means the tick behaves as before', async () => {
const m = migration();
const switchTo = vi.fn(async () => ({ session: {} }));
expect(await tick(billion(), { migration: m, switchTo })).toBe('switched');
expect(m.run).not.toHaveBeenCalled();
expect(await tick(billion(), { switchTo, env: { BILLION_AUTO_SWITCH: '0' } })).toBeNull();
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(OLD_TOKEN);
});
});
describe('rotation authentication adapter', () => {
it('round-trips through the macOS Keychain while keeping settings, projects and transcript bytes', async () => {
const { addRotationAccount, rotateAccount } = await import('../server/account-rotation.js');
const settings = join(home, '.claude', 'settings.json');
const transcript = join(home, '.claude', 'conversation.jsonl');
writeFileSync(settings, '{"model":"opus","permissions":{"defaultMode":"auto"}}');
writeFileSync(transcript, '{"sessionId":"keep-me","message":"unfinished work"}\n');
const originalConfig = json(defaultJson());
const rotateDeps = { ...deps, now: () => Date.now() };
expect(await addRotationAccount(newDir, rotateDeps)).toMatchObject({ ok: true });
keychain.get(DEFAULT_SERVICE).secret = 'latest-refreshed-old-token';
expect(await rotateAccount({}, rotateDeps)).toMatchObject({ ok: true, newEmail: NEW });
expect(keychain.get(DEFAULT_SERVICE).secret).toBe(NEW_TOKEN);
expect(await rotateAccount({}, rotateDeps)).toMatchObject({ ok: true, newEmail: OLD });
expect(keychain.get(DEFAULT_SERVICE).secret).toBe('latest-refreshed-old-token');
expect(json(defaultJson())).toEqual(originalConfig);
expect(readFileSync(settings, 'utf8')).toBe('{"model":"opus","permissions":{"defaultMode":"auto"}}');
expect(readFileSync(transcript, 'utf8')).toBe('{"sessionId":"keep-me","message":"unfinished work"}\n');
expect(calls.flat().join(' ')).not.toMatch(/latest-refreshed-old-token|sk-ant/);
});
});