import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import WebSocket from 'ws'; import { mkdtempSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { server, sessions, killSession } from '../server.js'; import { createSessionFromConfig } from '../server/pty.js'; import { broadcast } from '../server/ws.js'; import { handleUpdate, savedChatId } from '../server/owner.js'; import { codenamePool, nextSessionId, CONFIG_DIR } from '../server/state.js'; import { BILLION_NAME } from '../server/billion.js'; import { hashToken } from '../server/auth.js'; // A stand-in for Billion that runs anywhere: the real one would start Claude // Code. What is under test is how the server treats a session marked isBillion. const idle = `"${process.execPath}" -e "setInterval(() => {}, 1000)"`; function spawn({ isBillion = false, name }) { const { session, error } = createSessionFromConfig({ sessionId: nextSessionId(), name, color: '#fff', command: idle, repoPath: null, worktreePath: null, cwd: mkdtempSync(join(tmpdir(), 'a007-billion-cwd-')), isBillion, ownerId: null, }, broadcast); if (error) throw new Error(error); sessions.set(session.id, session); return session; } let wsUrl; // any free port, set once listening const connect = (opts = {}) => new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl, opts); const seen = []; ws.on('message', (d) => seen.push(JSON.parse(d))); ws.on('open', () => resolve({ ws, seen })); ws.on('error', reject); }); const waitFor = async (seen, pred, ms = 4000) => { const end = Date.now() + ms; while (Date.now() < end) { const m = seen.find(pred); if (m) return m; await new Promise(r => setTimeout(r, 20)); } return null; }; beforeAll(async () => { server.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r)); wsUrl = `ws://127.0.0.1:${server.address().port}`; codenamePool.reserve(BILLION_NAME); // what startup() does }); afterAll(async () => { for (const [id, s] of sessions) { clearInterval(s.stateCheckInterval); try { s.pty.kill(); } catch {} sessions.delete(id); } await new Promise(r => server.close(r)); }); describe('Billion on the server', () => { it('tells a new window it runs Billion, and replays Billion first', async () => { const other = spawn({ name: 'Viper' }); // older than Billion const billion = spawn({ isBillion: true, name: BILLION_NAME }); const { ws, seen } = await connect(); const welcome = await waitFor(seen, m => m.type === 'welcome'); expect(welcome.billionEnabled).toBe(true); await waitFor(seen, m => m.type === 'session-created' && m.sessionId === other.id); const created = seen.filter(m => m.type === 'session-created'); expect(created[0].sessionId).toBe(billion.id); expect(created[0].isBillion).toBe(true); expect(created.find(m => m.sessionId === other.id).isBillion).toBe(false); ws.close(); await killSession(other.id); await killSession(billion.id); }); it('refuses to rename Billion', async () => { const billion = spawn({ isBillion: true, name: BILLION_NAME }); const { ws, seen } = await connect(); ws.send(JSON.stringify({ type: 'rename-session', sessionId: billion.id, name: 'Bob' })); const refused = await waitFor(seen, m => m.type === 'notification' && /can't be changed/.test(m.message)); expect(refused).toBeTruthy(); expect(billion.name).toBe(BILLION_NAME); ws.close(); await killSession(billion.id); }); it('keeps the name reserved after Billion is closed', async () => { const billion = spawn({ isBillion: true, name: BILLION_NAME }); await killSession(billion.id); expect(sessions.has(billion.id)).toBe(false); expect(codenamePool.has(BILLION_NAME)).toBe(true); }); }); // The owner's Claude account switch over the socket (server.js accountAction, // server/ws.js 'account'). Every action here fails before the module reaches // `security` or `claude`: CONFIG_DIR is the suite's temp dir with nothing set // up, and the one folder given is relative. Nothing on this machine is touched. describe('the Claude account switch over the socket', () => { // A refusal comes back as account-error, for the panel to show in place. const refusal = async (ws, seen, msg) => { const before = seen.length; ws.send(JSON.stringify({ type: 'account', ...msg })); return waitFor(seen, (m, i) => i >= before && m.type === 'account-error'); }; // What a browser sends; a socket without it is not a browser (server/ws.js). const browser = () => ({ headers: { origin: `http://127.0.0.1:${new URL(wsUrl).port}` } }); it('refuses a socket that carries no Origin, or one from another local port: not this server\'s page', async () => { for (const opts of [{}, { headers: { origin: 'http://127.0.0.1:9' } }, { headers: { origin: 'http://localhost:9' } }]) { const { ws, seen } = await connect(opts); const got = await refusal(ws, seen, { action: 'setup', folder: '/tmp/never-read' }); expect(got.message, JSON.stringify(opts)).toMatch(/switched from the browser only/); expect(seen.some(m => m.type === 'account-state')).toBe(false); // nor is the state sent to it ws.close(); } }); it('tells a new window the state, and every action answers with its error', async () => { const { ws, seen } = await connect(browser()); const state = await waitFor(seen, m => m.type === 'account-state'); expect(state).toMatchObject({ status: 'not set up' }); expect(JSON.stringify(state)).not.toMatch(/backupDir/); for (const [msg, error] of [ [{ action: 'bogus' }, /Unknown account action bogus/], [{ action: 'constructor' }, /Unknown account action constructor/], [{ action: ['migrate'] }, /Unknown account action migrate/], [{ action: 'setup', folder: 'claude-new' }, /absolute path/], [{ action: 'arm' }, /Set the new account's folder up first/], [{ action: 'migrate' }, /Set the new account's folder up first/], [{ action: 'arm', on: false }, /Nothing is armed/], [{ action: 'rollback' }, /Nothing to roll back: no switch has been made/], [{ action: 'retire' }, /Only a folder whose account has been switched to/], ]) { const got = await refusal(ws, seen, msg); expect(got?.message, msg.action).toMatch(error); } // Each action re-broadcasts the state, still not set up. expect(seen.filter(m => m.type === 'account-state').length).toBeGreaterThan(1); expect(seen.filter(m => m.type === 'account-state').every(m => m.status === 'not set up')).toBe(true); ws.close(); }); it('a switch that fails before the swap reaches the owner as a notification, with no Billion to restart', async () => { const cfg = process.env.AGENT007_CONFIG_DIR; // A folder that exists but never logged in: preflight fails before any `security` or `claude` call. const folder = mkdtempSync(join(tmpdir(), 'acct-empty-')); writeFileSync(join(cfg, 'account-migration.json'), JSON.stringify({ status: 'ready', folder, newEmail: 'new@x', oldEmail: 'old@x' })); expect([...sessions.values()].some(s => s.isBillion && !s.exited)).toBe(false); // nothing to stop or start try { const { ws, seen } = await connect(browser()); expect(await waitFor(seen, m => m.type === 'account-state' && m.status === 'ready')).toBeTruthy(); const before = seen.length; ws.send(JSON.stringify({ type: 'account', action: 'migrate' })); // The click gets its answer over the socket, once, for the panel: no broadcast copy. const note = await waitFor(seen, (m, i) => i >= before && m.type === 'account-error' && /\.claude\.json/.test(m.message)); try { expect(note).toBeTruthy(); expect(note.message).not.toMatch(/sk-ant|Claude account switch to/); await new Promise(r => setTimeout(r, 200)); expect(seen.slice(before).filter(m => m.type === 'account-error')).toHaveLength(1); expect(seen.slice(before).filter(m => m.type === 'notification')).toHaveLength(0); expect(seen.slice(before).some(m => m.type === 'account-state' && m.status === 'ready')).toBe(true); } finally { ws.close(); } } finally { rmSync(join(cfg, 'account-migration.json'), { force: true }); } }); it('a browser action reaches browser sockets only', async () => { const plain = await connect(); const { ws, seen } = await connect(browser()); const before = plain.seen.length; ws.send(JSON.stringify({ type: 'account', action: 'arm' })); await waitFor(seen, m => m.type === 'account-error'); await new Promise(r => setTimeout(r, 100)); expect(plain.seen.slice(before).filter(m => m.type === 'account-state' || m.type === 'account-error' || m.type === 'notification')).toEqual([]); ws.close(); plain.ws.close(); }); it('with user accounts on, nobody switches the account', async () => { const usersPath = process.env.AGENT007_USERS_PATH; const token = 'tokAcct_' + Math.random().toString(36).slice(2, 10); writeFileSync(usersPath, JSON.stringify([{ id: 'u_acct', displayName: 'Owner', color: '#d4a847', tokenHash: hashToken(token) }])); try { const { ws, seen } = await new Promise((resolve, reject) => { const s = new WebSocket(`${wsUrl}/?token=${encodeURIComponent(token)}`); const seen = []; s.on('message', (d) => seen.push(JSON.parse(d))); s.on('open', () => resolve({ ws: s, seen })); s.on('error', reject); }); expect((await waitFor(seen, m => m.type === 'welcome')).authEnabled).toBe(true); const got = await refusal(ws, seen, { action: 'setup', folder: '/tmp/never-read' }); expect(got.message).toMatch(/Only the owner switches accounts, and with user accounts on nobody does/); ws.close(); } finally { rmSync(usersPath, { force: true }); } }); }); // The Billion tab's text box over the socket (server/ws.js 'chat-send'). describe('the Billion tab over the socket', () => { const browser = () => ({ headers: { origin: `http://127.0.0.1:${new URL(wsUrl).port}` } }); const say = async ({ ws, seen }, text) => { const nonce = `n${Math.random()}`; ws.send(JSON.stringify({ type: 'chat-send', nonce, text })); return waitFor(seen, m => m.type === 'chat-sent' && m.nonce === nonce); }; it('sends the thread on connect, and a browser\'s message is taken for Billion and shown to every window', async () => { const billion = spawn({ isBillion: true, name: BILLION_NAME }); const other = await connect(browser()); const owner = await connect(browser()); try { expect(await waitFor(owner.seen, m => m.type === 'chat-list')).toBeTruthy(); expect(await say(owner, 'hello from the tab')).not.toHaveProperty('error'); expect(await waitFor(other.seen, m => m.type === 'chat-message' && m.message.text === 'hello from the tab')).toMatchObject({ message: { from: 'owner', via: 'app' } }); } finally { owner.ws.close(); other.ws.close(); await killSession(billion.id); } }); it('takes a message\'s files and serves them back from chat-files only, to the owner only', async () => { const billion = spawn({ isBillion: true, name: BILLION_NAME }); const owner = await connect(browser()); const http = `http://127.0.0.1:${new URL(wsUrl).port}`; try { const nonce = 'files1'; owner.ws.send(JSON.stringify({ type: 'chat-send', nonce, text: '', files: [{ name: 'shot.png', type: 'image/png', data: Buffer.from('PNG').toString('base64') }] })); expect(await waitFor(owner.seen, m => m.type === 'chat-sent' && m.nonce === nonce)).not.toHaveProperty('error'); const { message } = await waitFor(owner.seen, m => m.type === 'chat-message' && m.message.files); const res = await fetch(`${http}/api/chat/${message.id}/files/shot.png`); expect([res.status, await res.text(), res.headers.get('content-security-policy')]).toEqual([200, 'PNG', 'sandbox']); for (const path of [`${message.id}/files/other.png`, `${message.id}/files/..%2F..%2Fchat.json`, `..%2F/files/chat.json`]) { expect((await fetch(`${http}/api/chat/${path}`)).status).toBe(404); } // With user accounts on the chat is nobody's, and so are its files. const usersPath = process.env.AGENT007_USERS_PATH; const token = 'tokFile_' + Math.random().toString(36).slice(2, 10); writeFileSync(usersPath, JSON.stringify([{ id: 'u_file', displayName: 'Owner', color: '#d4a847', tokenHash: hashToken(token) }])); try { expect((await fetch(`${http}/api/chat/${message.id}/files/shot.png`)).status).toBe(401); expect((await fetch(`${http}/api/chat/${message.id}/files/shot.png?token=${token}`)).status).toBe(403); } finally { rmSync(usersPath, { force: true }); } } finally { owner.ws.close(); await killSession(billion.id); } }); it('refuses a socket that is not this server\'s page, and never shows it the thread', async () => { const plain = await connect(); const owner = await connect(browser()); try { expect((await say(plain, 'hi')).error).toMatch(/browser only/); broadcast({ type: 'chat-message', message: { id: 'x', from: 'billion', text: 'private' } }); expect(await waitFor(owner.seen, m => m.type === 'chat-message' && m.message.id === 'x')).toBeTruthy(); await new Promise(r => setTimeout(r, 100)); expect(plain.seen.filter(m => m.type === 'chat-list' || m.type === 'chat-message')).toEqual([]); } finally { plain.ws.close(); owner.ws.close(); } }); }); // "Use this chat" on a Telegram chat offer (server/owner.js useTelegramChat). describe('connecting Telegram over the socket', () => { const browser = () => ({ headers: { origin: `http://127.0.0.1:${new URL(wsUrl).port}` } }); it('shows the offer to the owner\'s page only, and only that page can take it', async () => { const plain = await connect(); const owner = await connect(browser()); const quiet = console.log; console.log = () => {}; try { await handleUpdate({ update_id: 1, message: { chat: { id: 555, type: 'private' }, from: { first_name: 'Ann' }, text: 'hi' } }, { env: { TELEGRAM_BOT_TOKEN: '1:x' }, broadcast }); expect(await waitFor(owner.seen, m => m.type === 'telegram-state' && m.offers.length)).toMatchObject({ offers: [{ chatId: '555', name: 'Ann' }] }); plain.ws.send(JSON.stringify({ type: 'telegram-use', chatId: '555' })); expect((await waitFor(plain.seen, m => m.type === 'notification' && m.level === 'error')).message).toMatch(/browser only/); expect(savedChatId()).toBe(''); owner.ws.send(JSON.stringify({ type: 'telegram-use', chatId: '555' })); expect(await waitFor(owner.seen, m => m.type === 'telegram-state' && m.connected)).toMatchObject({ connectedTo: 'Ann', offers: [] }); expect(savedChatId()).toBe('555'); const from = (seen) => { const at = seen.length; return (pred) => waitFor(seen, m => seen.indexOf(m) >= at && pred(m)); }; const plainNext = from(plain.seen); plain.ws.send(JSON.stringify({ type: 'telegram-forget' })); expect((await plainNext(m => m.type === 'notification')).message).toMatch(/browser only/); expect(savedChatId()).toBe('555'); const ownerNext = from(owner.seen); owner.ws.send(JSON.stringify({ type: 'telegram-forget' })); expect(await ownerNext(m => m.type === 'telegram-state')).toMatchObject({ connected: false }); expect(savedChatId()).toBe(''); expect(plain.seen.filter(m => m.type === 'telegram-state')).toEqual([]); } finally { console.log = quiet; rmSync(join(CONFIG_DIR, 'telegram-chat.json'), { force: true }); plain.ws.close(); owner.ws.close(); } }); });