test/billion-server.test.js
Rama main · réplica de lectura del repositorio original.
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();
}
});
});