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 , 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); });