Ctrl K
ComunidadGuías…

test/account-rotation-server.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// End-to-end local socket + real PTYs; auth is an in-memory fixture and the
// Claude executable is a stub. Never reads or changes a real login.
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import WebSocket from 'ws';

const auth = vi.hoisted(() => ({ current: null, activation: null }));
vi.mock('../server/account-migration.js', async original => {
  const actual = await original();
  return {
    ...actual,
    captureLogin: async folder => { if (folder === '/fixture-claude-fail') throw new Error('no login'); return structuredClone(folder === '/fixture-claude-in' ? { email: 'c@example.com', folder, secret: 'fake-c', fields: { oauthAccount: { accountUuid: 'c', emailAddress: 'c@example.com' } } }
      : folder ? { email: 'b@example.com', folder, secret: 'fake-b', fields: { oauthAccount: { accountUuid: 'b', emailAddress: 'b@example.com' } } } : auth.current); },
    activateLogin: async snapshot => { await auth.activation?.(); auth.current = structuredClone(snapshot); },
  };
});
// Codex logins are fixtures too, and its background server "runs" for Codex checks only.
const codexAuth = vi.hoisted(() => ({ current: null, events: [], check: null }));
vi.mock('../server/codex-login.js', () => ({
  captureCodexLogin: async folder => structuredClone(folder === '/fixture-cb' ? { accountId: 'cx-b', email: 'cb@example.com', folder, secret: 'fake-cb' }
    : folder ? { accountId: `cx${folder}`, email: `${folder.slice(1)}@example.com`, folder, secret: 'fake-cx' } : codexAuth.current),
  activateCodexLogin: async snapshot => { codexAuth.check?.(); codexAuth.events.push('activate'); codexAuth.current = structuredClone(snapshot); },
  stopCodexDaemon: async () => { codexAuth.check?.(); codexAuth.events.push('daemon-stop'); },
}));
// The account scan is a fixture: no real ~/.codex* or ~/.claude* folder is looked at.
// The test hands server.js's scan listener a scan of its own.
const scan = vi.hoisted(() => ({ listener: null }));
vi.mock('../server/agent-accounts.js', async original => ({ ...(await original()), setOnScan: fn => { scan.listener = fn; } }));
// Codex's background server runs until a stop, and is back once a login is written.
vi.mock('../server/claude-processes.js', () => ({ assertClaudeProcessesManaged: async (_sessions, { agent } = {}) => ({ daemon: agent === 'codex' && codexAuth.events.at(-1) !== 'daemon-stop' }) }));
vi.mock('../server/models.js', async original => ({ ...(await original()), modelsReady: async () => true, startModelRefresh: () => {} }));
import { server, sessions, startBillion } from '../server.js';
import { createSessionFromConfig } from '../server/pty.js';
import { nextSessionId, CONFIG_DIR } from '../server/state.js';
import { broadcast } from '../server/ws.js';
import { sendText, pendingMessages } from '../server/messages.js';
import { quote } from '../lib/jobs.js';
import { removeTempDir } from './temp-dir.js';

let root, executable, url;
const wait = async fn => { const end = Date.now() + 12000; while (Date.now() < end) { const result = fn(); if (result) return result; await new Promise(r => setTimeout(r, 25)); } throw Error('Timed out'); };
beforeAll(async () => {
  root = mkdtempSync(join(tmpdir(), 'rotation-server-'));
  executable = join(root, process.platform === 'win32' ? 'claude.cmd' : 'claude');
  const script = join(root, 'stub.cjs');
  writeFileSync(script, 'console.log("fixture started " + JSON.stringify(process.argv.slice(2))); setInterval(() => {}, 1000);');
  writeFileSync(executable, process.platform === 'win32' ? `@echo off\r\n"${process.execPath}" "${script}" %*\r\n` : `#!/bin/sh\nexec "${process.execPath}" "${script}" "$@"\n`, { mode: 0o755 });
  auth.current = { email: 'a@example.com', folder: '/default', secret: 'fake-a', fields: { oauthAccount: { accountUuid: 'a', emailAddress: 'a@example.com' } } };
  server.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r));
  url = `ws://127.0.0.1:${server.address().port}`;
});
afterAll(async () => {
  const all = [...sessions.values()];
  for (const s of all) { clearInterval(s.stateCheckInterval); clearTimeout(s.scanTimer); try { s.pty.kill(); } catch {} }
  // The stubs run with root as their cwd, and Windows will not remove a folder
  // a live process sits in, so wait for every PTY to report its exit.
  await wait(() => all.every(s => s.exited)).catch(() => {});
  sessions.clear();
  await new Promise(r => server.close(r));
  removeTempDir(root);
});

describe('rotation through the owner socket', () => {
  it('stops two real PTYs before changing auth and resumes both exact conversations with their job links', async () => {
    const ws = new WebSocket(url, { headers: { origin: url.replace('ws:', 'http:') } }), seen = [];
    ws.on('message', data => seen.push(JSON.parse(data)));
    await new Promise(r => ws.once('open', r));
    const old = [];
    try {
      for (const isBillion of [true, false]) {
        const result = createSessionFromConfig({ sessionId: nextSessionId(), name: isBillion ? 'Billion' : 'Worker', command: `${quote(executable)} --permission-mode auto "initial task"`, cwd: root, isBillion, spawnedBy: isBillion ? 'user' : 'board', jobId: isBillion ? null : 'fixture-job' }, broadcast);
        expect(result.error).toBeUndefined();
        sessions.set(result.session.id, result.session); old.push(result.session);
      }
      await wait(() => old.every(s => s.ringBuffer.getAll().join('').includes('fixture started')));
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-add', folder: '/fixture-b' }));
      const enrolled = await wait(() => seen.find(m => m.type === 'account-state' && m.rotation?.accounts.length === 2));
      expect(enrolled.billionCli).toBe('claude');
      const target = enrolled.rotation.accounts.find(a => a.email === 'b@example.com');
      auth.activation = () => expect(old.every(s => s.exited)).toBe(true);
      old[0].messagesHeld = true;
      sendText(old[0], 'fixture pending mail');
      const at = seen.length;
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-switch', id: target.id }));
      await wait(() => seen.slice(at).find(m => m.type === 'account-state' && m.rotation?.active === target.id));
      expect(auth.current.email).toBe('b@example.com');
      expect(pendingMessages(old[0].id)).toBe(1);
      for (const previous of old) {
        const resumed = sessions.get(previous.id);
        expect(resumed).not.toBe(previous);
        expect(resumed.claudeSessionId).toBe(previous.claudeSessionId);
        expect(resumed.jobId).toBe(previous.jobId);
        expect(resumed.cwd).toBe(root);
        await wait(() => resumed.ringBuffer.getAll().join('').includes('fixture started'));
        const output = resumed.ringBuffer.getAll().join('');
        expect(output).toContain('--resume');
        expect(output).toContain(previous.claudeSessionId);
        expect(output).not.toContain('initial task');
      }
      expect(seen.slice(at).filter(m => m.type === 'session-ended')).toHaveLength(0);
      expect(JSON.stringify(seen)).not.toMatch(/fake-a|fake-b/);
    } finally { ws.close(); }
  }, 20000);
  it('retains mail and exact session IDs when restart fails, then retries through Settings', async () => {
    const ws = new WebSocket(url, { headers: { origin: url.replace('ws:', 'http:') } }), seen = [];
    ws.on('message', data => seen.push(JSON.parse(data)));
    await new Promise(r => ws.once('open', r));
    const previous = [...sessions.values()].filter(s => !s.exited);
    const billion = previous.find(s => s.isBillion);
    const queueSize = pendingMessages(billion.id);
    expect(queueSize).toBeGreaterThan(0);
    const state = JSON.parse(readFileSync(join(CONFIG_DIR, 'account-rotation.json'), 'utf8'));
    const target = state.accounts.find(a => a.id !== state.active);
    const hidden = `${executable}.unavailable`;
    try {
      auth.activation = () => { if (existsSync(executable)) renameSync(executable, hidden); };
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-switch', id: target.id }));
      await wait(() => seen.find(m => m.type === 'account-state' && m.rotation?.resumePending));
      for (const old of previous) {
        expect(sessions.get(old.id)).toBe(old);
        expect(old.exited).toBe(true);
        expect(old.rotationResume).toBe(true);
      }
      expect(pendingMessages(billion.id)).toBe(queueSize);
      expect((await startBillion()).error).toMatch(/Retry the paused Claude conversations/);
      expect(sessions.get(billion.id)).toBe(billion);
      renameSync(hidden, executable);
      auth.activation = null;
      const at = seen.length;
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-resume' }));
      await wait(() => seen.slice(at).find(m => m.type === 'account-state' && m.rotation?.resumePending === false));
      for (const old of previous) {
        const resumed = sessions.get(old.id);
        expect(resumed).not.toBe(old);
        expect(resumed.claudeSessionId).toBe(old.claudeSessionId);
        expect(resumed.rotationResume).toBe(false);
        await wait(() => resumed.ringBuffer.getAll().join('').includes('fixture started'));
      }
      expect(pendingMessages(billion.id)).toBe(queueSize);
    } finally {
      auth.activation = null;
      if (existsSync(hidden)) renameSync(hidden, executable);
      ws.close();
    }
  }, 20000);
  it('blocks new Claude processes after a crash left an unfinished authentication write', () => {
    const path = join(CONFIG_DIR, 'account-rotation.json');
    const original = readFileSync(path, 'utf8');
    const state = JSON.parse(original);
    state.pending = { from: state.active, to: state.accounts[0].id };
    writeFileSync(path, JSON.stringify(state));
    try {
      const result = createSessionFromConfig({ sessionId: nextSessionId(), name: 'Blocked', command: quote(executable), cwd: root, rotationRestart: true }, broadcast);
      expect(result.error).toMatch(/Restore the interrupted Claude login/);
      expect(result.session).toBeUndefined();
    } finally { writeFileSync(path, original); }
  });

  it('keeps an open Billion inbox, held worker inbox and scheduled wake across rotation', async () => {
    const ws = new WebSocket(url, { headers: { origin: url.replace('ws:', 'http:') } }), seen = [];
    ws.on('message', data => seen.push(JSON.parse(data)));
    await new Promise(r => ws.once('open', r));
    const previous = [...sessions.values()].filter(s => !s.exited);
    const billion = previous.find(s => s.isBillion), worker = previous.find(s => !s.isBillion);
    billion.messagesHeld = false; worker.messagesHeld = true;
    billion.wakeAt = Date.now() + 600_000; billion.lastWakeAt = Date.now() - 60_000;
    const state = JSON.parse(readFileSync(join(CONFIG_DIR, 'account-rotation.json'), 'utf8'));
    const target = state.accounts.find(a => a.id !== state.active);
    try {
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-switch', id: target.id }));
      await wait(() => previous.every(s => sessions.get(s.id) !== s));
      expect(sessions.get(billion.id).messagesHeld).toBe(false);
      expect(sessions.get(worker.id).messagesHeld).toBe(true);
      expect(sessions.get(billion.id).wakeAt).toBe(billion.wakeAt);
      expect(sessions.get(billion.id).lastWakeAt).toBe(billion.lastWakeAt);
      await wait(() => seen.find(m => m.type === 'account-state' && m.rotation?.active === target.id));
    } finally { ws.close(); }
  }, 20000);

  it('honors a stop requested during activation after the replacement session starts', async () => {
    const ws = new WebSocket(url, { headers: { origin: url.replace('ws:', 'http:') } }), seen = [];
    ws.on('message', data => seen.push(JSON.parse(data)));
    await new Promise(r => ws.once('open', r));
    const worker = [...sessions.values()].find(s => !s.exited && !s.isBillion);
    const state = JSON.parse(readFileSync(join(CONFIG_DIR, 'account-rotation.json'), 'utf8'));
    const target = state.accounts.find(a => a.id !== state.active);
    try {
      auth.activation = async () => {
        ws.send(JSON.stringify({ type: 'kill', sessionId: worker.id }));
        // Let the real WebSocket handler receive stop while activation remains pending.
        await new Promise(r => setTimeout(r, 100));
      };
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-switch', id: target.id }));
      await wait(() => seen.find(m => m.type === 'account-state' && m.rotation?.active === target.id));
      await wait(() => !sessions.has(worker.id));
      expect(sessions.has(worker.id)).toBe(false);
    } finally { auth.activation = null; ws.close(); }
  }, 20000);

  // Value: protects=a Codex switch from the owner socket stops only Codex PTYs, stops the background server, swaps, and resumes `codex resume <id>` with Claude untouched;
  //   fails_when=server.js routes msg.cli to Claude's registry, skips or misorders stopCodexDaemon, restarts Claude sessions, or loses the Codex conversation id;
  //   why_new=codex-rotation.test.js tests the pieces in isolation; nothing drives server.js's Codex wiring end to end; seam=none
  it.skipIf(process.platform === 'win32')('switches Codex logins: only Codex restarts, its background server stops first, and it resumes its exact conversation', async () => {
    const ws = new WebSocket(url, { headers: { origin: url.replace('ws:', 'http:') } }), seen = [];
    ws.on('message', data => seen.push(JSON.parse(data)));
    await new Promise(r => ws.once('open', r));
    const savedHome = process.env.CODEX_HOME;
    const codexHome = join(root, 'codex-home'), cwd = join(root, 'cx'), id = '019a0000-0000-7000-8000-00000000c0de';
    mkdirSync(join(codexHome, 'sessions', '2026', '10', '08'), { recursive: true }); mkdirSync(cwd);
    process.env.CODEX_HOME = codexHome;
    const codexExe = join(root, 'codex');
    writeFileSync(codexExe, `#!/bin/sh\nexec "${process.execPath}" "${join(root, 'stub.cjs')}" "$@"\n`, { mode: 0o755 });
    codexAuth.current = { accountId: 'cx-a', email: 'ca@example.com', folder: codexHome, secret: 'fake-ca' };
    const claudeBefore = [...sessions.values()].filter(s => !s.exited);
    const claudeAccounts = JSON.parse(readFileSync(join(CONFIG_DIR, 'account-rotation.json'), 'utf8')).accounts.length;
    try {
      const made = createSessionFromConfig({ sessionId: nextSessionId(), name: 'CodexWorker', command: `${quote(codexExe)} -m gpt-5.5 "initial task"`, cwd, spawnedBy: 'board', jobId: 'codex-job' }, broadcast);
      expect(made.error).toBeUndefined();
      const old = made.session; sessions.set(old.id, old);
      expect(old.agent).toBe('codex');
      await wait(() => old.ringBuffer.getAll().join('').includes('fixture started'));
      // Codex records its conversation once running: a rollout older than the session would not count.
      writeFileSync(join(codexHome, 'sessions', '2026', '10', '08', `rollout-${id}.jsonl`), `${JSON.stringify({ type: 'session_meta', payload: { id, cwd, source: 'cli' } })}\n`);
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-add', cli: 'codex', folder: '/fixture-cb' }));
      const enrolled = await wait(() => seen.find(m => m.type === 'account-state' && m.codexRotation?.accounts.length === 2));
      expect(enrolled.rotation.accounts).toHaveLength(claudeAccounts);
      const target = enrolled.codexRotation.accounts.find(a => a.email === 'cb@example.com');
      codexAuth.check = () => expect(old.exited).toBe(true);
      const at = seen.length;
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-switch', cli: 'codex', id: target.id }));
      await wait(() => seen.slice(at).find(m => m.type === 'account-state' && m.codexRotation?.active === target.id));
      expect(codexAuth.events).toEqual(['daemon-stop', 'activate']);
      expect(codexAuth.current.email).toBe('cb@example.com');
      for (const s of claudeBefore) expect(sessions.get(s.id)).toBe(s);
      const resumed = sessions.get(old.id);
      expect(resumed).not.toBe(old);
      expect(resumed.jobId).toBe('codex-job');
      await wait(() => resumed.ringBuffer.getAll().join('').includes('fixture started'));
      const output = resumed.ringBuffer.getAll().join('');
      expect(output).toContain(`"resume","${id}"`);
      expect(output).not.toContain('initial task');
      expect(JSON.stringify(seen)).not.toMatch(/fake-c[ab]/);
      // Idle after its restart, Codex has written nothing newer than this session to the rollout:
      // its own `codex resume <id>` still names the conversation, so it can rotate again.
      const back = enrolled.codexRotation.accounts.find(a => a.email === 'ca@example.com');
      codexAuth.check = () => expect(resumed.exited).toBe(true);
      const again = seen.length;
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-switch', cli: 'codex', id: back.id }));
      await wait(() => seen.slice(again).find(m => m.type === 'account-state' && m.codexRotation?.active === back.id));
      expect(seen.slice(again).filter(m => m.type === 'account-error')).toEqual([]);
      const third = sessions.get(old.id);
      await wait(() => third !== resumed && third.ringBuffer.getAll().join('').includes('fixture started'));
      expect(third.ringBuffer.getAll().join('')).toContain(`"resume","${id}"`);
    } finally {
      codexAuth.check = null;
      if (savedHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = savedHome;
      ws.close();
    }
  }, 20000);

  // Value: protects=two app Codex sessions in one folder cannot be told apart, so the Codex switch refuses before stopping either or touching auth.json;
  //   fails_when=codexIdFor stops checking for a second live Codex session in the same folder and resumes both on the newest rollout;
  //   why_new=the Codex socket test above has one Codex session; codex-rotation.test.js passes idFor null directly; seam=none
  it.skipIf(process.platform === 'win32')('refuses a Codex switch before stopping anything when two app Codex sessions share a folder', async () => {
    const ws = new WebSocket(url, { headers: { origin: url.replace('ws:', 'http:') } }), seen = [];
    ws.on('message', data => seen.push(JSON.parse(data)));
    await new Promise(r => ws.once('open', r));
    const savedHome = process.env.CODEX_HOME;
    process.env.CODEX_HOME = join(root, 'codex-home');
    const first = [...sessions.values()].find(s => s.agent === 'codex' && !s.exited);
    const events = codexAuth.events.length, email = codexAuth.current.email;
    try {
      const made = createSessionFromConfig({ sessionId: nextSessionId(), name: 'CodexTwin', command: `${quote(join(root, 'codex'))} "other task"`, cwd: first.cwd, spawnedBy: 'board' }, broadcast);
      expect(made.error).toBeUndefined();
      const twin = made.session; sessions.set(twin.id, twin);
      await wait(() => twin.ringBuffer.getAll().join('').includes('fixture started'));
      // A conversation recorded in the folder after both started: either session would claim it, so only the sharing check refuses.
      const rid = '019a0000-0000-7000-8000-00000000beef';
      writeFileSync(join(root, 'codex-home', 'sessions', '2026', '10', '08', `rollout-${rid}.jsonl`), `${JSON.stringify({ type: 'session_meta', payload: { id: rid, cwd: first.cwd, source: 'cli' } })}\n`);
      const state = JSON.parse(readFileSync(join(CONFIG_DIR, 'codex-account-rotation.json'), 'utf8'));
      ws.send(JSON.stringify({ type: 'account', action: 'rotation-switch', cli: 'codex', id: state.accounts.find(a => a.id !== state.active).id }));
      const refused = await wait(() => seen.find(m => m.type === 'account-error'));
      expect(refused.message).toMatch(/exact Codex conversation/);
      for (const s of [first, twin]) { expect(sessions.get(s.id)).toBe(s); expect(s.exited).toBeFalsy(); expect(s.accountRotating).toBeFalsy(); }
      expect(codexAuth.events).toHaveLength(events);
      expect(codexAuth.current.email).toBe(email);
    } finally {
      if (savedHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = savedHome;
      ws.close();
    }
  }, 20000);

  // Value: protects=a scan enrolls its logged-in Claude and Codex logins, each into its own registry, never logged-out ones or other CLIs';
  //   fails_when=enrollScanned covers one CLI only, files a login under the other CLI, drops the loggedIn filter, or no longer listens to scans;
  //   why_new=Find logged-in accounts is gone: the scan itself enrolls; seam=setOnScan
  it('enrolls the logged-in Claude and Codex logins a scan finds, each into its own registry', async () => {
    const ws = new WebSocket(url, { headers: { origin: url.replace('ws:', 'http:') } }), seen = [];
    ws.on('message', data => seen.push(JSON.parse(data)));
    await new Promise(r => ws.once('open', r));
    const agents = [
      { cli: 'claude', accounts: [{ folder: '/fixture-claude-in', loggedIn: true }, { folder: '/fixture-claude-fail', loggedIn: true }] },
      { cli: 'codex', accounts: [{ folder: '/fixture-cx-in', loggedIn: true }, { folder: '/fixture-cx-out', loggedIn: false }] },
      { cli: 'gemini', accounts: [{ folder: '/fixture-gemini', loggedIn: true }] },
    ];
    const claudeAccounts = JSON.parse(readFileSync(join(CONFIG_DIR, 'account-rotation.json'), 'utf8')).accounts.length;
    // Its own default login: it runs on Windows too, where the Codex switch test above is skipped.
    codexAuth.current ||= { accountId: 'cx-a', email: 'ca@example.com', folder: '/fixture-default', secret: 'fake-ca' };
    try {
      scan.listener({ scannedAt: Date.now(), agents });
      const found = await wait(() => seen.find(m => m.type === 'account-state' && m.codexRotation?.accounts.some(a => a.folder === '/fixture-cx-in')));
      expect(found.codexRotation.accounts.map(a => a.folder)).not.toContain('/fixture-cx-out');
      expect(found.codexRotation.accounts.map(a => a.folder)).not.toContain('/fixture-claude-in');
      expect([...found.rotation.accounts, ...found.codexRotation.accounts].map(a => a.folder)).not.toContain('/fixture-gemini');
      expect(found.rotation.accounts).toHaveLength(claudeAccounts + 1);
      expect(found.rotation.accounts.at(-1).folder).toBe('/fixture-claude-in');
      // A login that cannot be read is said in Settings, not only in the server log.
      expect(seen.filter(m => m.type === 'account-error').map(m => m.message)).toEqual([expect.stringMatching(/^\/fixture-claude-fail was not added to Claude account switching: Could not read this login/)]);
    } finally { ws.close(); }
  }, 20000);

});