Merge pull request #741: feat(discord): two-way Discord bridge with a thread per tab

This commit is contained in:
Codeman maintainer committed 2026-11-04 16:59:16 +01:00
commit 7c8d440928
39 files changed
+4940 -24

No files matched your search

+17 -1
View File
@@ -4,7 +4,7 @@
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
@@ -120,6 +120,22 @@ describe('/api/config-backups', () => {
}
});
it('a restored discord.json reaches the running bridge (it caches its config)', async () => {
const reloadDiscord = vi.fn(async () => undefined);
const { app } = await createRouteTestHarness((a) => registerConfigBackupRoutes(a, { reloadDiscord }));
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":false}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":true}', { mode: 0o600 });
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.json().data.restored).toContain('discord.json');
expect(reloadDiscord).toHaveBeenCalledTimes(1);
// A restore that does not carry the file leaves the bridge alone.
rmSync(join(dirname(SETTINGS_PATH), 'discord.json'), { force: true });
const id2 = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
await app.inject({ method: 'POST', url: `/api/config-backups/${id2}/restore` });
expect(reloadDiscord).toHaveBeenCalledTimes(1);
});
it('a restored intents.json is not reverted by the next captured prompt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-'));
@@ -0,0 +1,196 @@
/**
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
* back whole, in order, on its own line, and survive the split into Discord posts.
*
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
* Port: N/A
*
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
* transcript formats and are covered by their reader's tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { splitForDiscord } from '../../src/discord-bridge.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
const QUESTIONS = [
'Should the sweep run daily or hourly?',
'Do you want archived threads deleted as well?',
'Is one channel per Codeman acceptable?',
];
const INTRO = 'I have three questions before I continue:';
const OUTRO = 'Once you answer, I will apply the change.';
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
/** Every question present, in order, each on a line of its own. */
function expectWholeQuestions(text: string): void {
let from = 0;
for (const q of QUESTIONS) {
const at = text.indexOf(q, from);
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
from = at + q.length;
}
const lines = text.split('\n').map((l) => l.trim());
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
expect(text).toContain(INTRO);
expect(text).toContain(OUTRO);
// What goes to Discord: the same questions after the split, joined back.
expectWholeQuestionsAfterSplit(text);
}
function expectWholeQuestionsAfterSplit(text: string): void {
const joined = splitForDiscord(text).join('\n');
for (const q of QUESTIONS) expect(joined).toContain(q);
}
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown };
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('the last message with several questions, per CLI', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
let home: string;
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
async function lastText(): Promise<string> {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
return JSON.parse(res.body).data.text as string;
}
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
process.env.HOME = home;
process.env.CODEX_HOME = join(home, '.codex');
harness = await createHarness();
session = harness.ctx._session;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
rmSync(home, { recursive: true, force: true });
await harness.app.close();
});
it('claude: reads the whole message from the JSONL transcript', async () => {
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
session.mode = 'claude';
session.claudeSessionId = id;
session.adoptClaudeSessionId = () => undefined;
const dir = join(home, '.claude', 'projects', '-workspace');
mkdirSync(dir, { recursive: true });
const row = (type: string, text: string, ts: string) => ({
type,
timestamp: ts,
message: { content: [{ type: 'text', text }] },
});
writeFileSync(
join(dir, `${id}.jsonl`),
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
.map((e) => JSON.stringify(e))
.join('\n')
);
const text = await lastText();
expect(text).toBe(MESSAGE);
expectWholeQuestions(text);
});
it('codex: reads the whole message from the rollout', async () => {
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const workdir = join(home, 'work');
mkdirSync(workdir, { recursive: true });
session.mode = 'codex';
session.workingDir = workdir;
session.codexConfig = { resumeSessionId: uuid };
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
const entries = [
{
timestamp: '2026-10-30T00:00:00Z',
type: 'session_meta',
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
},
{
timestamp: '2026-10-30T00:00:01Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'plan the cleanup' },
},
{
timestamp: '2026-10-30T00:00:02Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
},
];
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
utimesSync(file, 1_750_000_000, 1_750_000_000);
const text = await lastText();
expectWholeQuestions(text);
});
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`${mode}: reads the whole message from the pane`, async () => {
session.mode = mode;
session.terminalBuffer = [
'› plan the cleanup',
'',
'model · project · main · Ready · Context 100% left',
'',
'• Called write_file',
'',
MESSAGE,
].join('\n');
expectWholeQuestions(await lastText());
});
}
it('copilot: reads the whole message from the pane screen', async () => {
session.mode = 'copilot';
const screen = [
' ❯ plan the cleanup 10:01',
'',
` ● ${INTRO}`,
'',
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
'',
` ${OUTRO}`,
'',
' ~/project Session: 1 AIC used',
'────────────────────────────────',
' ❯ ',
'────────────────────────────────',
' ← open sidebar · / commands · ? help',
].join('\n');
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
expectWholeQuestions(await lastText());
});
});
+328
View File
@@ -0,0 +1,328 @@
/**
* @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the
* settings routes are admin only in multi-user mode. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js';
import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js';
const TOKEN = 'MTA1.secret.token-value-1234567890';
const FULL = {
enabled: true,
token: TOKEN,
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
};
let dir: string;
let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>;
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
const bridge = new DiscordBridge({
configDir: dir,
fetchImpl,
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
const h = await createRouteTestHarness(
(app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }),
authUser ? { authUser } : undefined
);
return { ...h, bridge };
}
beforeEach(() => {
_resetNotifyBuckets();
dir = mkdtempSync(join(tmpdir(), 'discord-routes-'));
fetchImpl = vi.fn<DiscordFetch>(
async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 })
);
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('/api/discord', () => {
it('starts disabled with no token', async () => {
const { app } = await harness();
const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' });
expect(data).not.toHaveProperty('token');
});
it('saves, masks the token in every response and writes the file 0600', async () => {
const { app, bridge } = await harness();
const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
expect(put.statusCode).toBe(200);
expect(put.body).not.toContain('secret');
expect(put.json().data).toMatchObject({ enabled: true, hasToken: true });
const get = await app.inject({ method: 'GET', url: '/api/discord' });
expect(get.body).not.toContain('secret');
expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600);
expect((await readDiscordConfig(dir)).token).toBe(TOKEN);
bridge.stop();
});
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
});
const cfg = await readDiscordConfig(dir);
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
expect(cleared.json().data.hasToken).toBe(false);
bridge.stop();
});
it('stores startupHint (default on) and typedHint (default off) and returns them', async () => {
const { app, bridge } = await harness();
const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(first).toMatchObject({ startupHint: true, typedHint: false });
const put = await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { typedHint: true, startupHint: false },
});
expect(put.json().data).toMatchObject({ startupHint: false, typedHint: true });
bridge.stop();
});
it('refuses a bad id, an incomplete enable, and unknown fields', async () => {
const { app } = await harness();
const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } });
expect(bad.statusCode).toBe(400);
const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } });
expect(incomplete.statusCode).toBe(400);
const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } });
expect(extra.statusCode).toBe(400);
});
it('POST /test needs a saved token, then posts to the parent channel', async () => {
const { app, bridge } = await harness();
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const res = await app.inject({ method: 'POST', url: '/api/discord/test' });
expect(res.json().data.ok).toBe(true);
expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages');
bridge.stop();
});
it('is admin only in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'bob', role: 'user' });
expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403);
expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403);
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403);
});
});
describe('POST /api/sessions/:id/discord-notify', () => {
const url = '/api/sessions/test-session-1/discord-notify';
it('is refused while the bridge is off, and posts into the session thread once on', async () => {
const { app, bridge } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } });
expect(off.statusCode).toBe(409);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } });
expect(on.json().data.ok).toBe(true);
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true);
bridge.stop();
});
it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => {
const { app, bridge, ctx } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ok.statusCode).toBe(200);
ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!);
const ambiguous = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ambiguous.statusCode).toBe(404);
const short = await app.inject({
method: 'POST',
url: '/api/sessions/test/discord-notify',
payload: { message: 'hi' },
});
expect(short.statusCode).toBe(404);
bridge.stop();
});
it("does not reveal another user's session through its id prefix (multi-user)", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' });
(ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(404);
expect(res.json().error).not.toContain('test-session-1');
bridge.stop();
});
it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } });
const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } });
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(second.json().data.deduped).toBeUndefined();
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2);
bridge.stop();
});
it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 }));
const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 401 });
bridge.stop();
});
it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const codes: number[] = [];
for (let i = 0; i < 8; i++) {
codes.push(
(await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode
);
}
expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]);
expect(codes.slice(6)).toEqual([429, 429]);
const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } });
expect(limited.json().errorCode).toBe('RATE_LIMITED');
expect(limited.headers['retry-after']).toBe('60');
bridge.stop();
});
it('does not spend rate-limit tokens on a request refused for its file', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
for (let i = 0; i < 8; i++) {
const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } });
expect(bad.statusCode).toBe(400);
}
expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200);
bridge.stop();
});
it('rejects an empty or oversized message', async () => {
const { app } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400);
});
});
describe('discord-notify with a file', () => {
let work: string;
let outside: string;
const url = '/api/sessions/test-session-1/discord-notify';
async function ready() {
const h = await harness();
(h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work;
await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
return h;
}
const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) =>
app.inject({ method: 'POST', url, payload: { message: 'log attached', file } });
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), 'discord-work-'));
outside = mkdtempSync(join(tmpdir(), 'discord-outside-'));
writeFileSync(join(work, 'run.log'), 'hello');
writeFileSync(join(outside, 'secret.txt'), 'nope');
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
it('uploads a file inside the session folder as multipart', async () => {
const { app, bridge } = await ready();
const res = await post(app, join(work, 'run.log'));
expect(res.json().data.ok).toBe(true);
const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData;
expect((form.get('files[0]') as File).name).toBe('run.log');
bridge.stop();
});
it.each([
['a relative path', () => 'run.log'],
['a file outside the folder', () => join(outside, 'secret.txt')],
['the folder itself', () => work],
['a missing file', () => join(work, 'nope.log')],
])('refuses %s', async (_n, path) => {
const { app, bridge } = await ready();
const res = await post(app, path());
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses to send when the session folder is not a directory it can probe', async () => {
const { app, ctx, bridge } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log');
const res = await post(app, join(work, 'run.log'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/session folder/);
bridge.stop();
});
it('probes the file before realpath: a path that is not a regular file is refused', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'dir'));
expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/);
bridge.stop();
});
it('refuses a symlink that points outside the folder', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'sub'));
symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt'));
expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400);
bridge.stop();
});
it('refuses a file over 8 MB', async () => {
const { app, bridge } = await ready();
writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1));
const res = await post(app, join(work, 'big.bin'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/8 MB/);
bridge.stop();
});
it('refuses a file for a remote session', async () => {
const { app, bridge, ctx } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' };
expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400);
bridge.stop();
});
});