// 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 ? [''] : [])]); 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"="${item.acct}"\n "svce"="${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', '']); 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 to /); 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/); }); });