test/server.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { app, server, startup, sessions, killSession } from '../server.js';
import { pendingMessages } from '../server/messages.js';
import { countInFlightByRepo, selectDispatchableJobs } from '../lib/jobs.js';
import { hashToken, WS_UNAUTHORIZED } from '../server/auth.js';
import { addJob, deleteJob, moveJob, allJobs, updateSettings } from '../server/jobs.js';
import { config, orphans, codenamePool } from '../server/state.js';
import RawWebSocket from 'ws';
import { tmpdir } from 'os';
import { mkdirSync, mkdtempSync, existsSync, writeFileSync, rmSync, realpathSync, readFileSync, lstatSync } from 'fs';
import { join, basename } from 'path';
import { execFileSync } from 'child_process';
import { WebSocketServer } from 'ws';
import { setupWebSocket, ACTIVITY_MS, BOOT_ID } from '../server/ws.js';
import { STORE_FILE, writeStoreState } from '../server/skill-store.js';
import { removeTempDir } from './temp-dir.js';
// The server offers compression, and inflating is async: a compressed socket
// hears the connect-time burst after its test has attached listeners, which
// these tests were written never to see. They speak plain frames; one test
// below checks that a browser-like client does get compression.
class WebSocket extends RawWebSocket {
constructor(url, opts) { super(url, { perMessageDeflate: false, ...opts }); }
}
const PORT = 17007; // Use non-default port to avoid conflicts
let baseUrl;
let wsUrl;
beforeAll(async () => {
process.env.PORT = String(PORT);
// Override the port before startup
server.listen(PORT, '127.0.0.1');
await new Promise(r => server.once('listening', r));
baseUrl = `http://127.0.0.1:${PORT}`;
wsUrl = `ws://127.0.0.1:${PORT}`;
});
afterAll(async () => {
// Kill any sessions created during tests
for (const [id] of sessions) {
const session = sessions.get(id);
if (session) {
clearInterval(session.stateCheckInterval);
clearTimeout(session.scanTimer);
try { session.pty.kill(); } catch {}
sessions.delete(id);
}
}
await new Promise(r => server.close(r));
});
// --- Cross-origin check (HTTP + WS integration) ---
describe('cross-origin origin check', () => {
// Only the Origin header is under test: an empty folder, not tmpdir(),
// whose listing grows with every test run (seconds once it holds 100k+).
const browsed = mkdtempSync(join(tmpdir(), 'a007-origin-'));
it('rejects an HTTP request from a disallowed origin with 403', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${browsed}`, {
headers: { Origin: 'http://evil.example.com' },
});
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toMatch(/cross-origin/i);
});
it('allows an HTTP request from a localhost origin', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${browsed}`, {
headers: { Origin: 'http://localhost:3000' },
});
expect(res.status).toBe(200);
});
it('rejects a WebSocket handshake from a disallowed origin', async () => {
const result = await new Promise((resolve) => {
const ws = new WebSocket(wsUrl, { origin: 'http://evil.example.com' });
ws.on('open', () => { ws.close(); resolve('open'); });
ws.on('error', () => resolve('rejected'));
});
expect(result).toBe('rejected');
});
it('accepts a WebSocket handshake from a localhost origin', async () => {
const ws = await new Promise((resolve, reject) => {
const s = new WebSocket(wsUrl, { origin: 'http://localhost:3000' });
s.on('open', () => resolve(s));
s.on('error', reject);
});
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
});
});
// --- /api/browse endpoint ---
describe('/api/browse', () => {
it('should return directory listing for valid path', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`);
expect(res.status).toBe(200);
const data = await res.json();
// Path may differ due to symlink resolution (e.g. /tmp → /private/tmp on macOS)
expect(data.path).toBeTruthy();
expect(Array.isArray(data.entries)).toBe(true);
});
it('should return 400 for non-existent path', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=/nonexistent/path/xyz123`);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/does not exist/i);
});
it('should return 400 for a file path (not directory)', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${join(tmpdir(), '..')}/etc/hosts`);
// This may or may not exist depending on platform
const data = await res.json();
if (res.status === 200) {
// If it resolved to a dir somehow, that's fine
} else {
expect([400, 500]).toContain(res.status);
}
});
it('should default to home directory when no path provided', async () => {
const res = await fetch(`${baseUrl}/api/browse`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.path).toBeTruthy();
expect(data.entries).toBeDefined();
});
it('should filter hidden directories unless showHidden=1', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${process.env.HOME}`);
expect(res.status).toBe(200);
const data = await res.json();
const hasHidden = data.entries.some(e => e.name.startsWith('.'));
expect(hasHidden).toBe(false);
});
it('should show hidden directories when showHidden=1', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${process.env.HOME}&showHidden=1`);
expect(res.status).toBe(200);
const data = await res.json();
const hasHidden = data.entries.some(e => e.name.startsWith('.'));
expect(hasHidden).toBe(true);
});
});
// --- Job attachments ---
describe('/api/jobs/:id/attachments/:name', () => {
let job;
beforeAll(() => {
({ job } = addJob({
title: 'Shot', repoPath: tmpdir(),
attachments: [{ name: 'shot.png', data: Buffer.from('png!').toString('base64') }, { name: '.env', data: Buffer.from('K=1').toString('base64') }],
}, () => {}));
});
afterAll(async () => { await deleteJob(job.id, () => {}); });
it('serves the file sandboxed, sniff-proof, and without a referrer', async () => {
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/shot.png`);
expect(res.status).toBe(200);
expect(res.headers.get('content-security-policy')).toBe('sandbox');
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
expect(res.headers.get('referrer-policy')).toBe('no-referrer');
expect(await res.text()).toBe('png!');
});
it('serves a dotfile too', async () => {
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/.env`);
expect(res.status).toBe(200);
expect(await res.text()).toBe('K=1');
});
it('404s for a name the card does not have', async () => {
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/other.png`);
expect(res.status).toBe(404);
});
it('404s when the file is gone from disk', async () => {
rmSync(job.attachments[0].path, { force: true });
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/shot.png`);
expect(res.status).toBe(404);
expect((await res.json()).error).toMatch(/missing on disk/);
});
// The one call that connects the form to the disk: job-create and
// job-update forward `attachments`, and the broadcast carries the result.
it('carries attachments over the WebSocket on create and update', async () => {
const ws = await new Promise((resolve, reject) => {
const s = new WebSocket(wsUrl);
s.on('open', () => resolve(s));
s.on('error', reject);
});
const next = (pred) => new Promise((resolve) => {
const h = (d) => { const m = JSON.parse(d.toString()); if (pred(m)) { ws.removeListener('message', h); resolve(m); } };
ws.on('message', h);
});
let list = next(m => m.type === 'jobs-list' && m.jobs.some(j => j.title === 'Wire shot'));
ws.send(JSON.stringify({ type: 'job-create', title: 'Wire shot', repoPath: tmpdir(), agent: 'codex', attachments: [{ name: 'wire.png', data: Buffer.from('w').toString('base64') }] }));
const created = (await list).jobs.find(j => j.title === 'Wire shot');
expect(created.attachments).toEqual([{ name: 'wire.png', path: join(process.env.AGENT007_CONFIG_DIR, 'attachments', created.id, 'wire.png') }]);
expect(existsSync(created.attachments[0].path)).toBe(true);
// The agent rides the same two messages.
expect(created.agent).toBe('codex');
list = next(m => m.type === 'jobs-list' && m.jobs.find(j => j.id === created.id)?.attachments.length === 0);
ws.send(JSON.stringify({ type: 'job-update', jobId: created.id, agent: 'claude', attachments: [] }));
expect((await list).jobs.find(j => j.id === created.id).agent).toBe('claude');
expect(existsSync(created.attachments[0].path)).toBe(false);
ws.close();
await deleteJob(created.id, () => {});
});
});
// --- WebSocket connection ---
describe('WebSocket', () => {
function connectWs() {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
}
function receiveMessages(ws, count, timeoutMs = 5000) {
return new Promise((resolve) => {
const messages = [];
const handler = (data) => {
messages.push(JSON.parse(data.toString()));
if (messages.length >= count) {
clearTimeout(timer);
ws.removeListener('message', handler);
resolve(messages);
}
};
const timer = setTimeout(() => {
ws.removeListener('message', handler);
resolve(messages);
}, timeoutMs);
ws.on('message', handler);
});
}
it('should accept WebSocket connections', async () => {
const ws = await connectWs();
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
});
it('should send repos-list and orphans-list on connect', async () => {
// Collect messages from the moment the WS is created (before open fires)
const ws = new WebSocket(wsUrl);
const messages = [];
ws.on('message', (data) => messages.push(JSON.parse(data.toString())));
await new Promise((resolve, reject) => {
ws.on('open', resolve);
ws.on('error', reject);
});
// Wait for initial messages to arrive
await new Promise(r => setTimeout(r, 500));
const types = messages.map(m => m.type);
expect(types).toContain('repos-list');
expect(types).toContain('orphans-list');
ws.close();
}, 10000);
it('should handle invalid JSON gracefully', async () => {
const ws = await connectWs();
// Drain initial messages (repos-list + orphans-list + any sessions)
await new Promise(r => setTimeout(r, 500));
// Clear any pending listeners
ws.removeAllListeners('message');
// Send garbage
ws.send('not json at all');
// Should not crash — wait briefly and verify connection is still open
await new Promise(r => setTimeout(r, 200));
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
}, 10000);
it('should handle unknown message type gracefully', async () => {
const ws = await connectWs();
await new Promise(r => setTimeout(r, 500));
ws.removeAllListeners('message');
ws.send(JSON.stringify({ type: 'nonexistent-type', data: 'test' }));
await new Promise(r => setTimeout(r, 200));
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
}, 10000);
});
// --- PTY lifecycle (smoke test with real PTY) ---
describe('PTY lifecycle', () => {
it('should spawn a session with echo command and receive output', async () => {
const ws = await new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
// Drain initial messages
const initMsgs = [];
await new Promise(resolve => {
const timer = setTimeout(resolve, 1000);
ws.on('message', (data) => {
initMsgs.push(JSON.parse(data.toString()));
if (initMsgs.length >= 2) { clearTimeout(timer); resolve(); }
});
});
// Spawn a simple echo command (no repo needed)
ws.send(JSON.stringify({ type: 'spawn', command: 'echo hello-agent-007' }));
// Collect until session-ended itself: ConPTY on a slow Windows runner can
// take longer than any fixed wait to report echo's exit. The bound is only
// there so a real hang fails here, inside the test's own timeout.
// Output goes to windows showing the terminal, so this one shows it; echo
// may well be done by then, which the replay on watching covers.
const messages = [];
const said = () => messages.filter(m => m.type === 'pty-output').map(m => Buffer.from(m.data, 'base64').toString()).join('');
try {
await new Promise(resolve => {
const timer = setTimeout(resolve, 25000);
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
messages.push(msg);
if (msg.type === 'session-created') ws.send(JSON.stringify({ type: 'pty-resize', sessionId: msg.sessionId, cols: 80, rows: 24 }));
if (messages.some(m => m.type === 'session-ended') && said().includes('hello-agent-007')) { clearTimeout(timer); resolve(); }
});
});
} finally {
// An open socket would hold server.close() in afterAll past its timeout.
ws.close();
}
const types = messages.map(m => m.type);
expect(types).toContain('session-created');
// Should have received PTY output containing our echo string
const outputMsgs = messages.filter(m => m.type === 'pty-output');
expect(outputMsgs.length).toBeGreaterThan(0);
// Decode base64 output and check for our string
const allOutput = outputMsgs.map(m => Buffer.from(m.data, 'base64').toString()).join('');
expect(allOutput).toContain('hello-agent-007');
// Should have gotten session-ended (echo exits immediately)
expect(types).toContain('session-ended');
// Get the session ID for cleanup
const created = messages.find(m => m.type === 'session-created');
expect(created.sessionId).toBeTruthy();
expect(created.name).toBeTruthy();
expect(created.color).toBeTruthy();
}, 30000);
// On macOS and Linux node-pty "starts" a missing command, leaving a dead,
// empty tab. It must come back as a spawn error that says what is missing.
it('refuses a command that is not installed, with a readable error', async () => {
const ws = await new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
const messages = [];
ws.on('message', (data) => messages.push(JSON.parse(data.toString())));
ws.send(JSON.stringify({ type: 'spawn', command: 'a007-no-such-cli --flag' }));
await vi.waitFor(() => expect(messages.find(m => m.type === 'spawn-error')).toBeTruthy(), { timeout: 5000 });
expect(messages.find(m => m.type === 'spawn-error').error).toMatch(/^"a007-no-such-cli" is not installed/);
expect(messages.find(m => m.type === 'session-created')).toBeUndefined();
ws.close();
});
it('should kill a session on request', async () => {
const ws = await new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
// Drain initial messages
await new Promise(r => setTimeout(r, 500));
// Spawn a long-running command
ws.send(JSON.stringify({ type: 'spawn', command: 'cat' })); // cat waits for input forever
// Wait for session-created
const sessionId = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout waiting for session-created')), 5000);
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'session-created') {
clearTimeout(timer);
resolve(msg.sessionId);
}
});
});
// Kill it
ws.send(JSON.stringify({ type: 'kill', sessionId }));
// Wait for session-ended
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout waiting for session-ended')), 5000);
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'session-ended' && msg.sessionId === sessionId) {
clearTimeout(timer);
resolve();
}
});
});
ws.close();
}, 15000);
});
describe('pty size with two windows', () => {
const open = () => new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
const next = (ws, pred) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
const on = (data) => {
const msg = JSON.parse(data.toString());
if (pred(msg)) { clearTimeout(timer); ws.off('message', on); resolve(msg); }
};
ws.on('message', on);
});
it('tells only the spawning window to switch to the new tab', async () => {
const a = await open();
const b = await open();
const mine = next(a, m => m.type === 'session-created');
const theirs = next(b, m => m.type === 'session-created');
a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
expect((await mine).focus).toBe(true);
expect((await theirs).focus).toBeUndefined();
a.close(); b.close();
});
it('takes the smallest of the windows showing it', async () => {
const a = await open();
const b = await open();
const created = next(a, m => m.type === 'session-created');
a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
const { sessionId } = await created;
const show = (ws, cols, rows) => ws.send(JSON.stringify({ type: 'pty-resize', sessionId, cols, rows }));
// Every window hears every size, so wait for the one expected; a wrong
// size never arriving fails on the timeout.
const size = (ws, cols, rows) => next(ws, m => m.type === 'pty-size' && m.sessionId === sessionId && m.cols === cols && m.rows === rows);
let got = size(b, 100, 30);
show(a, 100, 30);
await got;
// A smaller second window shrinks it for both.
got = size(a, 60, 30);
show(b, 60, 40);
await got;
// A bigger one can't grow it past the smaller window still showing it.
got = size(b, 60, 40);
show(a, 200, 50);
await got;
// Once the smaller window closes, it grows back to the one left.
got = size(a, 200, 50);
b.close();
await got;
const ended = next(a, m => m.type === 'session-ended' && m.sessionId === sessionId);
a.send(JSON.stringify({ type: 'kill', sessionId }));
await ended;
a.close();
}, 15000);
it('tells every browser a closed agent is gone, but not a crashed one', async () => {
const a = await open();
const b = await open();
const created = next(a, m => m.type === 'session-created');
a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
const { sessionId } = await created;
const removed = next(b, m => m.type === 'session-removed' && m.sessionId === sessionId);
a.send(JSON.stringify({ type: 'kill', sessionId }));
await removed;
// A real exit sends session-ended and no removal: the tab stays readable.
const msgs = [];
b.on('message', d => msgs.push(JSON.parse(d.toString())));
const crashed = next(b, m => m.type === 'session-ended');
a.send(JSON.stringify({ type: 'spawn', command: 'echo bye' }));
await crashed;
await new Promise(r => setTimeout(r, 200));
expect(msgs.some(m => m.type === 'session-removed')).toBe(false);
a.close(); b.close();
}, 15000);
it('ignores bad sizes and lets go of a terminal a window switches away from', async () => {
const a = await open();
const b = await open();
const spawn = async () => {
const created = next(a, m => m.type === 'session-created');
a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
return created;
};
const first = await spawn();
// The xterm is built at the pty's size before any resize arrives.
expect([first.cols, first.rows]).toEqual([120, 30]);
const s1 = first.sessionId;
const { sessionId: s2 } = await spawn();
const show = (ws, sessionId, cols, rows) => ws.send(JSON.stringify({ type: 'pty-resize', sessionId, cols, rows }));
const anySize = (ws, sessionId) => next(ws, m => m.type === 'pty-size' && m.sessionId === sessionId);
// Bad sizes are dropped: the first size heard is the valid one after them.
let got = anySize(a, s1);
show(a, s1, 0, 30);
show(a, s1, '100', 30);
show(a, s1, 100, 1.5);
show(a, s1, 1e9, 1e9); // every browser would build an xterm this big
show(a, s1, 100, 30);
expect(await got).toMatchObject({ cols: 100, rows: 30 });
got = next(a, m => m.type === 'pty-size' && m.sessionId === s1 && m.cols === 60);
show(b, s1, 60, 30);
await got;
// b switches to s2, so s1 grows back to a's window alone.
got = next(a, m => m.type === 'pty-size' && m.sessionId === s1 && m.cols === 100);
show(b, s2, 80, 24);
await got;
// A window showing no terminal at all (job board, hidden tab) lets go too.
got = next(b, m => m.type === 'pty-size' && m.sessionId === s2 && m.cols === 100);
show(a, s2, 100, 30); // a is bigger, so b still holds s2 at 80x24
b.send(JSON.stringify({ type: 'pty-resize', sessionId: null }));
await got;
for (const sessionId of [s1, s2]) {
const ended = next(a, m => m.type === 'session-ended' && m.sessionId === sessionId);
a.send(JSON.stringify({ type: 'kill', sessionId }));
await ended;
}
a.close();
b.close();
}, 15000);
});
// A slow link: a window gets a terminal's output only while it shows that
// terminal, the rest a throttled pty-activity, and its scrollback on switching.
describe('terminal output on a slow link', () => {
const open = (opts) => new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl, opts);
const seen = [];
ws.seen = seen;
ws.on('message', (data) => seen.push(JSON.parse(data.toString())));
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
const text = (ws, sessionId) => ws.seen.filter(m => m.type === 'pty-output' && m.sessionId === sessionId)
.map(m => Buffer.from(m.data, 'base64').toString()).join('');
it('sends output only to the window showing it, activity to the rest, and the scrollback on switching', async () => {
const a = await open();
const b = await open();
a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
await vi.waitFor(() => expect(a.seen.find(m => m.type === 'session-created' && m.command === 'cat' && m.focus)).toBeTruthy());
const { sessionId } = a.seen.find(m => m.type === 'session-created' && m.focus);
a.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 80, rows: 24 }));
a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'MARK_ONE\n' }));
await vi.waitFor(() => expect(text(a, sessionId)).toContain('MARK_ONE'));
expect(text(b, sessionId)).toBe('');
await vi.waitFor(() => expect(b.seen.some(m => m.type === 'pty-activity' && m.sessionId === sessionId)).toBe(true), { timeout: ACTIVITY_MS + 2000 });
expect(b.seen.filter(m => m.type === 'pty-activity' && m.sessionId === sessionId).length).toBe(1); // throttled, not per chunk
// b switches to it: the scrollback, marked so its copy is cleared first.
b.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 80, rows: 24 }));
await vi.waitFor(() => expect(text(b, sessionId)).toContain('MARK_ONE'));
expect(b.seen.find(m => m.type === 'pty-output')).toMatchObject({ replay: true, reset: true });
// A resize of the same terminal is not a switch: no second replay.
b.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 70, rows: 24 }));
a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'MARK_TWO\n' }));
await vi.waitFor(() => expect(text(b, sessionId)).toContain('MARK_TWO'));
expect(b.seen.filter(m => m.type === 'pty-output' && m.reset)).toHaveLength(1);
// Leaving it (job board, hidden tab) stops the stream.
b.send(JSON.stringify({ type: 'pty-resize', sessionId: null }));
await new Promise(r => setTimeout(r, 100));
const before = b.seen.length;
a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'MARK_THREE\n' }));
await vi.waitFor(() => expect(text(a, sessionId)).toContain('MARK_THREE'));
expect(b.seen.slice(before).some(m => m.type === 'pty-output')).toBe(false);
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
it('compresses for a client that offers it', async () => {
const ws = await open({ perMessageDeflate: true });
expect(ws.extensions).toMatch(/permessage-deflate/);
ws.close();
});
it('cuts a socket that stops answering pings, and keeps one that answers', async () => {
const wss = new WebSocketServer({ port: 0 });
await new Promise(r => wss.once('listening', r));
setupWebSocket(wss, { heartbeatMs: 100 });
const url = `ws://127.0.0.1:${wss.address().port}`;
const connect = () => new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
const alive = await connect();
const dead = await connect();
dead._socket.pause(); // a dead link: nothing read, so no pong goes back
await vi.waitFor(() => expect(wss.clients.size).toBe(1), { timeout: 3000 });
expect([...wss.clients][0].readyState).toBe(1);
expect(alive.readyState).toBe(1);
alive.close();
dead.terminate();
await new Promise(r => wss.close(r));
});
// A proxy or a suspended tab can lose protocol pongs; the page's own ping
// (public/modules/ws.js) still counts.
it('keeps a socket whose protocol pongs go missing while the page still pings', async () => {
const wss = new WebSocketServer({ port: 0 });
await new Promise(r => wss.once('listening', r));
setupWebSocket(wss, { heartbeatMs: 100 });
const url = `ws://127.0.0.1:${wss.address().port}`;
const connect = () => new Promise((resolve, reject) => {
const ws = new WebSocket(url, { autoPong: false });
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
const pinging = await connect();
const silent = await connect();
const pinger = setInterval(() => pinging.send('{"type":"ping"}'), 50);
await vi.waitFor(() => expect(wss.clients.size).toBe(1), { timeout: 3000 });
await new Promise(r => setTimeout(r, 400)); // several heartbeats on
expect(pinging.readyState).toBe(1);
expect(silent.readyState).not.toBe(1);
clearInterval(pinger);
pinging.close();
await new Promise(r => wss.close(r));
});
});
// --- One skill store (server/skill-store.js) ---
// Value: protects=Settings' skill-store routes (preview is a dry run, the switch validates, turning it on syncs and persists, off leaves files); fails_when=preview writes files, a non-boolean enabled is accepted, enabled:true skips the sync or the state file; why_new=test/skill-store.test.js calls the module directly and never the routes; seam=none
// The routes act on the owner's homes, so HOME (homedir) and the CLI dirs
// point at a temp folder for the test and are put back after it.
describe('/api/skill-store', () => {
const keys = ['HOME', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME'];
let home, saved;
beforeAll(() => {
saved = Object.fromEntries(keys.map(k => [k, process.env[k]]));
home = mkdtempSync(join(tmpdir(), 'a007-store-home-'));
Object.assign(process.env, { HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEX_HOME: join(home, '.codex') });
mkdirSync(join(home, '.claude', 'skills', 'ads'), { recursive: true });
writeFileSync(join(home, '.claude', 'skills', 'ads', 'SKILL.md'), '---\nname: ads\n---\nbody\n');
});
afterAll(() => {
for (const k of keys) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
// Off again, so a later spawn in this file never syncs the real homes.
writeStoreState({ enabled: false, last: null });
removeTempDir(home);
});
const post = (path, body) => fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const skillPath = () => join(home, '.claude', 'skills', 'ads');
const isLink = () => { try { return lstatSync(skillPath()).isSymbolicLink(); } catch { return false; } };
it('previews without changing anything, refuses a non-boolean switch, then syncs and remembers when turned on', async () => {
writeStoreState({ enabled: false, last: null });
const preview = await (await post('/api/skill-store/preview', {})).json();
expect(preview).toMatchObject({ enabled: false, last: null, preview: { dryRun: true, moved: ['ads'], linked: ['ads'] } });
expect(preview.preview.summary).toMatch(/^Would move 1 skill into the store/);
expect(isLink()).toBe(false);
expect(existsSync(join(home, '.agents', 'skills', 'ads'))).toBe(false);
const bad = await post('/api/skill-store', { enabled: 'yes' });
expect(bad.status).toBe(400);
expect(JSON.parse(readFileSync(STORE_FILE, 'utf8')).enabled).toBe(false);
const on = await (await post('/api/skill-store', { enabled: true })).json();
expect(on).toMatchObject({ enabled: true, last: { moved: ['ads'] }, preview: null });
expect(on.summary).toMatch(/^Moved 1 skill into the store/);
expect(isLink()).toBe(true);
expect(existsSync(join(home, '.agents', 'skills', 'ads', 'SKILL.md'))).toBe(true);
expect((await (await fetch(`${baseUrl}/api/skill-store`)).json())).toMatchObject({ enabled: true, last: { moved: ['ads'] } });
const off = await (await post('/api/skill-store', { enabled: false })).json();
expect(off).toMatchObject({ enabled: false, last: { moved: ['ads'] } });
expect(isLink()).toBe(true);
});
});
// --- Auth enforcement (phase 1) ---
// Runs LAST: writes a user to the hermetic users path so the running server
// (which started auth-disabled) picks it up live, then removes it so nothing
// after this block is affected.
describe('auth enforcement (live enable)', () => {
const usersPath = process.env.AGENT007_USERS_PATH;
const token = 'tok_' + Math.random().toString(36).slice(2, 12);
beforeAll(() => {
writeFileSync(usersPath, JSON.stringify([
{ id: 'u_test', displayName: 'Tester', color: '#d4a847', tokenHash: hashToken(token) },
]));
});
afterAll(() => { try { rmSync(usersPath, { force: true }); } catch {} });
it('rejects an /api request with no token (401)', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`);
expect(res.status).toBe(401);
});
it('accepts an /api request with a valid Bearer token (200)', async () => {
const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
});
// A card's attachment link is a plain <a href>, which cannot send a header,
// so the token rides in the query for exactly this route.
it('gates an attachment link on the token, in the query', async () => {
const { job } = addJob({ title: 'Gated', repoPath: tmpdir(), attachments: [{ name: 'g.txt', data: Buffer.from('g').toString('base64') }] }, () => {});
try {
expect((await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/g.txt`)).status).toBe(401);
expect((await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/g.txt?token=${token}`)).status).toBe(200);
} finally {
await deleteJob(job.id, () => {});
}
});
// Update restarts the server: the owner's call, which nobody has with user accounts on.
it('refuses Update to a signed-in user and to another origin', async () => {
const auth = { Authorization: `Bearer ${token}` };
expect((await fetch(`${baseUrl}/api/update`, { method: 'POST', headers: auth })).status).toBe(403);
expect((await fetch(`${baseUrl}/api/update`, { headers: auth })).status).toBe(403);
expect((await fetch(`${baseUrl}/api/update`, { method: 'POST', headers: { ...auth, Origin: 'https://evil.example' } })).status).toBe(403);
});
// Value: protects=the skill store's routes stay owner-only; fails_when=ownerOnly is dropped from any of the three routes, letting a signed-in user move the owner's skill folders; why_new=only /api/update's 403 was tested; seam=none
it('refuses the skill store routes to a signed-in user', async () => {
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
expect((await fetch(`${baseUrl}/api/skill-store`, { headers })).status).toBe(403);
expect((await fetch(`${baseUrl}/api/skill-store`, { method: 'POST', headers, body: '{"enabled":true}' })).status).toBe(403);
expect((await fetch(`${baseUrl}/api/skill-store/preview`, { method: 'POST', headers, body: '{}' })).status).toBe(403);
});
it('closes a WS handshake without a token (code 4401)', async () => {
// The server accepts the socket then closes it with 4401 in the connection
// handler, so the client sees a brief open followed by a 4401 close.
const code = await new Promise((resolve) => {
const ws = new WebSocket(wsUrl);
ws.on('close', (c) => resolve(c));
ws.on('error', () => {});
});
expect(code).toBe(4401);
});
it('accepts a WS handshake with a valid token and sends welcome', async () => {
const welcome = await new Promise((resolve, reject) => {
const s = new WebSocket(`${wsUrl}/?token=${encodeURIComponent(token)}`);
s.on('message', (d) => {
const m = JSON.parse(d);
if (m.type === 'welcome') { resolve(m); s.close(); }
});
s.on('close', () => reject(new Error('closed before welcome')));
s.on('error', reject);
});
expect(welcome.authEnabled).toBe(true);
expect(welcome.user.displayName).toBe('Tester');
expect(welcome.platform).toBe(process.platform);
// The same boot for every socket, so a reconnecting page keeps its state.
expect(welcome.bootId).toBe(BOOT_ID);
expect(welcome.sessionIds).toEqual([...sessions.keys()]);
});
});
// --- Auth revocation (phase 1 hardening) ---
// A user removed from users.json must lose access on their LIVE socket, not just
// on the next reconnect. Two users so auth stays enabled after removing one.
describe('auth revocation (live socket)', () => {
const usersPath = process.env.AGENT007_USERS_PATH;
const tokenA = 'tokA_' + Math.random().toString(36).slice(2, 10);
const tokenB = 'tokB_' + Math.random().toString(36).slice(2, 10);
beforeAll(() => {
writeFileSync(usersPath, JSON.stringify([
{ id: 'u_a', displayName: 'A', color: '#d4a847', tokenHash: hashToken(tokenA) },
{ id: 'u_b', displayName: 'B', color: '#58a6ff', tokenHash: hashToken(tokenB) },
]));
});
afterAll(() => { try { rmSync(usersPath, { force: true }); } catch {} });
it('closes a live socket with 4401 when its user is removed', async () => {
const ws = await new Promise((resolve, reject) => {
const s = new WebSocket(`${wsUrl}/?token=${encodeURIComponent(tokenA)}`);
s.on('message', (d) => { if (JSON.parse(d).type === 'welcome') resolve(s); });
s.on('error', reject);
});
// Revoke A, keep B so auth remains enabled.
writeFileSync(usersPath, JSON.stringify([
{ id: 'u_b', displayName: 'B', color: '#58a6ff', tokenHash: hashToken(tokenB) },
]));
const code = await new Promise((resolve) => {
ws.on('close', (c) => resolve(c));
ws.send(JSON.stringify({ type: 'refresh-tree', sessionId: 'none' }));
});
expect(code).toBe(WS_UNAUTHORIZED);
});
});
// --- Ownership authorization (phase 2) ---
// With auth on, only the owner may control a session. A non-owner's kill is
// rejected with a notification and the session survives; the owner's kill works.
describe('ownership authorization', () => {
const usersPath = process.env.AGENT007_USERS_PATH;
const tokenA = 'ownA_' + Math.random().toString(36).slice(2, 10);
const tokenB = 'ownB_' + Math.random().toString(36).slice(2, 10);
beforeAll(() => {
writeFileSync(usersPath, JSON.stringify([
{ id: 'u_a', displayName: 'Aowner', color: '#d4a847', tokenHash: hashToken(tokenA) },
{ id: 'u_b', displayName: 'Bviewer', color: '#58a6ff', tokenHash: hashToken(tokenB) },
]));
});
afterAll(() => { try { rmSync(usersPath, { force: true }); } catch {} });
const connect = (token) => new Promise((resolve, reject) => {
const ws = new WebSocket(`${wsUrl}/?token=${encodeURIComponent(token)}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
const nextMatching = (ws, pred, timeoutMs = 4000) => new Promise((resolve) => {
const to = setTimeout(() => { ws.off('message', h); resolve(null); }, timeoutMs);
const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); resolve(m); } };
ws.on('message', h);
});
it('tags a spawned session with its owner and blocks a non-owner kill', async () => {
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
a.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
const payload = await created;
expect(payload).toBeTruthy();
expect(payload.ownerId).toBe('u_a');
expect(payload.ownerName).toBe('Aowner');
const sessionId = payload.sessionId;
// B (non-owner) tries to kill -> gets a read-only notification, session survives.
const b = await connect(tokenB);
const denied = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
b.send(JSON.stringify({ type: 'kill', sessionId }));
const note = await denied;
expect(note).toBeTruthy();
expect(note.message).toMatch(/owned by Aowner/);
expect(sessions.has(sessionId)).toBe(true);
// A (owner) kills successfully.
const ended = nextMatching(a, (m) => m.type === 'session-ended' && m.sessionId === sessionId);
a.send(JSON.stringify({ type: 'kill', sessionId }));
expect(await ended).toBeTruthy();
a.close(); b.close();
}, 15000);
it('refuses Restart of an exited agent to a non-owner', async () => {
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && m.command === 'true');
const ended = nextMatching(a, (m) => m.type === 'session-ended');
a.send(JSON.stringify({ type: 'spawn', command: 'true' }));
const { sessionId } = await created;
expect((await ended).closed).toBe(false); // exited by itself, so Restart is offered
const b = await connect(tokenB);
const denied = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
b.send(JSON.stringify({ type: 'restart', sessionId }));
expect((await denied).message).toMatch(/owned by Aowner/);
expect(sessions.get(sessionId).exited).toBe(true); // not restarted
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
it('lets only the owner rename a session and broadcasts the new name', async () => {
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep 7/.test(m.command || ''));
a.send(JSON.stringify({ type: 'spawn', command: 'sleep 7' }));
const { sessionId, name: oldName } = await created;
const b = await connect(tokenB);
const denied = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
b.send(JSON.stringify({ type: 'rename-session', sessionId, name: 'nope' }));
expect(await denied).toBeTruthy();
expect(sessions.get(sessionId).name).toBe(oldName);
const seenByB = nextMatching(b, (m) => m.type === 'session-renamed' && m.sessionId === sessionId);
a.send(JSON.stringify({ type: 'rename-session', sessionId, name: ' viper ' }));
const renamed = await seenByB;
expect(renamed.name).toBe('viper');
expect(sessions.get(sessionId).name).toBe('viper');
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
it('silently drops pty-input from a non-owner but forwards the owner\'s', async () => {
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && /cat/.test(m.command || ''));
a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
const { sessionId } = await created;
a.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 80, rows: 24 })); // output goes to watchers
const b = await connect(tokenB);
const marker = 'BINTRUDER_' + Math.random().toString(36).slice(2, 8);
const echoOfB = nextMatching(a, (m) => m.type === 'pty-output'
&& Buffer.from(m.data || '', 'base64').toString().includes(marker), 1500);
b.send(JSON.stringify({ type: 'pty-input', sessionId, data: marker + '\n' }));
expect(await echoOfB).toBeNull(); // dropped server-side; cat never saw it
const own = 'AOWNER_' + Math.random().toString(36).slice(2, 8);
const echoOfA = nextMatching(a, (m) => m.type === 'pty-output'
&& Buffer.from(m.data || '', 'base64').toString().includes(own), 3000);
a.send(JSON.stringify({ type: 'pty-input', sessionId, data: own + '\n' }));
expect(await echoOfA).toBeTruthy();
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
it("sizes a terminal to its owner's windows only, never a viewer's", async () => {
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep 11/.test(m.command || ''));
a.send(JSON.stringify({ type: 'spawn', command: 'sleep 11' }));
const { sessionId } = await created;
const b = await connect(tokenB);
const show = (ws, cols, rows) => ws.send(JSON.stringify({ type: 'pty-resize', sessionId, cols, rows }));
const owners = nextMatching(b, (m) => m.type === 'pty-size' && m.sessionId === sessionId);
show(a, 100, 30);
expect(await owners).toMatchObject({ cols: 100, rows: 30 });
// B's smaller window is a viewer's: it renders at 100x30, it doesn't shrink it.
// A's own copy of the 100x30 broadcast may still be in flight, so only a
// different size counts as a shrink.
const shrunk = nextMatching(a, (m) => m.type === 'pty-size' && m.sessionId === sessionId
&& (m.cols !== 100 || m.rows !== 30), 1500);
show(b, 50, 20);
expect(await shrunk).toBeNull();
expect([sessions.get(sessionId).pty.cols, sessions.get(sessionId).pty.rows]).toEqual([100, 30]);
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
it('marks a terminal as being typed in only for real keystrokes from its owner', async () => {
// lastUserInputAt holds agent messages back (server/messages.js). A focus
// report arrives every time the tab is clicked; counting it would stall
// messages for 30 s on every glance at the terminal.
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep 9/.test(m.command || ''));
a.send(JSON.stringify({ type: 'spawn', command: 'sleep 9' }));
const { sessionId } = await created;
const b = await connect(tokenB);
const session = sessions.get(sessionId);
// Messages on one socket are handled in order: a later rename landing
// means every earlier pty-input on that socket has been processed.
const settled = async (ws, name) => {
const done = nextMatching(a, (m) => m.type === 'session-renamed' && m.name === name);
ws.send(JSON.stringify({ type: 'rename-session', sessionId, name }));
await done;
};
a.send(JSON.stringify({ type: 'pty-input', sessionId, data: '\x1b[I' }));
await settled(a, 'focus');
expect(session.lastUserInputAt).toBeUndefined();
// A viewer's keystrokes never reach the pty, so they hold nothing back.
const refused = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
b.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'x' }));
b.send(JSON.stringify({ type: 'rename-session', sessionId, name: 'nope' }));
expect(await refused).toBeTruthy();
a.send(JSON.stringify({ type: 'pty-input', sessionId, data: '\x1b[O' }));
await settled(a, 'blur');
expect(session.lastUserInputAt).toBeUndefined();
const before = Date.now();
a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'x' }));
await settled(a, 'typed');
expect(session.lastUserInputAt).toBeGreaterThanOrEqual(before);
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
it('rejects refresh-tree and upload-file from a non-owner', async () => {
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
a.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
const { sessionId } = await created;
const b = await connect(tokenB);
const t = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
b.send(JSON.stringify({ type: 'refresh-tree', sessionId }));
expect(await t).toBeTruthy();
const u = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
b.send(JSON.stringify({ type: 'upload-file', sessionId, filename: 'x.txt', data: Buffer.from('hi').toString('base64') }));
expect((await u).message).toMatch(/owned by Aowner/);
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
it('keeps read paths (get-diff) open to a non-owner', async () => {
const a = await connect(tokenA);
const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
a.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
const { sessionId } = await created;
const b = await connect(tokenB);
// Non-owner get-diff must return a file-diff response, not a read-only notice.
const resp = nextMatching(b, (m) =>
(m.type === 'file-diff' && m.sessionId === sessionId) ||
(m.type === 'notification' && /read-only/i.test(m.message || '')));
b.send(JSON.stringify({ type: 'get-diff', sessionId, filePath: 'anything', status: 'M' }));
expect((await resp).type).toBe('file-diff');
a.send(JSON.stringify({ type: 'kill', sessionId }));
a.close(); b.close();
}, 15000);
});
describe('ownership is inert when auth is disabled', () => {
// No users file here (default hermetic state) => authEnabled() false.
const open = () => new Promise((res, rej) => {
const s = new WebSocket(wsUrl); s.on('open', () => res(s)); s.on('error', rej);
});
const next = (ws, pred, ms = 4000) => new Promise((res) => {
const to = setTimeout(() => { ws.off('message', h); res(null); }, ms);
const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); res(m); } };
ws.on('message', h);
});
it('lets any socket control any session in single-player mode', async () => {
const w1 = await open();
const created = next(w1, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
w1.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
const { sessionId, ownerId } = await created;
expect(ownerId ?? null).toBeNull(); // unowned in single-player
const w2 = await open();
const ended = next(w2, (m) => m.type === 'session-ended' && m.sessionId === sessionId);
w2.send(JSON.stringify({ type: 'kill', sessionId }));
expect(await ended).toBeTruthy(); // second socket kills it — no ownership block
w1.close(); w2.close();
}, 15000);
it('spawn refuses a custom name that is already held', async () => {
const w = await open();
const created = next(w, (m) => m.type === 'session-created' && m.name === 'dup-name-test');
w.send(JSON.stringify({ type: 'spawn', command: 'sleep 4', name: 'dup-name-test' }));
const { sessionId } = await created;
const err = next(w, (m) => m.type === 'spawn-error' && /already exists/.test(m.error || ''));
w.send(JSON.stringify({ type: 'spawn', command: 'sleep 4', name: 'dup-name-test' }));
expect(await err).toBeTruthy();
w.send(JSON.stringify({ type: 'kill', sessionId }));
w.close();
}, 15000);
it('rename-session ignores bad input, rejects a taken name, and truncates to 40', async () => {
const w = await open();
const spawn = async (cmd) => {
const created = next(w, (m) => m.type === 'session-created' && m.command === cmd);
w.send(JSON.stringify({ type: 'spawn', command: cmd }));
return created;
};
const one = await spawn('sleep 6');
const two = await spawn('sleep 8');
// A missing session, a non-string or missing name, a whitespace name, a
// name with no letters or digits, and the current name are all silent no-ops.
w.send(JSON.stringify({ type: 'rename-session', sessionId: 'nope-999', name: 'ghost' }));
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: 42 }));
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId }));
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: ' ' }));
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: '...' }));
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: one.name }));
// A name another live session holds is refused with an error notification.
const dup = next(w, (m) => m.type === 'notification' && /already exists/.test(m.message || ''));
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: two.name }));
expect((await dup).level).toBe('error');
expect(sessions.get(one.sessionId).name).toBe(one.name);
// So is a name an orphan still holds: the pool reserves those too.
orphans.set('orphan-rename-test', { id: 'orphan-rename-test', name: 'orphan-ghost', worktreePath: '/nonexistent/orphan-ghost' });
codenamePool.addUsed('orphan-ghost');
const dupOrphan = next(w, (m) => m.type === 'notification' && /already exists/.test(m.message || ''));
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: 'orphan-ghost' }));
expect((await dupOrphan).level).toBe('error');
orphans.delete('orphan-rename-test');
codenamePool.recycle('orphan-ghost');
// Over-long names are cut to 40 characters.
const renamed = next(w, (m) => m.type === 'session-renamed' && m.sessionId === one.sessionId);
w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: 'x'.repeat(41) }));
expect((await renamed).name).toBe('x'.repeat(40));
w.send(JSON.stringify({ type: 'kill', sessionId: one.sessionId }));
w.send(JSON.stringify({ type: 'kill', sessionId: two.sessionId }));
w.close();
}, 15000);
it('rename-session carries the new name into the restart record and the linked job card', async () => {
const w = await open();
const created = next(w, (m) => m.type === 'session-created' && m.command === 'sleep 9');
w.send(JSON.stringify({ type: 'spawn', command: 'sleep 9' }));
const { sessionId, name: oldName } = await created;
const session = sessions.get(sessionId);
// A bare spawn has no worktree; give it one named after the codename, the
// way createWorktree lays them out, so the config record is findable.
session.worktreePath = join(tmpdir(), oldName);
config.activeSessions.push({ name: oldName, worktreePath: session.worktreePath });
const { job } = addJob({ title: 'Rename me', repoPath: tmpdir() }, () => {});
Object.assign(job, { agentSessionId: sessionId, agentName: oldName });
// Every card linked to this session follows, whatever label it showed before.
const { job: other } = addJob({ title: 'Stale label', repoPath: tmpdir() }, () => {});
Object.assign(other, { agentSessionId: sessionId, agentName: 'someone-else' });
try {
const list = next(w, (m) => m.type === 'jobs-list' && m.jobs.find(j => j.id === job.id)?.agentName === 'mamba');
w.send(JSON.stringify({ type: 'rename-session', sessionId, name: 'mamba' }));
expect((await list).jobs.find(j => j.id === other.id).agentName).toBe('mamba');
expect(config.activeSessions.find(s => s.worktreePath === session.worktreePath).name).toBe('mamba');
// The old codename still names the worktree directory on disk, so it stays
// reserved: a later spawn must not try to create the same path.
expect(codenamePool.has(oldName)).toBe(true);
expect(codenamePool.has('mamba')).toBe(true);
// Renaming back to the directory's own codename is allowed, and frees the label.
const back = next(w, (m) => m.type === 'session-renamed' && m.name === oldName);
w.send(JSON.stringify({ type: 'rename-session', sessionId, name: oldName }));
expect(await back).toBeTruthy();
expect(codenamePool.has('mamba')).toBe(false);
} finally {
config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== session.worktreePath);
session.worktreePath = null; // keep the kill path from touching a worktree that never existed
codenamePool.recycle(oldName);
await deleteJob(job.id, () => {});
await deleteJob(other.id, () => {});
w.send(JSON.stringify({ type: 'kill', sessionId }));
w.close();
}
}, 15000);
// Closing a tab whose worktree cannot be cleaned up parks it as an orphan, and
// the explorer's Re-spawn brings it back. What Re-spawn runs must be the CLI
// the tab ran: `claude --continue` in a Codex worktree dies at once.
//
// A fake `codex` first on PATH stands in for the real one, which would try to
// resume a real session; the PTY inherits process.env at spawn time. The
// worktree is a bare directory with an unreadable .git, which the close path
// treats like uncommitted work (git status fails) and the re-adopt path
// accepts as a worktree (.git is there).
function fakeOrphanWorktree() {
const worktreePath = mkdtempSync(join(tmpdir(), 'a007-orphan-wt-'));
writeFileSync(join(worktreePath, '.git'), 'not a gitfile\n');
return worktreePath;
}
// A fake `codex` first on PATH, as a posix shell script: the Windows leg has
// neither the shebang nor the `:` PATH delimiter, so these two skip there.
function fakeCodexOnPath() {
const bin = mkdtempSync(join(tmpdir(), 'a007-bin-'));
writeFileSync(join(bin, 'codex'), '#!/bin/sh\nsleep 5\n', { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${bin}:${savedPath}`;
return { bin, restore: () => { process.env.PATH = savedPath; rmSync(bin, { recursive: true, force: true }); } };
}
it.skipIf(process.platform === 'win32')('closing a tab notes its CLI on the orphan, and Re-spawn resumes with that CLI', async () => {
const { bin, restore } = fakeCodexOnPath();
const worktreePath = fakeOrphanWorktree();
// Started with a permission flag, which the re-spawn must carry back.
const command = `${join(bin, 'codex')} --model o3 --dangerously-bypass-approvals-and-sandbox`;
const w = await open();
let name, back, other;
try {
const created = next(w, (m) => m.type === 'session-created' && m.command === command);
w.send(JSON.stringify({ type: 'spawn', command }));
const { sessionId, name: spawned } = await created;
name = spawned;
// A bare spawn has no worktree; give it one, as createWorktree would have.
Object.assign(sessions.get(sessionId), { repoPath: tmpdir(), worktreePath, branchName: 'b/orphan-test' });
const parked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
w.send(JSON.stringify({ type: 'kill', sessionId }));
const orphan = (await parked).orphans.find(o => o.name === name);
expect(orphan.agent).toBe('codex'); // read off the command, path and flags stripped
expect(orphans.get(orphan.id).agent).toBe('codex');
expect(orphan.permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
const isBack = (m) => m.type === 'session-created' && m.name === name && m.sessionId !== sessionId;
const readopted = next(w, isBack);
other = await open();
const theirs = next(other, isBack);
w.send(JSON.stringify({ type: 'restart', orphanId: orphan.id }));
back = await readopted;
expect(back.command).toBe('codex resume --dangerously-bypass-approvals-and-sandbox');
// Only the window that clicked Re-spawn switches to the revived tab.
expect(back.focus).toBe(true);
expect((await theirs).focus).toBeUndefined();
expect(orphans.has(orphan.id)).toBe(false);
// The record for the next restart carries the CLI and the flags too.
const rec = config.activeSessions.find(s => s.worktreePath === worktreePath);
expect(rec.agent).toBe('codex');
expect(rec.permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
} finally {
restore();
config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
for (const o of [...orphans.values()]) if (o.worktreePath === worktreePath) orphans.delete(o.id);
if (back) {
// Keep the kill path from orphaning the fake worktree a second time.
Object.assign(sessions.get(back.sessionId), { repoPath: null, worktreePath: null });
w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
}
if (name) codenamePool.recycle(name);
rmSync(worktreePath, { recursive: true, force: true });
w.close();
other?.close();
}
}, 15000);
// Restart of a session whose CLI quit by itself (Codex's update, a crash, a
// stray /exit): back in place under the same id and tab, resuming its own
// conversation with its model and permission flags, and never twice. The
// fake CLI logs its argv, exits on its first run and stays up after.
function fakeCliOnPath(cli) {
const bin = mkdtempSync(join(tmpdir(), 'a007-bin-'));
const log = join(bin, 'argv.log');
writeFileSync(join(bin, cli), `#!/bin/sh\necho "$@" >> "${log}"\n[ -f "${bin}/ran" ] && exec sleep 5\ntouch "${bin}/ran"\n`, { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${bin}:${savedPath}`;
return { bin, log, restore: () => { process.env.PATH = savedPath; rmSync(bin, { recursive: true, force: true }); } };
}
async function exitedSession(w, command) {
const created = next(w, (m) => m.type === 'session-created' && m.command === command);
w.send(JSON.stringify({ type: 'spawn', command }));
const { sessionId, name } = await created;
await vi.waitFor(() => expect(sessions.get(sessionId).exited).toBe(true));
return { sessionId, name };
}
it.skipIf(process.platform === 'win32')('Restart brings an exited Codex agent back in place, resuming its own session, and never twice', async () => {
const { bin, log, restore } = fakeCliOnPath('codex');
const worktreePath = fakeOrphanWorktree();
const codexHome = mkdtempSync(join(tmpdir(), 'a007-codex-home-'));
const day = join(codexHome, 'sessions', '2026', '10', '08');
mkdirSync(day, { recursive: true });
const id = '0199c0de-0000-7000-8000-00000000c0de';
writeFileSync(join(day, 'rollout-r.jsonl'), JSON.stringify({ type: 'session_meta', payload: { id, cwd: realpathSync.native(worktreePath), source: 'cli', thread_source: 'user' } }) + '\n');
const savedHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
const w = await open();
let sessionId;
try {
const command = `${join(bin, 'codex')} --model o3 --dangerously-bypass-approvals-and-sandbox`;
({ sessionId } = await exitedSession(w, command));
Object.assign(sessions.get(sessionId), { repoPath: tmpdir(), worktreePath, branchName: 'b/restart-test' });
const back = next(w, (m) => m.type === 'session-created' && m.sessionId === sessionId);
const refused = next(w, (m) => m.type === 'spawn-error');
w.send(JSON.stringify({ type: 'restart', sessionId }));
w.send(JSON.stringify({ type: 'restart', sessionId })); // a second click
const msg = await back;
expect(msg.command).toBe(`codex resume ${id} --dangerously-bypass-approvals-and-sandbox -m "o3"`);
expect(msg.focus).toBe(true);
expect((await refused).error).toMatch(/already (running|restarting)/);
expect(sessions.get(sessionId).exited).toBe(false);
// Codex's update prompt is off for every Codex Agent 007 starts.
await vi.waitFor(() => expect(readFileSync(log, 'utf8').trim().split('\n')).toHaveLength(2));
for (const argv of readFileSync(log, 'utf8').trim().split('\n')) expect(argv).toContain('-c check_for_update_on_startup=false');
expect(readFileSync(log, 'utf8').trim().split('\n')[1]).toMatch(new RegExp(`resume ${id} `));
} finally {
restore();
if (savedHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = savedHome;
config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
if (sessionId && sessions.has(sessionId)) {
Object.assign(sessions.get(sessionId), { repoPath: null, worktreePath: null });
w.send(JSON.stringify({ type: 'kill', sessionId }));
}
rmSync(worktreePath, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
w.close();
}
}, 15000);
it.skipIf(process.platform === 'win32')('Restart resumes an exited Claude Code agent by its exact conversation', async () => {
const { bin, log, restore } = fakeCliOnPath('claude');
const w = await open();
let sessionId;
try {
({ sessionId } = await exitedSession(w, `${join(bin, 'claude')} --model opus`));
const conversation = sessions.get(sessionId).claudeSessionId;
expect(conversation).toMatch(/^[0-9a-f-]{36}$/);
const back = next(w, (m) => m.type === 'session-created' && m.sessionId === sessionId);
w.send(JSON.stringify({ type: 'restart', sessionId }));
expect((await back).command).toBe(`claude --resume ${conversation} --model "opus"`);
await vi.waitFor(() => expect(readFileSync(log, 'utf8')).toContain(`--resume ${conversation}`));
// Running now: a Restart is refused rather than starting a second one.
const refused = next(w, (m) => m.type === 'spawn-error');
w.send(JSON.stringify({ type: 'restart', sessionId }));
expect((await refused).error).toMatch(/already running/);
} finally {
restore();
if (sessionId) w.send(JSON.stringify({ type: 'kill', sessionId }));
w.close();
}
}, 15000);
it.skipIf(process.platform === 'win32')('Restart refuses an exited agent that is being closed, and closing one tells every window', async () => {
const { bin, restore } = fakeCliOnPath('claude');
const w = await open();
try {
const { sessionId } = await exitedSession(w, `${join(bin, 'claude')}`);
sessions.get(sessionId).closing = true; // killSession is mid-way through its git work
const refused = next(w, (m) => m.type === 'spawn-error');
w.send(JSON.stringify({ type: 'restart', sessionId }));
expect((await refused).error).toBe('Agent not found');
sessions.get(sessionId).closing = false;
const closed = next(w, (m) => m.type === 'session-ended' && m.sessionId === sessionId && m.closed);
w.send(JSON.stringify({ type: 'kill', sessionId }));
expect(await closed).toBeTruthy();
} finally {
restore();
w.close();
}
}, 15000);
// An orphan with no note (a record written before the CLI was noted, or a
// worktree discovered on disk) resumes by whatever its job card or the
// transcripts say — but that answer is a guess, and the new session must
// NOT record it as fact, or a wrong one could never be corrected. The card,
// when there is one, also lends the resume its permission mode.
it.skipIf(process.platform === 'win32')('re-adopting an orphan with no note resumes by card and transcript, under the card\'s mode, and records nothing', async () => {
const { bin, restore } = fakeCodexOnPath();
const worktreePath = fakeOrphanWorktree();
const repoPath = tmpdir();
const branchName = 'b/no-note-test';
// A Codex rollout whose cwd is the worktree, under a fake CODEX_HOME.
const codexHome = mkdtempSync(join(tmpdir(), 'a007-codex-home-'));
const day = join(codexHome, 'sessions', '2026', '09', '16');
mkdirSync(day, { recursive: true });
writeFileSync(join(day, 'rollout-x.jsonl'), JSON.stringify({ type: 'session_meta', payload: { id: 'x', cwd: realpathSync.native(worktreePath), source: 'cli', thread_source: 'user' } }) + '\n');
const savedHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
const savedRepos = config.repos;
config.repos = [...config.repos, { path: repoPath }];
const command = `${join(bin, 'codex')} --model o3`;
const w = await open();
let name, back, jobId;
try {
const created = next(w, (m) => m.type === 'session-created' && m.command === command);
w.send(JSON.stringify({ type: 'spawn', command }));
const { sessionId, name: spawned } = await created;
name = spawned;
Object.assign(sessions.get(sessionId), { repoPath, worktreePath, branchName });
const parked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
w.send(JSON.stringify({ type: 'kill', sessionId }));
const orphan = (await parked).orphans.find(o => o.name === name);
orphans.get(orphan.id).agent = null; // as a record from before the note, or a discovered worktree
// No card yet: the transcript answers, bare, and the session keeps no note.
let readopted = next(w, (m) => m.type === 'session-created' && m.name === name && m.sessionId !== sessionId);
w.send(JSON.stringify({ type: 'restart', orphanId: orphan.id }));
back = await readopted;
expect(back.command).toBe('codex resume');
expect(sessions.get(back.sessionId).agent).toBeNull();
expect(config.activeSessions.find(s => s.worktreePath === worktreePath).agent).toBeNull();
// Park it again; this time a read-only card on the branch is waiting.
const reparked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
const again = (await reparked).orphans.find(o => o.name === name);
expect(again.agent).toBeNull(); // still no note: nothing was learned for certain
const { job } = addJob({ title: 'read only codex', repoPath, agent: 'codex', permissionMode: 'plan' }, () => {});
jobId = job.id;
Object.assign(job, { state: 'in-progress', branchName, agentSessionId: null });
readopted = next(w, (m) => m.type === 'session-created' && m.name === name && m.sessionId !== back.sessionId);
w.send(JSON.stringify({ type: 'restart', orphanId: again.id }));
back = await readopted;
expect(back.command).toBe('codex resume --sandbox read-only');
expect(allJobs().find(j => j.id === jobId).agentSessionId).toBe(back.sessionId); // relinked to its card
const rec = config.activeSessions.find(s => s.worktreePath === worktreePath);
expect(rec.agent).toBeNull();
// Under a card's mode the session records no flags of its own — the
// card, or the board once the card is gone, decides again next time —
// even though its resume command carries `--sandbox read-only`.
expect(rec.permissionFlags).toEqual([]);
expect(sessions.get(back.sessionId).permissionFlags).toEqual([]);
} finally {
restore();
if (savedHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = savedHome;
config.repos = savedRepos;
config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
for (const o of [...orphans.values()]) if (o.worktreePath === worktreePath) orphans.delete(o.id);
if (back && sessions.has(back.sessionId)) {
Object.assign(sessions.get(back.sessionId), { repoPath: null, worktreePath: null, jobId: null });
w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
}
if (jobId) await deleteJob(jobId, () => {});
if (name) codenamePool.recycle(name);
rmSync(worktreePath, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
w.close();
}
}, 20000);
// A board worker parked by a restart (or a close) keeps its card. Re-spawned,
// it is that card's worker again: a board tab, counted toward the cap, told
// once to carry on while its card is In progress, and retired like any other.
it.skipIf(process.platform === 'win32')('a re-spawned board worker keeps its card, counts toward the cap, is nudged once, and leaves as a board worker', async () => {
const { bin, restore } = fakeCodexOnPath();
const worktreePath = fakeOrphanWorktree();
const repoPath = tmpdir();
const branchName = 'b/respawn-board-test';
const savedRepos = config.repos;
config.repos = [...config.repos, { path: repoPath }];
const command = `${join(bin, 'codex')} --model o3`;
const w = await open();
let name, back, jobId, queuedId;
const park = async (sessionId) => {
const parked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
w.send(JSON.stringify({ type: 'kill', sessionId }));
return (await parked).orphans.find(o => o.name === name);
};
const respawn = async (orphan, from) => {
const readopted = next(w, (m) => m.type === 'session-created' && m.name === name && m.sessionId !== from);
w.send(JSON.stringify({ type: 'restart', orphanId: orphan.id }));
return readopted;
};
try {
const { job } = addJob({ title: 'respawn board card', repoPath, agent: 'codex' }, () => {});
jobId = job.id;
const created = next(w, (m) => m.type === 'session-created' && m.command === command);
w.send(JSON.stringify({ type: 'spawn', command }));
const { sessionId, name: spawned } = await created;
name = spawned;
// As the dispatcher would have made it.
Object.assign(sessions.get(sessionId), { repoPath, worktreePath, branchName, spawnedBy: 'board', jobId, origin: 'board' });
Object.assign(job, { state: 'review', branchName, agentSessionId: sessionId });
let orphan = await park(sessionId);
expect(orphan.jobId).toBe(jobId);
expect(orphan.origin).toBe('board');
// Card in Review: a board worker again, but nothing to nudge.
back = await respawn(orphan, sessionId);
expect(back.spawnedBy).toBe('board');
expect(back.jobId).toBe(jobId);
expect(pendingMessages(back.sessionId)).toBe(0);
// Card in progress, and an older record with no jobId: found by branch.
job.state = 'in-progress';
orphan = await park(back.sessionId);
orphans.get(orphan.id).jobId = null;
const before = back.sessionId;
back = await respawn(orphan, before);
const session = sessions.get(back.sessionId);
expect(session.spawnedBy).toBe('board');
expect(session.jobId).toBe(jobId);
expect(job.agentSessionId).toBe(back.sessionId);
expect(config.activeSessions.find(s => s.worktreePath === worktreePath).jobId).toBe(jobId);
// One line queued, typed once it rests at its prompt.
expect(pendingMessages(back.sessionId)).toBe(1);
// It holds its repo's slot: a queued card there waits.
const live = new Set([...sessions].filter(([, s]) => !s.exited).map(([id]) => id));
expect(countInFlightByRepo(allJobs(), live).get(repoPath)).toBe(1);
const { job: queued } = addJob({ title: 'respawn queued card', repoPath, agent: 'codex' }, () => {});
queuedId = queued.id;
expect(selectDispatchableJobs(allJobs(), { maxPerRepo: 1, liveSessionIds: live }).map(j => j.id)).not.toContain(queuedId);
// Filed as done: it ends as a board worker, so its tab and desk go.
const ended = next(w, (m) => m.type === 'session-ended' && m.sessionId === back.sessionId);
await moveJob(jobId, 'done', () => {}, { killSession });
expect(await ended).toMatchObject({ spawnedBy: 'board', jobId });
back = null;
} finally {
restore();
config.repos = savedRepos;
config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
for (const o of [...orphans.values()]) if (o.worktreePath === worktreePath) orphans.delete(o.id);
if (back && sessions.has(back.sessionId)) {
Object.assign(sessions.get(back.sessionId), { repoPath: null, worktreePath: null, jobId: null });
w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
}
for (const id of [jobId, queuedId]) if (id && allJobs().some(j => j.id === id)) await deleteJob(id, () => {});
if (name) codenamePool.recycle(name);
rmSync(worktreePath, { recursive: true, force: true });
w.close();
}
}, 20000);
});
// #137: `git worktree remove` under a 5 s timeout was killed mid-delete on a
// node_modules-sized tree, leaving the orphan listed and its folder half gone.
describe('delete-orphan', () => {
const open = () => new Promise((res, rej) => {
const s = new WebSocket(wsUrl); s.on('open', () => res(s)); s.on('error', rej);
});
const next = (ws, pred, ms = 8000) => new Promise((res) => {
const to = setTimeout(() => { ws.off('message', h); res(null); }, ms);
const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); res(m); } };
ws.on('message', h);
});
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
function orphanWorktree(name) {
const repo = mkdtempSync(join(tmpdir(), 'a007-del-orphan-'));
git('init', '-q', repo);
git('-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'base');
const worktreePath = join(process.env.AGENT007_WORKTREE_DIR, 'del-orphan', name);
git('-C', repo, 'worktree', 'add', '-q', worktreePath, '-b', `bill10/${name}`);
const id = `orphan-${name}`;
orphans.set(id, { id, name, repoPath: repo, worktreePath, branchName: `bill10/${name}` });
return { id, repo, worktreePath };
}
async function deleteOrphan(id) {
const w = await open();
const done = next(w, (m) => m.type === 'notification' && /orphan/i.test(m.message || ''));
w.send(JSON.stringify({ type: 'delete-orphan', orphanId: id }));
const note = await done;
w.close();
return note;
}
it('removes a worktree full of ignored files completely', async () => {
const { id, repo, worktreePath } = orphanWorktree('del-heavy');
writeFileSync(join(worktreePath, '.gitignore'), 'node_modules\n');
for (let d = 0; d < 20; d++) {
mkdirSync(join(worktreePath, 'node_modules', `p${d}`), { recursive: true });
for (let f = 0; f < 100; f++) writeFileSync(join(worktreePath, 'node_modules', `p${d}`, `f${f}.js`), 'x');
}
expect((await deleteOrphan(id)).level).toBe('info');
expect(orphans.has(id)).toBe(false);
expect(existsSync(worktreePath)).toBe(false);
expect(git('-C', repo, 'worktree', 'list')).not.toContain('del-heavy');
expect(git('-C', repo, 'branch', '--list')).not.toContain('del-heavy');
}, 20000);
it('finishes a worktree whose folder is already gone', async () => {
const { id, repo, worktreePath } = orphanWorktree('del-half');
rmSync(worktreePath, { recursive: true, force: true });
expect((await deleteOrphan(id)).level).toBe('info');
expect(orphans.has(id)).toBe(false);
expect(git('-C', repo, 'worktree', 'list')).not.toContain('del-half');
expect(git('-C', repo, 'branch', '--list')).not.toContain('del-half');
}, 20000);
it('finishes a worktree that is partly deleted but still on disk', async () => {
const { id, repo, worktreePath } = orphanWorktree('del-partial');
rmSync(join(worktreePath, '.git'));
expect((await deleteOrphan(id)).level).toBe('info');
expect(orphans.has(id)).toBe(false);
expect(existsSync(worktreePath)).toBe(false);
expect(git('-C', repo, 'worktree', 'list')).not.toContain('del-partial');
}, 20000);
});
describe('remove-repo', () => {
const open = () => new Promise((res, rej) => {
const s = new WebSocket(wsUrl); s.on('open', () => res(s)); s.on('error', rej);
});
const next = (ws, pred, ms = 15000) => new Promise((res) => {
const to = setTimeout(() => { ws.off('message', h); res(null); }, ms);
const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); res(m); } };
ws.on('message', h);
});
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
function makeRepo() {
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'a007-rm-repo-')));
git('init', '-q', repo);
git('-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'base');
git('-C', repo, 'config', 'user.name', 'bill10');
git('-C', repo, 'config', 'user.email', 't@t');
return repo;
}
async function setup() {
const repo = makeRepo();
const w = await open();
w.send(JSON.stringify({ type: 'add-repo', path: repo }));
await next(w, m => m.type === 'repos-list' && m.repos.some(r => r.path === repo));
const spawn = async () => {
const created = next(w, m => m.type === 'session-created' && m.repoPath === repo);
w.send(JSON.stringify({ type: 'spawn', command: 'cat', repoPath: repo }));
return created;
};
return { repo, w, spawn };
}
it('previews who is lost, and cancelling changes nothing', async () => {
const { repo, w, spawn } = await setup();
const a = await spawn(), b = await spawn();
const wtA = sessions.get(a.sessionId).worktreePath;
writeFileSync(join(wtA, 'scratch.txt'), 'x'); // uncommitted
const wtB = sessions.get(b.sessionId).worktreePath;
writeFileSync(join(wtB, 'f.txt'), 'x');
git('-C', wtB, 'add', '-A');
for (const n of [1, 2]) git('-C', wtB, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', `c${n}`);
try {
w.send(JSON.stringify({ type: 'remove-repo-check', path: repo }));
const preview = await next(w, m => m.type === 'repo-removal-preview');
expect(preview.slug).toBe(basename(repo));
const byName = Object.fromEntries(preview.agents.map(x => [x.name, x]));
expect(byName[a.name]).toMatchObject({ kind: 'agent', uncommitted: true });
expect(byName[b.name]).toMatchObject({ kind: 'agent', uncommitted: false, unpushed: 2 });
// Cancel sends nothing further: everything is still here.
expect(config.repos.some(r => r.path === repo)).toBe(true);
expect(sessions.has(a.sessionId) && sessions.has(b.sessionId)).toBe(true);
expect(existsSync(wtA) && existsSync(wtB)).toBe(true);
} finally {
w.send(JSON.stringify({ type: 'remove-repo', path: repo }));
await next(w, m => m.type === 'repos-list' && !m.repos.some(r => r.path === repo));
w.close();
}
}, 40000);
it('closes its agents, deletes worktrees and orphans, returns the card, and tells every client', async () => {
const { repo, w, spawn } = await setup();
const other = await open();
const a = await spawn(), b = await spawn();
const wtA = sessions.get(a.sessionId).worktreePath, brA = sessions.get(a.sessionId).branchName;
const wtB = sessions.get(b.sessionId).worktreePath;
writeFileSync(join(wtA, 'scratch.txt'), 'x');
git('-C', wtB, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'unpushed');
// An orphan of the same repo, and a card on Agent A.
const orphanWt = join(process.env.AGENT007_WORKTREE_DIR, 'rm-repo', 'ghost');
git('-C', repo, 'worktree', 'add', '-q', orphanWt, '-b', 'bill10/ghost');
orphans.set('orphan-rm-ghost', { id: 'orphan-rm-ghost', name: 'Ghost', repoPath: repo, worktreePath: orphanWt, branchName: 'bill10/ghost' });
const { job } = addJob({ title: 'rm-repo card', repoPath: repo }, () => {});
Object.assign(job, { state: 'in-progress', branchName: brA, worktreePath: wtA, agentSessionId: a.sessionId });
try {
const otherSees = [];
other.on('message', d => otherSees.push(JSON.parse(d)));
w.send(JSON.stringify({ type: 'remove-repo', path: repo }));
await next(w, m => m.type === 'repos-list' && !m.repos.some(r => r.path === repo));
expect(config.repos.some(r => r.path === repo)).toBe(false);
expect(sessions.has(a.sessionId) || sessions.has(b.sessionId)).toBe(false);
expect(orphans.has('orphan-rm-ghost')).toBe(false);
expect([...orphans.values()].some(o => o.repoPath === repo)).toBe(false);
for (const p of [wtA, wtB, orphanWt]) expect(existsSync(p)).toBe(false);
expect(existsSync(repo)).toBe(true); // the folder itself is never touched
expect(git('-C', repo, 'branch', '--list')).not.toMatch(/bill10\/(?!ghost)\S+|ghost/);
expect(job).toMatchObject({ state: 'todo', agentSessionId: null, lastError: 'repo removed from Agent 007' });
await new Promise(r => setTimeout(r, 300));
expect(otherSees.filter(m => m.type === 'session-ended' && m.closed).map(m => m.sessionId).sort())
.toEqual([a.sessionId, b.sessionId].sort());
expect(otherSees.some(m => m.type === 'repos-list' && !m.repos.some(r => r.path === repo))).toBe(true);
} finally {
await deleteJob(job.id, () => {});
other.close(); w.close();
}
}, 40000);
});